Files
ark-stronghold-protocol/test/sim/loadout-summons.test.js
T
sganggs 6464e4b46a Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar
- #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
2026-10-01 19:08:23 +08:00

274 lines
17 KiB
JavaScript

// test/sim/loadout-summons.test.js — summons follow their owner's operator loadout during the battle (DESIGN §16,
// DATA.md §14): Battle (_createAllyFromInput passes the unit's loadout, _tokenDef / spawnToken / tokenDef use the
// owner's, producesToken refuses summons the selected skill does not make), professions.js (dollkeeper substitute
// token) and content/tokens.js (tokenSources / variantOf with the owner's loadout; the skill-summon fallback also runs
// when only the selected skill falls back to the generic spec).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DataSource, getDefaultSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { Battle } from '../../server/sim/Battle.js';
import { buildBattleSpec, createBattleFromSpec, resultDigest, jsonClone } from '../../server/sim/spec.js';
import { makeBattle } from '../helpers/battleHarness.js';
const skip = !hasGeneratedData() && 'no generated data (run node tools/build-data.mjs)';
const ds = getDefaultSource();
const C = ds.raw.chess;
const T = ds.raw.tokens;
/** A fresh browser-like DataSource (no research fallback, empty caches). */
const freshDs = () => new DataSource(ds.raw, null);
const MLYSS = 'chess_char_6_11_b', WTRMAN = 'token_10030_mlyss_wtrman'; // 缪尔赛思 精锐: talent summon 流形
const CATHY = 'chess_char_4_11_a', CATSLD = 'token_10041_cathy_catsld'; // 凯瑟琳: talent devices (token skill per slot)
const HEMO = 'chess_char_2_02_a', DRONE = 'token_10000_silent_healrb'; // 赫默: S2 (default) drone, S1 none
const ROSMON = 'chess_char_6_12_a', ROSMON_B = 'chess_char_6_12_b', GEAR = 'token_10012_rosmon_shield'; // 迷迭香: S3 gear
const SWIRE = 'chess_char_3_04_b', TRAP = 'token_10031_swire2_gdtrap'; // 琳琅诗怀雅: S2 (default) bombs, S1/S3 none
const KAZEMA = 'chess_char_2_11_b', SHADOW = 'token_10022_kazema_shadow'; // 风丸 (dollkeeper)
const mk = (playerId, seat, colOffset, units) => ({ playerId, seat, side: 'L', colOffset, units, bonds: {}, playerEffects: [] });
const uniteSpec = (p1, p2, seed = 11) => buildBattleSpec({
battleId: 'ls', fieldId: 'u:p1', kind: 'unite', seed, modeId: 'mode_multi_normal', round: 4, stageId: 'act2autochess_m01', timeLimit: 60,
players: [mk('p1', 0, 0, p1), mk('p2', 1, 8, p2)], spawns: [], routes: [],
});
/** A normal-field spec with one player's units and a real wave (act1autochess_01). */
function normalSpec(units, { seed = 4242 } = {}) {
const tpl = ds.getWave('act1autochess_01');
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: 'ls1', fieldId: 'n:p1', kind: 'normal', seed, modeId: 'mode_multi_normal', round: 1, stageId: 'act2autochess_m01',
timeLimit: 90, players: [mk('p1', 0, 0, units)], spawns, routes: tpl.routes, flags: {}, waveId: 'act1autochess_01',
});
}
const summonsOf = (b, owner, tokenId) => b.allyUnits.filter((u) => u.kind === 'token' && u.uid == null && u.ownerUnit === owner && u.defId === tokenId);
const opOf = (b, pid, uid) => b.allyUnits.find((u) => u.kind === 'op' && u.player.playerId === pid && u.uid === uid);
/** Run until `pred` holds (max `sec` s). */
function runUntil(b, pred, sec = 10) {
for (let i = 0; i < sec * 30 && !pred() && !b.finished; i++) b.step();
return pred();
}
test('two players in one field, same chess, different skills / modules: summons spawned mid-battle get each owner\'s variant', { skip }, () => {
const spec = uniteSpec(
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 0, moduleId: 'uniequip_003_mlyss' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT', skillIndex: 0 },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
// p2: MLYSS with another explicit choice, CATHY without loadout fields (= the default, never p1's choice)
[{ uid: 1, chessId: MLYSS, row: 10, col: 4, dir: 'RIGHT', skillIndex: 1, moduleId: 'none' }, { uid: 2, chessId: CATHY, row: 11, col: 4, dir: 'RIGHT' },
{ uid: 3, kind: 'token', tokenId: CATSLD, ownerUid: 2, row: 11, col: 5, dir: 'LEFT' }],
);
const b = createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
assert.deepEqual([...b.data.loadoutConflicts].sort(), [CATHY, MLYSS].sort(), 'id-only lookups would give p2 p1\'s choices');
const m1 = opOf(b, 'p1', 1), m2 = opOf(b, 'p2', 1), c1 = opOf(b, 'p1', 2), c2 = opOf(b, 'p2', 2);
// no 流形 piece: the 援军 is summoned during the battle; 凯瑟琳's devices are board pieces (user playtest #6) whose
// def follows the owner's loadout too
const piecesOf = (owner) => b.allyUnits.filter((u) => u.kind === 'token' && u.uid != null && u.ownerUnit === owner && u.defId === CATSLD);
assert.ok(runUntil(b, () => [m1, m2].every((o) => summonsOf(b, o, WTRMAN).length > 0) && [c1, c2].every((o) => piecesOf(o).some((t) => t.alive)), 5), 'every owner summoned');
const d = freshDs();
const w1 = summonsOf(b, m1, WTRMAN)[0], w2 = summonsOf(b, m2, WTRMAN)[0];
assert.equal(w1.def.skill.id, 'sktok_mlyss_wtrman_1', 'p1 流形: its owner\'s S1');
assert.equal(w2.def.skill.id, 'sktok_mlyss_wtrman_2', 'p2 流形: its owner\'s S2, not p1\'s S1');
assert.ok(w1.def.talents.some((t) => t.bb.taunt_level === 1), 'p1 流形: module 003 token talent');
assert.ok(!w2.def.talents.some((t) => t.bb.taunt_level === 1), 'p2 流形 (no module): no module talent');
assert.deepEqual(w2.def.talents, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).talents);
assert.equal(w2.base.maxHp, d.getToken(WTRMAN, MLYSS, { skillIndex: 1, moduleId: 'none' }).stats.maxHp);
assert.ok(piecesOf(c1).length === 1 && piecesOf(c2).length === 1);
for (const t of piecesOf(c1)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_1', 'p1 devices: S1 token skill');
for (const t of piecesOf(c2)) assert.equal(t.def.skill.id, 'sktok_cathy_catsld_2', 'p2 devices (default loadout): S2 token skill');
// Battle.tokenDef / spawnToken (content summons) resolve the owner's loadout as well
assert.equal(b.tokenDef(WTRMAN, m1).skill.id, 'sktok_mlyss_wtrman_1');
assert.equal(b.tokenDef(WTRMAN, m2).skill.id, 'sktok_mlyss_wtrman_2');
assert.equal(b.tokenDef(WTRMAN, MLYSS).skill.id, 'sktok_mlyss_wtrman_1', 'a chess id alone: the view\'s (first) loadout');
const free = [[9, 5], [9, 6], [12, 5], [12, 6], [9, 7], [12, 7]].map(([r, c]) => [r, c + 8]).find(([r, c]) => !b.unitAt(r, c) && b.grid.canStand(r, c, { ranged: true }));
const extra = b.spawnToken(m2, WTRMAN, free[0], free[1]);
assert.ok(extra, 'spawned');
assert.equal(extra.def.skill.id, 'sktok_mlyss_wtrman_2');
// deterministic whatever the source's cache state (server re-simulation / the browser sim use their own sources)
const digest = (src) => resultDigest(createBattleFromSpec(jsonClone(spec), src, { recordEvents: false, quiet: true }).runToEnd(120));
assert.equal(digest(freshDs()).hash, digest(ds).hash);
});
test('Battle._createAllyFromInput asks the data for the unit\'s own loadout (no field = the default)', { skip }, () => {
const src = freshDs();
const calls = [];
const spy = Object.create(src);
spy.getChess = (id, lo = null) => { calls.push([id, lo]); return src.getChess(id, lo); };
const players = [
mk('p1', 0, 0, [{ uid: 1, chessId: MLYSS, row: 10, col: 4, skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]),
mk('p2', 1, 8, [{ uid: 1, chessId: MLYSS, row: 10, col: 4 }]),
];
const b = new Battle({ seed: 3, kind: 'unite', stageId: 'act2autochess_m01', timeLimit: 30, players, spawns: [], routes: [], data: spy, recordEvents: false, quiet: true, content: 'none' });
const mine = calls.filter(([id]) => id === MLYSS);
assert.deepEqual(mine[0], [MLYSS, { skillIndex: 0, moduleId: 'uniequip_003_mlyss' }]);
assert.deepEqual(mine[1], [MLYSS, { skillIndex: null, moduleId: null }], 'an entry without fields asks for the default');
assert.equal(opOf(b, 'p1', 1).def.loadout.moduleId, 'uniequip_003_mlyss');
assert.equal(opOf(b, 'p2', 1).def.loadout.isDefault, true);
});
test('producesToken / spawnToken: a summon the owner\'s selected skill does not make is refused (data `sources`)', { skip }, () => {
const run = (skillIndex) => {
const units = [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...(skillIndex != null ? { skillIndex } : {}) }];
const h = makeBattle({ players: [mk('p1', 0, 0, units)], data: freshDs(), content: 'none' });
h.step();
return [h.b, h.b.allyUnits.find((u) => u.uid === 1)];
};
const [b1, s1] = run(0);
assert.deepEqual(b1.tokenDef(DRONE, s1).sources, ['display'], '赫默 S1: the drone is only displayed');
assert.equal(b1.producesToken(s1, DRONE), false);
assert.equal(b1.spawnToken(s1, DRONE, 10, 6), null, 'refused');
assert.equal(b1.producesToken(s1, TRAP), true, 'another chess\'s token: the data does not tell ⇒ allowed');
assert.ok(b1.spawnToken(s1, DRONE, 10, 6, { anySource: true }), 'opts.anySource overrides');
assert.ok(b1.spawnToken(s1, DRONE, 10, 7, { def: { name: 'inline', stats: { maxHp: 10 } } }), 'an inline def is never refused');
assert.equal(b1.producesToken(b1.getPlayer('p1'), DRONE), true, 'player-owned summons are not policed');
const [b2, s2] = run(null);
assert.equal(b2.producesToken(s2, DRONE), true, '赫默 S2 (default) makes the drone');
assert.ok(b2.spawnToken(s2, DRONE, 10, 6));
// under the DEFAULT skill the kit stays the authority: 迷迭香 S2 does not make 战术装备 (data), content may still
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: ROSMON, row: 10, col: 5 }])], data: freshDs(), content: 'none' });
h.step();
const r = h.b.allyUnits.find((u) => u.uid === 1);
assert.equal(h.b.producesToken(r, GEAR), false);
assert.ok(h.b.spawnToken(r, GEAR, 10, 6), 'default skill: not policed');
});
test('琳琅诗怀雅 (real content): S1 / S3 summon no 香槟炸弹; S2 (default) does', { skip }, () => {
const bombs = (skillIndex) => {
const b = createBattleFromSpec(normalSpec([
{ uid: 1, chessId: SWIRE, row: 10, col: 6, dir: 'RIGHT', ...(skillIndex != null ? { skillIndex } : {}) },
{ uid: 2, chessId: 'chess_char_1_02_a', row: 10, col: 5, dir: 'RIGHT' },
], { seed: 5 }), freshDs(), { recordEvents: false, quiet: true });
const seen = new Set();
for (let i = 0; i < 90 * 30 && !b.finished; i++) { b.step(); for (const u of b.allyUnits) if (u.defId === TRAP) seen.add(u); }
return seen.size;
};
assert.ok(bombs(null) > 0, 'S2 (default): bombs');
assert.equal(bombs(0), 0, 'S1: none');
assert.equal(bombs(2), 0, 'S3: none');
});
test('spawnToken guard end to end: a kit install hook written for the default skill summons nothing under a skill that does not make the token', { skip }, () => {
// an injected kit (independent of the real tier3 kit, which now has its own S1 / S3 specs): its `install` runs under
// EVERY selected skill and drops a 香槟炸弹 once she stands, as the old 琳琅诗怀雅 kit's default-skill hooks did
const kit = () => ({
skill: { kind: 'duration', duration: 5, mods: {} }, talents: [],
install(battle, unit) {
battle.on('deploy', (c) => { if (c.unit === unit) battle.after(0.1, () => battle.spawnToken(unit, TRAP, unit.tileR, unit.tileC + 1), { owner: unit }); }, { owner: unit });
},
});
const run = (lo) => {
const h = makeBattle({ players: [mk('p1', 0, 0, [{ uid: 1, chessId: SWIRE, row: 10, col: 5, ...lo }])], data: freshDs(), kits: { [SWIRE]: kit } });
h.run(0.5);
const u = h.b.allyUnits.find((x) => x.uid === 1);
assert.ok(u.alive && u.deployed);
return summonsOf(h.b, u, TRAP).length;
};
assert.equal(run({}), 1, 'S2 (default): the kit decides');
assert.equal(run({ skillIndex: 0 }), 0, 'S1: refused');
assert.equal(run({ skillIndex: 2 }), 0, 'S3: refused');
});
/** 迷迭香 alone on a real normal field (a wave, so the range covers path tiles); returns [harness battle, unit]. */
function rosmon(chessId, lo, kits) {
const spec = normalSpec([{ uid: 1, chessId, row: 10, col: 6, dir: 'RIGHT', ...lo }], { seed: 9 });
const b = kits
? new Battle({ ...spec, spawns: spec.spawns, data: freshDs(), kits, recordEvents: false, quiet: true })
: createBattleFromSpec(spec, freshDs(), { recordEvents: false, quiet: true });
b.step();
const u = b.allyUnits.find((x) => x.uid === 1);
assert.ok(u.alive && u.deployed);
return [b, u];
}
/** 迷迭香 S3 cast once: her two 战术装备 stand inside her attack range on melee tiles with the S3 token skill. */
function assertRosmonS3Gears(b, u) {
assert.equal(u.skill.id, 'skchr_rosmon_3');
assert.deepEqual(b.tokenDef(GEAR, u).sources, ['skill', 'display']);
assert.equal(summonsOf(b, u, GEAR).length, 0);
assert.ok(u.skill.activate('test', { free: true }));
b.step();
const gears = summonsOf(b, u, GEAR);
assert.equal(gears.length, 2, '"部署两个战术装备"');
const range = new Set(u.rangeKeys);
for (const g of gears) {
assert.ok(g.alive && range.has(g.tileR * 21 + g.tileC), 'inside her attack range');
assert.ok(b.grid.canStand(g.tileR, g.tileC, { ranged: false }), 'on a melee tile');
assert.equal(g.def.skill.bb.stun, 2, 'the S3 token skill (stun 2 s)');
}
}
test('迷迭香 S3 (non-default skill that summons): two 战术装备 at skill start — real content, whichever spec runs it', { skip }, () => {
// behaviour only: holds while her S3 runs the generic spec + tokens.js fallback AND once a hand-authored S3 lands
const [b, u] = rosmon(ROSMON, { skillIndex: 2 });
assertRosmonS3Gears(b, u);
});
test('迷迭香 S3 via the tokens.js fallback: a hand-authored kit without an S3 spec (generic spec for the selected skill)', { skip }, () => {
// an injected kit, so this path stays covered after the real kit gains `skills.skchr_rosmon_3`
const [b, u] = rosmon(ROSMON, { skillIndex: 2 }, { [ROSMON]: () => ({ skill: { kind: 'passive' }, talents: [] }) });
assert.equal(u.kit.generic, undefined, 'a hand-authored kit…');
assert.equal(u.kit.skillSource, 'generic', '…whose selected skill runs the generic spec');
assertRosmonS3Gears(b, u);
});
test('迷迭香 S1 / S2 (default) summon no 战术装备; a kit `skills` entry for S3 takes the summon over', { skip }, () => {
for (const lo of [{ skillIndex: 0 }, {}]) {
const [b, u] = rosmon(ROSMON, lo);
assert.ok(u.skill.activate('test', { free: true }));
b.step();
assert.equal(summonsOf(b, u, GEAR).length, 0, `S${u.def.loadout.skillIndex + 1}: none`);
}
const s3 = { kind: 'duration', duration: 5, mods: {} };
const [b, u] = rosmon(ROSMON, { skillIndex: 2 }, { [ROSMON]: () => ({ skill: null, skills: { skchr_rosmon_3: s3 }, talents: [] }) });
assert.equal(u.kit.skillSource, 'skills');
assert.ok(u.skill.activate('test', { free: true }));
assert.equal(summonsOf(b, u, GEAR).length, 0, 'the kit\'s own S3 spec: no fallback summon');
});
test('tokens.js variantOf: the deploy limit of a summon follows the owner\'s module (byModule)', { skip }, () => {
for (const moduleId of ['none', 'uniequip_003_rosmon']) {
const [b, u] = rosmon(ROSMON_B, { skillIndex: 2, moduleId });
const v = T[GEAR].variants[ROSMON_B];
const limit = (moduleId === 'none' ? v.byModule.none : v.byModule[moduleId] ?? v).stats.deployLimit;
assert.ok(u.skill.activate('test', { free: true }));
u.skill.end('test');
b.step();
assert.ok(u.skill.activate('test', { free: true }));
const standing = summonsOf(b, u, GEAR).filter((g) => g.alive).length;
assert.equal(standing, Math.min(4, limit), `${moduleId}: deployLimit ${limit}`);
}
});
test('tokens.js tokenSources: the generic-kit skill-summon fallback reads the owner loadout (赫默 S1 makes no drone)', { skip }, () => {
// the drone is a placed piece (user playtest #6): S2 deploys it once with the board (SKILL_SUMMON_START_DEPLOY) and
// brings it back onto its tile with each cast; S1 never makes one (its piece never deploys)
const run = (lo, atStart) => {
const units = [{ uid: 1, chessId: HEMO, row: 10, col: 5, ...lo }, { uid: 2, kind: 'token', tokenId: DRONE, ownerUid: 1, row: 10, col: 6 }];
const h = makeBattle({ players: [mk('p1', 0, 0, units)], data: freshDs(), kits: { [HEMO]: () => null } });
h.step();
const u = h.b.allyUnits.find((x) => x.uid === 1);
assert.equal(u.kit.generic, true);
assert.equal(h.b.allyUnits.find((x) => x.uid === 2).alive, atStart, 'with the board: S2 yes, S1 no');
h.b.producesToken = () => true; // only the fallback's own check decides here
assert.ok(u.skill.activate('test', { free: true }));
return h.b.allyUnits.filter((t) => t.kind === 'token' && t.defId === DRONE && t.ownerUnit === u && t.alive).length;
};
assert.equal(run({}, true), 1, 'S2 (default): the drone');
assert.equal(run({ skillIndex: 0 }, false), 0, 'S1: none');
});
test('dollkeeper: the substitute token is looked up with the unit\'s own loadout', { skip }, () => {
const players = [mk('p1', 0, 0, [{ uid: 1, chessId: KAZEMA, row: 10, col: 5, skillIndex: 0, moduleId: 'none' }])];
const h = makeBattle({ players, data: freshDs() });
h.step();
const u = h.b.allyUnits.find((x) => x.uid === 1);
const calls = [];
const view = h.b.data;
const orig = view.getToken.bind(view);
view.getToken = (...a) => { calls.push(a); return orig(...a); };
h.b.dealDamage(null, u, { amount: 1e7, type: 'true' });
assert.ok(u.alive && u.trait.doll, 'substitute');
const call = calls.find(([id]) => id === SHADOW);
assert.ok(call, 'the 纸偶 was looked up');
assert.equal(call[1], KAZEMA);
assert.equal(call[2], u.def.loadout, 'with the owner\'s loadout');
assert.equal(call[2].skillIndex, 0);
assert.equal(call[2].moduleId, 'none');
});