Files
sganggs ec0a2fc669 feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.

- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
  (shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
  a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
  the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
  per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
  ×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
  盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
  the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
  多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
  unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
  the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
  numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
  potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
  chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
2026-10-08 20:38:08 +08:00

389 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, potential: 6, skillIsDefault: true, moduleIsDefault: true, potentialIsDefault: 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, potential: 6, skillIsDefault: false, moduleIsDefault: true, potentialIsDefault: 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');
// 开源节流 at full potential (runtime_cost −2; the module restates it) and the module's own data-only talents (sp_other)
assert.ok(m3.talents.some((t) => t.bb.runtime_cost === -2 && t.bb.cost === -2), 'module talent change applied');
assert.ok(m3.talents.some((t) => t.bb.sp_other === 10) && !none.talents.some((t) => t.bb.sp_other === 10), 'the module-only talent');
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)));
});