Files
ark-stronghold-protocol/test/sim/loadout.test.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

389 lines
26 KiB
JavaScript
Raw 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, 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');
// 开源节流 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)));
});