Files
ark-stronghold-protocol/test/sim/loadout.test.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

382 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-index trigger: 角峰 S1 casts when hit (TANK row, skill 1 only); S2 is DEFAULT
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, 'DEFAULT');
// 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 }, () => {
const CATHY = 'chess_char_4_11_a', SHIELD = 'token_10041_cathy_catsld';
const run = (skillIndex) => {
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, bonds: {}, playerEffects: [],
units: [{ uid: 1, kind: 'chess', chessId: CATHY, 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 === SHIELD); i++) b.step();
const t = b.allyUnits.find((u) => u.defId === SHIELD);
assert.ok(t && t.uid == null, 'a device 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[CATHY].skills[0].skillId);
assert.equal(t1.def.skill.id, 'sktok_cathy_catsld_1', 'the summon follows the owner\'s S1');
const [, t2] = run(1);
assert.equal(t2.def.skill.id, 'sktok_cathy_catsld_2', 'default owner ⇒ 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)));
});