- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
387 lines
26 KiB
JavaScript
387 lines
26 KiB
JavaScript
// test/sim/loadout.test.js — operator loadouts in the sim (DESIGN §16): resolving (chessId, skillIndex, moduleId) defs
|
||
// (simdata), the per-unit loadout fields of a BattleSpec (spec.js), the kit loader's skill selection (content/index.js)
|
||
// and the kit coverage report (tools/kit-coverage.mjs).
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { DataSource, getDefaultSource, resolveLoadout, loadoutRecord, hasGeneratedData, isLoadoutView } from '../../server/sim/simdata.js';
|
||
import { Battle } from '../../server/sim/Battle.js';
|
||
import { loadoutOptions, resolveLoadout as resolveSeatLoadout } from '../../shared/protocol.js';
|
||
import { buildBattleSpec, createBattleFromSpec, resultDigest, withUnitLoadouts, jsonClone } from '../../server/sim/spec.js';
|
||
import { setupUnitKit, selectSkillSpec, skillSpecSource } from '../../server/sim/content/index.js';
|
||
import { genericKit } from '../../server/sim/content/generic.js';
|
||
import { makeBattle } from '../helpers/battleHarness.js';
|
||
import { kitCoverage } from '../../tools/kit-coverage.mjs';
|
||
|
||
const skip = !hasGeneratedData() && 'no generated data (run node tools/build-data.mjs)';
|
||
const ds = getDefaultSource();
|
||
const C = ds.raw.chess;
|
||
/** A fresh browser-like DataSource (no research fallback, empty caches). */
|
||
const freshDs = () => new DataSource(ds.raw, null);
|
||
|
||
const INSIDE = 'chess_char_1_01_a'; // 隐现 (T1, E1: S1–S2, default S2)
|
||
const MLYSS = 'chess_char_6_11_b'; // 缪尔赛思 精锐 (S1–S3, default S3; modules 002 default / 003)
|
||
const WTRMAN = 'token_10030_mlyss_wtrman';
|
||
|
||
test('resolveLoadout: legal choices resolve, anything else falls back to the default', { skip }, () => {
|
||
const n = C[INSIDE];
|
||
assert.deepEqual(resolveLoadout(n, null), { skillIndex: 1, moduleId: null, skillIsDefault: true, moduleIsDefault: true, isDefault: true });
|
||
assert.equal(resolveLoadout(n, { skillIndex: 0 }).skillIndex, 0);
|
||
assert.equal(resolveLoadout(n, { skill: 0 }).skillIndex, 0, 'client shape { skill, module } accepted');
|
||
assert.equal(resolveLoadout(n, { skillIndex: 2 }).skillIndex, 1, 'S3 is not unlocked at E1 → default');
|
||
for (const bad of [-1, 1.5, '0', null, 9]) assert.equal(resolveLoadout(n, { skillIndex: bad }).skillIndex, 1, `skillIndex ${bad}`);
|
||
assert.equal(resolveLoadout(n, { moduleId: 'uniequip_002_inside' }).moduleId, null, 'normal chess have no module choice');
|
||
const g = C[MLYSS];
|
||
assert.equal(resolveLoadout(g, {}).moduleId, 'uniequip_002_mlyss');
|
||
assert.equal(resolveLoadout(g, { moduleId: 'none' }).moduleId, 'none');
|
||
assert.equal(resolveLoadout(g, { module: 'uniequip_003_mlyss' }).moduleId, 'uniequip_003_mlyss');
|
||
assert.equal(resolveLoadout(g, { moduleId: 'uniequip_002_inside' }).moduleId, 'uniequip_002_mlyss', "another character's module → default");
|
||
assert.equal(resolveLoadout(g, { moduleId: 'uniequip_001_mlyss' }).moduleId, 'uniequip_002_mlyss', 'INITIAL is not a choice ("none" is)');
|
||
assert.equal(resolveLoadout(g, { skillIndex: 2, moduleId: 'uniequip_002_mlyss' }).isDefault, true);
|
||
// research-shaped record (no skills list): always the default
|
||
assert.equal(resolveLoadout({ skill: { skillId: 'x', index: 0 } }, { skillIndex: 1 }).skillIndex, 0);
|
||
assert.equal(loadoutRecord(n, resolveLoadout(n, null)), n, 'default loadout = the record itself');
|
||
});
|
||
|
||
test('every choice the lobby accepts (shared loadoutOptions) resolves to itself in the sim; defaults agree', { skip }, () => {
|
||
const d = freshDs();
|
||
let n = 0;
|
||
for (const c of Object.values(C)) {
|
||
if (c.isGolden || !c.visible) continue;
|
||
const g = C[c.goldenId];
|
||
const opt = loadoutOptions(c, g);
|
||
assert.equal(opt.defaultSkill, c.skill.index, `${c.chessId}: default skill`);
|
||
assert.equal(opt.defaultModule, resolveLoadout(g, null).moduleId, `${c.chessId}: default module`);
|
||
for (const skill of opt.skills) {
|
||
for (const module of opt.modules) {
|
||
const seat = { [c.chessId]: { skill, module } };
|
||
for (const rec of [c, g]) {
|
||
const lo = resolveSeatLoadout(seat, rec, (id) => C[id]); // what PlayerState puts on the unit
|
||
const def = d.getChess(rec.chessId, lo);
|
||
assert.equal(def.loadout.skillIndex, skill, `${rec.chessId} S${skill + 1}`);
|
||
assert.equal(def.skill.id, rec.skills.find((s) => s.index === skill).skillId);
|
||
if (rec.isGolden) assert.equal(def.loadout.moduleId, module, `${rec.chessId} ${module}`);
|
||
n++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert.ok(n > 1000, `${n} unit defs resolved`);
|
||
});
|
||
|
||
test('getChess(id, loadout): selected skill (bb, SP, trigger) and module (stats, trait, talents); cached; default = same def', { skip }, () => {
|
||
const d = freshDs();
|
||
const def0 = d.getChess(INSIDE);
|
||
assert.equal(d.getChess(INSIDE, { skillIndex: 1 }), def0, 'explicit default loadout = the default def');
|
||
assert.equal(d.getChess(INSIDE, { skillIndex: 7 }), def0, 'illegal loadout = the default def');
|
||
const s1 = d.getChess(INSIDE, { skillIndex: 0 });
|
||
assert.notEqual(s1, def0);
|
||
assert.equal(d.getChess(INSIDE, { skillIndex: 0 }), s1, 'variant defs are cached');
|
||
assert.equal(s1.id, INSIDE, 'def id stays the chess id');
|
||
assert.equal(s1.skill.id, 'skchr_inside_1');
|
||
assert.deepEqual(s1.skill.bb, C[INSIDE].skills[0].bb);
|
||
assert.equal(s1.skill.spCost, C[INSIDE].skills[0].spCost);
|
||
assert.deepEqual(s1.loadout, { skillIndex: 0, moduleId: null, skillIsDefault: false, moduleIsDefault: true, isDefault: false });
|
||
assert.deepEqual(s1.stats, def0.stats, 'a skill choice never changes stats');
|
||
assert.ok(Object.isFrozen(s1) && Object.isFrozen(s1.skill.bb), 'variant defs are frozen like every def');
|
||
assert.equal(C[INSIDE].skill.skillId, 'skchr_inside_2', 'the raw record is never mutated');
|
||
// per-skill trigger (PRTS 技能策略): 角峰's MANUAL skills both cast when hit (the 重装 row covers every skill); 古米's
|
||
// AUTO S1 keeps its own rule (DEFAULT in data, its kit: an injured ally of the skill range), his MANUAL S2 TAKE_DAMAGE
|
||
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 0 }).skill.trigger.rule, 'TAKE_DAMAGE');
|
||
assert.equal(d.getChess('chess_char_1_02_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
|
||
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 0 }).skill.trigger.rule, 'DEFAULT');
|
||
assert.equal(d.getChess('chess_char_1_10_a', { skillIndex: 1 }).skill.trigger.rule, 'TAKE_DAMAGE');
|
||
|
||
// modules on the elite: statsBase + module attr; trait / talents of that module; 'none' = the base
|
||
const g = C[MLYSS];
|
||
const dm = d.getChess(MLYSS);
|
||
const m3 = d.getChess(MLYSS, { moduleId: 'uniequip_003_mlyss' });
|
||
const none = d.getChess(MLYSS, { moduleId: 'none' });
|
||
assert.equal(dm.stats.maxHp, 1893);
|
||
assert.equal(m3.stats.maxHp, g.statsBase.maxHp + 170);
|
||
assert.equal(m3.stats.atk, g.statsBase.atk + 28);
|
||
assert.equal(none.stats.maxHp, g.statsBase.maxHp);
|
||
assert.equal(m3.traitBb.atk_scale, 1.65, 'module trait upgrade');
|
||
assert.deepEqual(none.traitBb, g.traitBase.bb);
|
||
assert.equal(m3.raw.module.id, 'uniequip_003_mlyss');
|
||
assert.equal(m3.raw.module.active, true);
|
||
assert.equal(none.raw.module.active, false, 'no module ⇒ moduleOn(chess) false for kits');
|
||
assert.ok(m3.talents.some((t) => t.bb.runtime_cost === -1 && t.bb.cost === -2), 'module talent change applied');
|
||
assert.equal(m3.skill.id, dm.skill.id, 'a module choice keeps the skill');
|
||
const both = d.getChess(MLYSS, { skillIndex: 0, moduleId: 'none' });
|
||
assert.equal(both.skill.id, 'skchr_mlyss_1');
|
||
assert.equal(both.stats.maxHp, g.statsBase.maxHp);
|
||
});
|
||
|
||
test('getToken(id, owner, ownerLoadout): summons follow the owner\'s skill slot and module', { skip }, () => {
|
||
const d = freshDs();
|
||
const t0 = d.getToken(WTRMAN, MLYSS);
|
||
const t1 = d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' });
|
||
assert.equal(t0.skill.id, 'sktok_mlyss_wtrman_3');
|
||
assert.equal(t1.skill.id, 'sktok_mlyss_wtrman_1', '流形 uses the skill of the owner\'s selected slot');
|
||
assert.equal(t0.talents[0].bb.scale, 1);
|
||
assert.equal(t1.talents[0].bb.scale, 0.9, 'no module ⇒ 90% copy (module isToken part not applied)');
|
||
assert.equal(d.getToken(WTRMAN, MLYSS, { skillIndex: 2 }), t0, 'default owner loadout = the default token def');
|
||
assert.equal(d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' }), t1, 'cached per owner loadout');
|
||
// 凛御银灰: the talent's eagle follows the selected skill (S1 → eagle1)
|
||
const sv = d.getChess('chess_char_5_14_a', { skillIndex: 0 });
|
||
assert.equal(sv.talents[0].tokenKey, 'token_10057_svash2_eagle1');
|
||
assert.equal(d.getChess('chess_char_5_14_a').talents[0].tokenKey, 'token_10057_svash2_eagle2');
|
||
});
|
||
|
||
test('kit loader: skills[selected] → kit.skill (default only) → generic; talents / trait / install always from the kit', { skip }, () => {
|
||
const d = freshDs();
|
||
const specA = { kind: 'duration', duration: 1, mods: { atkPct: 0.1 } };
|
||
const specS1 = { kind: 'instant', mods: { atkPct: 0.2 } };
|
||
const talent = { install() {} };
|
||
const install = () => {};
|
||
const kitFn = () => ({ skill: specA, talents: [talent], trait: { priority: 'fly' }, install });
|
||
const battle = { opts: { kits: { [INSIDE]: kitFn } }, _handlerError(label, u, e) { throw e; } };
|
||
const unit = (def) => ({ kind: 'op', def });
|
||
// default skill: kit.skill
|
||
const k0 = setupUnitKit(battle, unit(d.getChess(INSIDE)), 'full');
|
||
assert.equal(k0.skill, specA);
|
||
// non-default, not authored: the generic spec of the SELECTED skill, kit talents / trait kept
|
||
const defS1 = d.getChess(INSIDE, { skillIndex: 0 });
|
||
const k1 = setupUnitKit(battle, unit(defS1), 'full');
|
||
assert.equal(k1.skillSource, 'generic');
|
||
assert.deepEqual(k1.skill, genericKit(defS1.skill.bb, defS1.raw, defS1).skill);
|
||
assert.deepEqual([k1.talents[0], k1.trait.priority, k1.install], [talent, 'fly', install]);
|
||
// authored per skill id
|
||
battle.opts.kits[INSIDE] = () => ({ skill: specA, skills: { skchr_inside_1: specS1 }, talents: [] });
|
||
const k2 = setupUnitKit(battle, unit(defS1), 'full');
|
||
assert.equal(k2.skill, specS1);
|
||
assert.equal(k2.skillSource, 'skills');
|
||
assert.equal(setupUnitKit(battle, unit(d.getChess(INSIDE)), 'full').skill, specA, 'skills map without the default id → kit.skill');
|
||
// an explicit null in skills means "never casts"
|
||
assert.equal(selectSkillSpec({ skill: specA, skills: { skchr_inside_1: null } }, {}, defS1.raw, defS1).skill, null);
|
||
// the kit receives the selected skill and its blackboard
|
||
let seen = null;
|
||
battle.opts.kits[INSIDE] = (bb, chess) => { seen = [bb, chess.skill.skillId]; return { skill: specA, talents: [] }; };
|
||
setupUnitKit(battle, unit(defS1), 'full');
|
||
assert.deepEqual(seen, [C[INSIDE].skills[0].bb, 'skchr_inside_1']);
|
||
// generic install of the generic skill chains after the kit's own install
|
||
const order = [];
|
||
const gi = selectSkillSpec({ skill: specA, talents: [], install: () => order.push('kit') }, {}, defS1.raw, { ...defS1, skill: { ...defS1.skill, bb: {} } });
|
||
assert.equal(typeof gi.install, 'function');
|
||
// coverage probe
|
||
assert.equal(skillSpecSource(d.getChess(INSIDE), { [INSIDE]: () => ({ skill: specA }) }), 'kit');
|
||
assert.equal(skillSpecSource(defS1, { [INSIDE]: () => ({ skill: specA }) }), 'generic');
|
||
assert.equal(skillSpecSource(defS1, { [INSIDE]: () => ({ skill: specA, skills: { skchr_inside_1: specS1 } }) }), 'skills');
|
||
assert.equal(skillSpecSource(defS1, {}), 'none');
|
||
});
|
||
|
||
/** A normal-field spec with one player's units and a real wave (act1autochess_01 on 战场#05). */
|
||
function normalSpec(units, { seed = 4242 } = {}) {
|
||
const tpl = ds.getWave('act1autochess_01');
|
||
const routes = tpl.routes;
|
||
const spawns = tpl.spawns.filter((s) => !s.action && !s.slot).map((s) => ({ time: s.time, enemyKey: s.key, routeIndex: s.routeIndex, count: s.count, interval: s.interval }));
|
||
return buildBattleSpec({
|
||
battleId: 'b1', fieldId: 'n:p1', kind: 'normal', seed, modeId: 'mode_multi_normal', round: 1, stageId: 'act2autochess_m01',
|
||
timeLimit: 90, players: [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units, bonds: {}, playerEffects: [] }],
|
||
spawns, routes, flags: {}, waveId: 'act1autochess_01',
|
||
});
|
||
}
|
||
|
||
test('BattleSpec: units carry skillIndex / moduleId (sanitised); a battle uses the selected skill and module', { skip }, () => {
|
||
const spec = normalSpec([
|
||
{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 },
|
||
{ uid: 2, chessId: MLYSS, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' },
|
||
{ uid: 3, chessId: 'chess_char_1_02_a', row: 10, col: 7, dir: 'RIGHT', skillIndex: 'x', moduleId: '../../etc' },
|
||
{ uid: 4, kind: 'token', tokenId: WTRMAN, ownerUid: 2, row: 12, col: 6, skillIndex: 1 },
|
||
]);
|
||
const u = spec.players[0].units;
|
||
assert.deepEqual([u[0].skillIndex, u[1].skillIndex, u[1].moduleId], [0, 0, 'uniequip_003_mlyss']);
|
||
assert.ok(!('skillIndex' in u[2]) && !('moduleId' in u[2]), 'malformed loadout fields dropped');
|
||
assert.ok(!('skillIndex' in u[3]), 'tokens carry no loadout');
|
||
assert.equal(JSON.stringify(jsonClone(spec)), JSON.stringify(spec), 'JSON-safe');
|
||
|
||
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||
const inside = b.allyUnits.find((x) => x.uid === 1);
|
||
assert.equal(inside.def.skill.id, 'skchr_inside_1');
|
||
assert.equal(inside.skill.id, 'skchr_inside_1', 'the unit\'s skill runtime is the selected skill');
|
||
assert.deepEqual(inside.skill.bb, C[INSIDE].skills[0].bb, 'with that skill\'s blackboard');
|
||
assert.equal(inside.skill.baseSpCost, C[INSIDE].skills[0].spCost);
|
||
const ml = b.allyUnits.find((x) => x.uid === 2);
|
||
assert.equal(ml.base.maxHp, C[MLYSS].statsBase.maxHp + 170, 'module attr applied to the elite\'s stats');
|
||
assert.equal(ml.base.atk, C[MLYSS].statsBase.atk + 28);
|
||
assert.equal(ml.skill.id, 'skchr_mlyss_1');
|
||
const jf = b.allyUnits.find((x) => x.uid === 3);
|
||
assert.equal(jf.skill.id, C.chess_char_1_02_a.skill.skillId, 'malformed loadout ⇒ default');
|
||
const tok = b.allyUnits.find((x) => x.uid === 4);
|
||
assert.ok(tok, '流形 piece created');
|
||
assert.equal(tok.def.skill?.id, 'sktok_mlyss_wtrman_1', '流形 piece follows its owner\'s S1');
|
||
// 落叶四季's isToken part makes the 流形 taunt (+1); the 100% copy belongs to 梳妆流形 only
|
||
assert.equal(tok.def.talents[0].bb.scale, 0.9);
|
||
assert.ok(tok.def.talents.some((t) => t.bb.taunt_level === 1), '流形 gets the module 003 token talent');
|
||
// the Battle still runs to the end
|
||
b.runToEnd(4000);
|
||
assert.ok(b.finished);
|
||
});
|
||
|
||
test('determinism: a spec with loadouts gives identical results on independent sources; the loadout matters', { skip }, () => {
|
||
const units = [
|
||
{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 },
|
||
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 8, dir: 'RIGHT', skillIndex: 0 },
|
||
{ uid: 3, chessId: MLYSS, row: 11, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' },
|
||
];
|
||
const spec = normalSpec(units);
|
||
const run = (s, src) => resultDigest(createBattleFromSpec(s, src, { recordEvents: false, quiet: true }).runToEnd(4000));
|
||
const a = run(spec, freshDs());
|
||
const b = run(JSON.parse(JSON.stringify(spec)), freshDs());
|
||
const c = run(spec, ds); // shared (cached) default source, other construction order
|
||
assert.equal(a.hash, b.hash);
|
||
assert.equal(a.hash, c.hash);
|
||
// the same board with default loadouts builds different units (the selected skills / module are really used)
|
||
const sp = (s) => createBattleFromSpec(s, freshDs(), { recordEvents: false, quiet: true });
|
||
const withLo = sp(spec), withoutLo = sp(normalSpec(units.map(({ skillIndex, moduleId, ...x }) => x)));
|
||
const sig = (bt) => bt.allyUnits.map((x) => [x.skill.id, x.base.maxHp]);
|
||
assert.deepEqual(sig(withLo), [['skchr_inside_1', C[INSIDE].stats.maxHp], [C.chess_char_1_02_a.skills[0].skillId, C.chess_char_1_02_a.stats.maxHp], ['skchr_mlyss_2', C[MLYSS].statsBase.maxHp]]);
|
||
assert.deepEqual(sig(withoutLo), [['skchr_inside_2', C[INSIDE].stats.maxHp], [C.chess_char_1_02_a.skill.skillId, C.chess_char_1_02_a.stats.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp]]);
|
||
});
|
||
|
||
test('withUnitLoadouts: per-battle view; explicit loadouts win; conflicting chess ids are reported', { skip }, () => {
|
||
const base = freshDs();
|
||
assert.equal(withUnitLoadouts(base, [{ units: [{ chessId: INSIDE }] }]), base, 'no loadouts ⇒ the source itself');
|
||
const players = [
|
||
{ playerId: 'a', units: [{ uid: 1, chessId: INSIDE, skillIndex: 0 }, { uid: 2, chessId: MLYSS, skillIndex: 0 }] },
|
||
{ playerId: 'b', units: [{ uid: 1, chessId: MLYSS, skillIndex: 1 }] },
|
||
];
|
||
const v = withUnitLoadouts(base, players);
|
||
assert.ok(v instanceof DataSource);
|
||
assert.equal(v.getChess(INSIDE).skill.id, 'skchr_inside_1');
|
||
assert.equal(v.getChess(INSIDE, { skillIndex: 1 }).skill.id, 'skchr_inside_2', 'explicit loadout wins');
|
||
assert.equal(v.getChess(INSIDE), base.getChess(INSIDE, { skillIndex: 0 }), 'caches shared with the source');
|
||
assert.deepEqual(v.loadoutConflicts, [MLYSS]);
|
||
assert.equal(v.getToken(WTRMAN, MLYSS).skill.id, 'sktok_mlyss_wtrman_1', 'summons: owner loadout from the view');
|
||
assert.equal(v.getToken(WTRMAN, MLYSS, { skillIndex: 1 }).skill.id, 'sktok_mlyss_wtrman_2');
|
||
assert.equal(v.getEnemy('enemy_1422_lrsldr'), base.getEnemy('enemy_1422_lrsldr'), 'everything else delegates');
|
||
});
|
||
|
||
test('multi-player field: the same chess with different loadouts per player — every unit (and summon) gets its own', { skip }, () => {
|
||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||
const spec = buildBattleSpec({
|
||
battleId: 'u1', fieldId: 'u:p1', kind: 'unite', seed: 7, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
|
||
players: [
|
||
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }]),
|
||
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 2, moduleId: 'uniequip_002_mlyss' }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }]),
|
||
],
|
||
spawns: [], routes: [],
|
||
});
|
||
const b = createBattleFromSpec(spec, freshDs(), { quiet: true });
|
||
const of = (pid, uid) => b.allyUnits.find((u) => u.player.playerId === pid && u.uid === uid);
|
||
assert.deepEqual([of('p1', 1).skill.id, of('p1', 1).base.maxHp], ['skchr_mlyss_1', C[MLYSS].statsBase.maxHp + 170]);
|
||
assert.deepEqual([of('p2', 1).skill.id, of('p2', 1).base.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp]);
|
||
assert.equal(of('p1', 2).def.skill.id, 'sktok_mlyss_wtrman_1');
|
||
assert.equal(of('p2', 2).def.skill.id, 'sktok_mlyss_wtrman_3');
|
||
assert.equal(of('p2', 2).def.talents[0].bb.scale, 1);
|
||
assert.equal(of('p1', 1).hp, of('p1', 1).s.maxHp, 'HP follows the swapped stats');
|
||
// determinism of the same multi-player spec
|
||
const d1 = resultDigest(createBattleFromSpec(spec, freshDs(), { quiet: true, recordEvents: false }).runToEnd(200));
|
||
const d2 = resultDigest(createBattleFromSpec(jsonClone(spec), ds, { quiet: true, recordEvents: false }).runToEnd(200));
|
||
assert.equal(d1.hash, d2.hash);
|
||
});
|
||
|
||
test('harness battle with injected kits: non-default skill via the view uses the authored skills entry', { skip }, () => {
|
||
const specS1 = { kind: 'duration', duration: 5, mods: { atkPct: 1 } };
|
||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [{ uid: 1, chessId: INSIDE, row: 10, col: 5, skillIndex: 0 }], bonds: {}, playerEffects: [] }];
|
||
const h = makeBattle({ players, data: withUnitLoadouts(freshDs(), players), kits: { [INSIDE]: () => ({ skill: null, skills: { skchr_inside_1: specS1 }, talents: [] }) } });
|
||
const u = h.battle.allyUnits[0];
|
||
assert.equal(u.kit.skill, specS1);
|
||
assert.equal(u.skill.kind, 'duration');
|
||
assert.equal(u.skill.id, 'skchr_inside_1');
|
||
});
|
||
|
||
test('tools/kit-coverage: every visible chess and each selectable skill, defaults hand-authored', { skip }, () => {
|
||
const rep = kitCoverage();
|
||
assert.equal(rep.summary.chess, 112);
|
||
assert.equal(rep.chess.length, 112);
|
||
for (const r of rep.chess) {
|
||
const ch = loadoutOptions(C[r.chessId], C[C[r.chessId].goldenId]);
|
||
assert.deepEqual(r.skills.map((s) => s.index), ch.skills, `${r.chessId}: selectable skills listed`);
|
||
for (const s of r.skills) {
|
||
assert.ok(['skills', 'kit', 'generic', 'none'].includes(s.normal) && ['skills', 'kit', 'generic', 'none'].includes(s.elite));
|
||
assert.equal(s.covered, ['skills', 'kit'].includes(s.normal) && ['skills', 'kit'].includes(s.elite));
|
||
}
|
||
}
|
||
assert.equal(rep.summary.skills, rep.chess.reduce((n, r) => n + r.skills.length, 0));
|
||
// every chess already has a hand-authored kit for its default skill (the coverage baseline kits build on)
|
||
assert.equal(rep.summary.defaultCovered, rep.summary.defaults);
|
||
assert.equal(kitCoverage({ tier: 6 }).chess.every((r) => r.tier === 6), true);
|
||
});
|
||
|
||
// ---- adversarial review (data-contract) regressions -------------------------------------------------------------
|
||
|
||
test('multi-player field: a unit WITHOUT loadout fields fights with the default even when another player picked a variant', { skip }, () => {
|
||
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
|
||
const spec = buildBattleSpec({
|
||
battleId: 'u2', fieldId: 'u:p1', kind: 'unite', seed: 9, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
|
||
players: [
|
||
// p1 is listed first: the per-battle view maps MLYSS / INSIDE to p1's choices for id-only lookups
|
||
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'none' }, { uid: 3, chessId: INSIDE, row: 9, col: 4, skillIndex: 0 }]),
|
||
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4 }, { uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 4 }, { uid: 3, chessId: INSIDE, row: 9, col: 4 }]),
|
||
],
|
||
spawns: [], routes: [],
|
||
});
|
||
const b = createBattleFromSpec(spec, freshDs(), { quiet: true });
|
||
const of = (pid, uid) => b.allyUnits.find((u) => u.player.playerId === pid && u.uid === uid);
|
||
assert.equal(of('p1', 1).skill.id, 'skchr_mlyss_1');
|
||
assert.equal(of('p1', 3).skill.id, 'skchr_inside_1');
|
||
assert.deepEqual([of('p2', 1).skill.id, of('p2', 1).base.maxHp], ['skchr_mlyss_3', C[MLYSS].stats.maxHp], 'no loadout fields ⇒ the default, not p1\'s choice');
|
||
assert.equal(of('p2', 3).skill.id, 'skchr_inside_2');
|
||
assert.equal(of('p2', 2).def.skill.id, 'sktok_mlyss_wtrman_3', 'its summon piece follows the default owner');
|
||
assert.equal(of('p2', 2).def.talents[0].bb.scale, 1);
|
||
});
|
||
|
||
test('an inline token def of a PlayerBattleInput entry is never replaced by the loadout sync', { skip }, () => {
|
||
for (const lo of [{}, { skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]) {
|
||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [], units: [
|
||
{ uid: 1, chessId: MLYSS, row: 10, col: 5, ...lo },
|
||
{ uid: 2, kind: 'token', tokenId: WTRMAN, ownerUid: 1, row: 11, col: 5, def: { name: 'inline', stats: { maxHp: 77 } } },
|
||
] }];
|
||
const h = makeBattle({ players, data: withUnitLoadouts(freshDs(), players) });
|
||
const t = h.battle.allyUnits.find((u) => u.uid === 2);
|
||
assert.deepEqual([t.def.name, t.base.maxHp], ['inline', 77], `inline def kept (${JSON.stringify(lo)})`);
|
||
const op = h.battle.allyUnits.find((u) => u.uid === 1);
|
||
assert.equal(op.def.loadout.skillIndex, lo.skillIndex ?? 2);
|
||
}
|
||
});
|
||
|
||
test('getToken: def.sources / def.count follow the owner loadout (which summons a chess really produces)', { skip }, () => {
|
||
const d = freshDs();
|
||
const SHADOW = 'token_10022_kazema_shadow', KAZEMA = 'chess_char_2_11_a';
|
||
assert.deepEqual(d.getToken(SHADOW, KAZEMA).sources, ['skill']);
|
||
assert.deepEqual(d.getToken(SHADOW, KAZEMA, { skillIndex: 0 }).sources, [], '风丸 S1 makes no 纸偶');
|
||
const TRAP = 'token_10031_swire2_gdtrap', SWIRE = 'chess_char_3_04_b';
|
||
assert.deepEqual(d.getToken(TRAP, SWIRE).sources, ['skill'], '琳琅诗怀雅 S2 (default) makes the 香槟炸弹');
|
||
for (const s of [0, 2]) assert.deepEqual(d.getToken(TRAP, SWIRE, { skillIndex: s }).sources, [], `S${s + 1} makes none`);
|
||
assert.deepEqual(d.getToken(WTRMAN, MLYSS, { skillIndex: 0, moduleId: 'none' }).sources, ['talent', 'display'], '流形 is a talent summon: every skill');
|
||
assert.deepEqual([d.getToken('token_10041_cathy_catsld', 'chess_char_4_11_b', { skillIndex: 0 }).count, d.getToken('token_10041_cathy_catsld', 'chess_char_4_11_a').count], [4, 3], '凯瑟琳 devices: count per owner');
|
||
const HEAL = 'token_10000_silent_healrb';
|
||
assert.deepEqual(d.getToken(HEAL, 'chess_char_2_02_a', { skillIndex: 0 }).sources, ['display'], '赫默 S1: no drone piece');
|
||
});
|
||
|
||
test('a Battle built WITHOUT the spec path (server-run fields) resolves mid-battle summons with the owner loadout', { skip }, () => {
|
||
// 缪尔赛思 without a 流形 piece: her 援军 is summoned during the battle (凯瑟琳's devices, the old example, are placed
|
||
// pieces since user playtest #6)
|
||
const MLYSS = 'chess_char_6_11_a', WTRMAN = 'token_10030_mlyss_wtrman';
|
||
const run = (skillIndex) => {
|
||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
|
||
units: [{ uid: 1, kind: 'chess', chessId: MLYSS, row: 10, col: 5, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) }] }];
|
||
const b = new Battle({ seed: 3, kind: 'normal', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: freshDs(), recordEvents: false, quiet: true });
|
||
for (let i = 0; i < 60 && !b.allyUnits.some((u) => u.defId === WTRMAN); i++) b.step();
|
||
const t = b.allyUnits.find((u) => u.defId === WTRMAN);
|
||
assert.ok(t && t.uid == null, 'a 流形 spawned during the battle');
|
||
return [b, t];
|
||
};
|
||
const [b1, t1] = run(0);
|
||
assert.ok(isLoadoutView(b1.data), 'installContent gave the battle the loadout view');
|
||
assert.equal(b1.allyUnits.find((u) => u.uid === 1).skill.id, C[MLYSS].skills[0].skillId);
|
||
assert.equal(t1.def.skill.id, 'sktok_mlyss_wtrman_1', 'the summon follows the owner\'s S1');
|
||
const [, t2] = run(null);
|
||
assert.equal(t2.def.skill.id, 'sktok_mlyss_wtrman_3', 'default owner (S3) ⇒ default token skill');
|
||
// the spec path is not wrapped twice
|
||
const spec = normalSpec([{ uid: 1, chessId: INSIDE, row: 10, col: 5, dir: 'RIGHT', skillIndex: 0 }]);
|
||
const b3 = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
|
||
assert.ok(isLoadoutView(b3.data) && !isLoadoutView(Object.getPrototypeOf(b3.data)));
|
||
});
|