Files
ark-stronghold-protocol/test/content/tokens_summons.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

340 lines
20 KiB
JavaScript

// Summon regressions from user playtest #2 (docs/DESIGN.md §14/§15; content/tokens.js, kits tier3 琳琅诗怀雅 / tier6
// 缪尔赛思, public/js/audio.js):
// #4 琳琅诗怀雅's 香槟炸弹 is a summon, not an operator: using it up is no knock-out (no operator-knocked-down sound,
// no death count, no "干员被击倒" effect); clients hear its explosion.
// #5 缪尔赛思's 流形: never attacks before it copied an operator, the copy skill waits (ready) while nobody can be
// copied, one respawn 25 s after being killed — never a quick second one (stale timers, her redeploy).
// audit: official per-class death/deploy sounds for every summon kind (operator / summon / map character / device).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { hasGeneratedData, getDefaultSource } from '../../server/sim/simdata.js';
import { TOKEN_IDS, pickCopyTarget } from '../../server/sim/content/tokens.js';
import { AudioManager, deathSfxUrl, deploySfxUrl, unitSoundClass } from '../../public/js/audio.js';
import { showsDeathFx } from '../../public/js/render/app.js';
const REAL = { skip: !hasGeneratedData() };
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8'));
const SFX = manifest.audio.sfx;
const TOKEN_DEAD = SFX.battle.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3');
const ds = getDefaultSource();
const dummy = (o = {}) => enemyRec({ key: o.key ?? 'enemy_dummy', hp: 1e7, speed: 0, def: 0, res: 0, ...o });
const walker = (o = {}) => enemyRec({ key: o.key ?? 'enemy_walker', hp: 1e6, speed: 1, def: 0, atk: 0, ...o });
const guard = (o = {}) => chessRec({ id: 'test_guard', profession: 'WARRIOR', skill: null, stats: { atk: 600, def: 300, maxHp: 5000, blockCnt: 2, bat: 1 }, rangeGrid: [[0, 0], [0, 1]], ...o });
const MF = TOKEN_IDS.manifold;
const manifolds = (h) => h.b.allyUnits.filter((u) => u.defId === MF && !u.mem.mlyssClone && !u.mem.isClone);
const standing = (h) => manifolds(h).filter((u) => u.alive && u.deployed);
const noErrors = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
/** Records every attack (and damage) by a 流形 made while it has no copy (tier6 `mem.mlyss` / tokens.js `mem.copy`). */
function watchUncopied(b, out) {
const bare = (u) => u && u.defId === MF && !u.mem.mlyss && !u.mem.copy;
b.on('attack', (c) => { if (bare(c.attacker)) out.push({ t: b.time, id: c.attacker.id, what: 'attack' }); }, { priority: 1000 });
b.on('damaged', (c) => { if (bare(c.source)) out.push({ t: b.time, id: c.source.id, what: 'damage' }); }, { priority: 1000 });
}
const attacksBy = (b, list) => { b.on('attack', (c) => { if (c.attacker.defId === MF) list.push({ t: b.time, u: c.attacker }); }); };
/** An AudioManager whose sounds are recorded instead of played. */
function recordingAudio() {
const a = new AudioManager({ win: null, getManifest: () => manifest });
a.ctx = {}; // "unlocked"
const played = [];
a._play = (url) => { played.push(url); };
return { a, played };
}
// =================================================================================================================
// #4 香槟炸弹
test('#4 香槟炸弹 (琳琅诗怀雅): used up by its blast = no knock-out — no death count, no operator-knocked-down sound, its explosion is heard', REAL, () => {
const id = 'chess_char_3_04_a';
const knocked = [];
const h = makeBattle({
defs: { enemies: { enemy_w: walker({ key: 'enemy_w' }) } }, timeLimit: 60, hooks: ['death', 'deploy'],
units: [{ chessId: id, row: 9, col: 5 }], enemies: [{ key: 'enemy_w', route: 0 }],
setup: (b) => b.on('death', (c) => { if (c.reason === 'killed' && c.unit.side === 'ally') knocked.push(c.unit.defId); }),
});
assert.ok(h.runUntil(() => h.hooksOf('death').some((c) => c.unit.defId === TOKEN_IDS.champagne), 30), 'a bomb went off');
h.run(0.1);
const bombDeaths = h.hooksOf('death').filter((c) => c.unit.defId === TOKEN_IDS.champagne);
for (const c of bombDeaths) {
assert.equal(c.unit.kind, 'token');
assert.notEqual(c.reason, 'killed', 'a used-up bomb is not knocked out');
}
assert.deepEqual(knocked, [], 'no ally knocked out');
assert.equal(h.result().perPlayer.p1.deaths, 0, 'result: no operator deaths');
// the client stream: the blast fx names the bomb as used up right before its `die` event
const ev = h.events;
const bombIds = new Set(bombDeaths.map((c) => c.unit.id));
for (const bid of bombIds) {
const iDie = ev.findIndex((e) => e[0] === 'die' && e[1] === bid);
const iBoom = ev.findIndex((e) => e[0] === 'fx' && e[4] && e[4].id === bid && e[4].consumed === true);
assert.ok(iBoom >= 0 && iBoom < iDie, 'consumed blast fx before the die event');
}
// …and the audio manager fed with it plays the bomb's impact sound, never the operator knock-down sound
const { a, played } = recordingAudio();
a.setFieldUnits([]);
a.handleBattleEvents(ev);
assert.ok(!played.includes(SFX.battle.charDie), 'no 干员被击倒 sound');
assert.ok(!played.includes(TOKEN_DEAD), 'no token death sound either (it exploded)');
assert.ok(played.includes(SFX.units[TOKEN_IDS.champagne].hit), 'its explosion (official ON_ABILITY_HIT) is heard');
assert.ok(played.includes(SFX.units[TOKEN_IDS.champagne].born), 'placed with its own sound');
// …and the renderer shows no knock-down particles for it (its blast is its end — integration)
for (const bid of bombIds) {
const info = ev.find((e) => e[0] === 'spawn' && e[1]?.id === bid)?.[1] || { kind: 'token' };
assert.equal(showsDeathFx(info, true), false, 'used-up bomb: no death particles');
assert.equal(showsDeathFx(info, false), true, 'a summon destroyed otherwise still gets them');
}
assert.equal(showsDeathFx({ kind: 'device' }), false, 'stage devices: none');
noErrors(h);
});
test('#4 audio classes (official ON_UNIT_DEAD/BORN defaults): only operators play the knock-down / operator deploy sounds', () => {
const op = { side: 'ally', kind: 'op', defId: 'chess_char_1_01_a', def: 'char_x' };
const tok = { side: 'ally', kind: 'token', defId: TOKEN_IDS.wolfPack, def: TOKEN_IDS.wolfPack };
const own = { side: 'ally', kind: 'token', defId: MF, def: MF };
const bomb = { side: 'ally', kind: 'token', defId: TOKEN_IDS.champagne, def: TOKEN_IDS.champagne };
const mapChar = { side: 'ally', kind: 'token', defId: 'char_605_cmedic', def: 'char_605_cmedic' };
const crate = { side: 'ally', kind: 'device', defId: 'trap_1105_accrate', def: 'trap_1105_accrate' };
const enemy = { side: 'enemy', kind: 'enemy', defId: 'enemy_x', def: 'enemy_x' };
const boss = { ...enemy, boss: true };
assert.deepEqual([op, tok, bomb, mapChar, crate, enemy].map(unitSoundClass), ['char', 'token', 'token', 'char', 'device', 'enemy']);
// deaths
assert.equal(deathSfxUrl(manifest, op), SFX.battle.charDie);
assert.equal(deathSfxUrl(manifest, tok), TOKEN_DEAD, 'summon ⇒ b_char_tokendead');
assert.equal(deathSfxUrl(manifest, own), SFX.units[MF].die, 'own ON_UNIT_DEAD first (流形)');
assert.equal(deathSfxUrl(manifest, bomb), TOKEN_DEAD);
assert.equal(deathSfxUrl(manifest, bomb, { consumed: true }), null, 'used up ⇒ silent');
assert.equal(deathSfxUrl(manifest, mapChar), SFX.battle.charDie, 'band map characters are operators');
assert.equal(deathSfxUrl(manifest, crate), TOKEN_DEAD, 'act crate trap_1105 ⇒ b_char_tokendead');
assert.equal(deathSfxUrl(manifest, enemy), SFX.battle.enemyDie);
assert.equal(deathSfxUrl(manifest, boss), SFX.battle.enemyDieHeavy);
assert.equal(deathSfxUrl(manifest, op, { reason: 'retreat' }), null, 'a retreat is no knock-down');
assert.equal(deathSfxUrl(manifest, op, { reason: 'killed' }), SFX.battle.charDie);
assert.equal(deathSfxUrl(null, op), null);
// deployments
assert.equal(deploySfxUrl(manifest, op), SFX.battle.deploy);
assert.equal(deploySfxUrl(manifest, tok), SFX.units[TOKEN_IDS.wolfPack].born);
assert.equal(deploySfxUrl(manifest, { ...tok, def: 'token_nope', defId: 'token_nope' }), SFX.battle.tokenDeploy, 'summon ⇒ b_char_tokenset');
assert.equal(deploySfxUrl(manifest, crate), null, 'stage devices are placed silently');
assert.equal(deploySfxUrl(manifest, enemy), null);
// through the event handler
const { a, played } = recordingAudio();
a.setFieldUnits([{ id: 1, ...op, spine: 'char_x' }, { id: 2, side: 'ally', kind: 'token', defId: 'token_nope', spine: 'token_nope' }, { id: 3, ...crate, spine: crate.def }]);
a.handleBattleEvents([['die', 2], ['die', 3], ['deploy', 3]]);
assert.deepEqual(played, [TOKEN_DEAD, TOKEN_DEAD], 'summon + crate: token death sound, no operator one');
a.handleBattleEvents([['die', 1]]);
assert.deepEqual(played.slice(2), [SFX.battle.charDie]);
});
test('#4 die events carry their reason: a retreat plays no knock-down sound, a knock-out does (integration)', REAL, () => {
const h = makeBattle({
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 60, autoFinish: false,
units: [{ chessId: 'test_guard', row: 10, col: 5, uid: 1 }, { chessId: 'test_guard', row: 11, col: 5, uid: 2 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 8] }],
});
h.step();
const [g1, g2] = h.b.allyUnits.filter((u) => u.defId === 'test_guard');
h.b.retreat(g1);
h.b.kill(g2);
h.step();
const dies = h.eventsOf('die');
assert.deepEqual(dies.find((e) => e[1] === g1.id), ['die', g1.id, 'retreat']);
assert.deepEqual(dies.find((e) => e[1] === g2.id), ['die', g2.id, 'killed']);
const { a, played } = recordingAudio();
const op = { side: 'ally', kind: 'op', defId: 'test_guard', def: 'test_guard' };
a.setFieldUnits([{ id: g1.id, ...op, spine: 'char_x' }, { id: g2.id, ...op, spine: 'char_y' }]);
a.handleBattleEvents(dies.filter((e) => e[1] === g1.id));
assert.deepEqual(played, [], 'a retreat is no knock-down');
a.handleBattleEvents(dies.filter((e) => e[1] === g2.id));
assert.deepEqual(played, [SFX.battle.charDie], 'a knock-out plays b_char_dead');
noErrors(h);
});
// =================================================================================================================
// #5 流形
test('#5 流形: never attacks before copying; with nobody to copy its copy skill waits ready, then copies at once', REAL, () => {
const bad = [], atk = [];
const h = makeBattle({
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 9, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }], // standing on the 流形's own tile (its uncopied range)
setup: (b) => { watchUncopied(b, bad); attacksBy(b, atk); },
});
h.step();
const m = standing(h)[0], g = h.unit('test_guard');
assert.ok(m);
assert.equal(m.profile.noAttack, true, 'uncopied: no attack');
h.b.dealDamage(null, g, { amount: 1e9, type: 'true' }); // the only operator it could copy is knocked out
assert.equal(g.alive, false);
h.run(12);
assert.equal(pickCopyTarget(h.b, m), null);
assert.equal(m.skill.active, false, 'copy skill not spent on nobody');
assert.ok(m.skill.ready, 'it stays ready');
assert.equal(m.mem.mlyss ?? null, null);
assert.deepEqual(bad, [], 'no attack / damage without a copy');
assert.equal(atk.length, 0);
// the operator comes back: the 流形 copies it at once and only then attacks
assert.ok(h.b.redeploy(g, { free: true }));
h.step(2);
assert.ok(m.mem.mlyss, 'copied as soon as someone can be copied');
assert.equal(m.mem.mlyss.from, g.id);
assert.equal(m.profile.noAttack, false);
assert.ok(h.runUntil(() => atk.length > 0, 5), 'attacks once copied');
assert.deepEqual(bad, []);
noErrors(h);
});
test('#5 流形: a respawned 流形 comes back uncopied after 25 s and does not attack until its copy (5 s later)', REAL, () => {
const bad = [];
const h = makeBattle({
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
setup: (b) => watchUncopied(b, bad),
});
h.step();
const first = standing(h)[0];
assert.ok(h.runUntil(() => first.mem.mlyss, 7), 'first copy after 95 → 100 SP');
h.b.dealDamage(null, first, { amount: 1e9, type: 'true' });
const respawn = ds.getToken(MF, 'chess_char_6_11_a').talents.find((t) => t.bb.scale != null).bb.interval;
const tKill = h.b.time;
assert.ok(h.runUntil(() => standing(h).length === 1, respawn + 2));
const again = standing(h)[0];
assert.ok(Math.abs(h.b.time - tKill - respawn) < 0.1, `back after ${respawn} s (${h.b.time - tKill})`);
assert.deepEqual([again.tileR, again.tileC], [9, 6], 'on its tile');
assert.equal(again.mem.mlyss ?? null, null, 'uncopied');
assert.equal(again.profile.noAttack, true);
const t0 = h.b.time;
assert.ok(h.runUntil(() => again.mem.mlyss, 6));
assert.ok(h.b.time - t0 > 4.5, 'copies when its SP fills (95 → 100)');
assert.deepEqual(bad, [], 'the enemy on its tile was never attacked by an uncopied 流形');
noErrors(h);
});
test('#5 流形: no quick second revive — her knock-out cancels the pending respawn, her redeploy brings exactly one back', REAL, () => {
const deploys = [];
const h = makeBattle({
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 200, autoFinish: false, hooks: ['death'],
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 7, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 5, uid: 3, ownerUid: 1 }],
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === MF && !c.unit.mem.mlyssClone) deploys.push({ t: b.time, id: c.unit.id, tile: [c.unit.tileR, c.unit.tileC] }); }),
});
h.step();
const u = h.unit('chess_char_6_11_a');
h.run(1);
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' }); // 流形 killed: respawn pending (25 s)
h.run(2);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); // then she is knocked out
assert.equal(u.alive, false);
h.run(40); // past the old respawn time: she is still down
assert.equal(standing(h).length, 0, 'no 流形 while she is off the field');
assert.ok(h.b.redeploy(u, { free: true })); // her redeploy summons it as her 援军
h.step();
assert.equal(standing(h).length, 1);
assert.deepEqual([standing(h)[0].tileR, standing(h)[0].tileC], [11, 5], 'back on the tactical point the player chose (its board tile, inside her range)');
const n = deploys.length;
// kill it right away: the next one needs the full 25 s (no stale timer firing within a second)
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
h.run(20);
assert.equal(standing(h).length, 0, 'not back within 20 s');
assert.equal(deploys.length, n);
h.run(6);
assert.equal(standing(h).length, 1, 'back after 25 s');
h.run(60);
assert.equal(deploys.length, n + 1, 'exactly one respawn');
assert.equal(h.hooksOf('death').filter((c) => c.unit.defId === MF && c.reason === 'expired').length, 0, 'never replaced by a second 流形');
noErrors(h);
});
test('#5 流形 (unmanaged token kit): redeploys uncopied (data stats, own tile range), then copies again', REAL, () => {
const genericNoSkill = () => ({ skill: null, talents: [], generic: true });
const bad = [];
const h = makeBattle({
defs: { chess: { test_guard: guard() }, enemies: { enemy_dummy: dummy() } }, timeLimit: 120, autoFinish: false,
kits: { chess_char_6_11_a: genericNoSkill },
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 9, col: 6, uid: 3, ownerUid: 1 }],
enemies: [{ key: 'enemy_dummy', pos: [9, 6] }],
setup: (b) => watchUncopied(b, bad),
});
h.step();
const m = standing(h)[0];
const tdef = ds.getToken(MF, 'chess_char_6_11_a');
assert.ok(h.runUntil(() => m.mem.copy, 7));
assert.notEqual(m.base.atk, tdef.stats.atk);
h.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
const respawn = tdef.talents.find((t) => t.bb.scale != null).bb.interval;
assert.ok(h.runUntil(() => m.alive, respawn + 2), 'same piece redeployed');
assert.equal(m.mem.copy, null);
assert.equal(m.base.atk, tdef.stats.atk, 'uncopied data ATK');
assert.equal(m.base.blockCnt, tdef.stats.blockCnt);
assert.deepEqual(m.rangeGrid, tdef.rangeGrid, 'own tile range again');
assert.equal(m.profile.noAttack, true);
assert.ok(h.runUntil(() => m.mem.copy, 6), 'copies again');
assert.deepEqual(bad, [], 'no attack before either copy');
noErrors(h);
});
// =================================================================================================================
// adversarial review (workstream "summons")
const fastSniper = (o = {}) => chessRec({
id: 'test_sniper', profession: 'SNIPER', position: 'RANGED', skill: null, stats: { atk: 300, def: 50, maxHp: 3000, blockCnt: 1, bat: 0.3 },
rangeGrid: [[0, 0], [0, 1], [0, 2], [0, 3], [1, 0], [1, 1], [1, 2], [-1, 0], [-1, 1], [-1, 2]], ...o,
});
test('#5 流形: a split clone alive when 缪尔赛思 redeploys is not taken for her 流形 — a real one comes back and stays', REAL, () => {
const h = makeBattle({
defs: { chess: { test_sniper: fastSniper() }, enemies: { enemy_dummy: dummy({ atk: 0 }) } }, timeLimit: 300, autoFinish: false,
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_sniper', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 5, uid: 3, ownerUid: 1 }],
enemies: [{ key: 'enemy_dummy', pos: [11, 8] }],
});
h.step();
const clones = () => h.b.allyUnits.filter((u) => u.defId === MF && u.alive && u.deployed && (u.mem.mlyssClone || u.mem.isClone));
assert.ok(h.runUntil(() => clones().length > 0, 60), 'the ranged copy split');
const m = h.unit('chess_char_6_11_a');
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
h.b.dealDamage(null, m, { amount: 1e9, type: 'true' });
h.step();
assert.equal(standing(h).length, 0);
assert.equal(clones().length, 1, 'the clone outlives them (25 s)');
assert.ok(h.b.redeploy(m, { free: true }));
h.step(2);
assert.equal(standing(h).length, 1, 'her redeploy brings a real 流形 although a clone stands');
assert.equal(m.trait.reinforcement, standing(h)[0], 'her 援军 is the 流形, not the clone');
assert.deepEqual([standing(h)[0].tileR, standing(h)[0].tileC], [11, 5], 'on the tactical point the player chose');
h.run(60); // the clone expired long ago
assert.equal(standing(h).length, 1, 'still has her 流形');
noErrors(h);
});
test('#5 流形 (unmanaged token kit): no respawn while 缪尔赛思 is down, her redeploy brings exactly one back', REAL, () => {
const genericNoSkill = () => ({ skill: null, talents: [], generic: true });
const deploys = [];
const h = makeBattle({
defs: { chess: { test_guard: guard() } }, timeLimit: 200, autoFinish: false,
kits: { chess_char_6_11_a: genericNoSkill },
units: [{ chessId: 'chess_char_6_11_a', row: 12, col: 2, uid: 1 }, { chessId: 'test_guard', row: 12, col: 6, uid: 2 }, { kind: 'token', tokenId: MF, row: 11, col: 4, uid: 3, ownerUid: 1 }],
setup: (b) => b.on('deploy', (c) => { if (c.unit.defId === MF && !c.unit.mem.isClone) deploys.push(b.time); }),
});
h.step();
const u = h.unit('chess_char_6_11_a');
const n0 = deploys.length;
h.b.dealDamage(null, standing(h)[0], { amount: 1e9, type: 'true' });
h.run(2);
h.b.dealDamage(null, u, { amount: 1e9, type: 'true' });
h.run(40); // past the 25 s respawn
assert.equal(standing(h).length, 0, 'not back while she is down');
assert.equal(deploys.length, n0);
assert.ok(h.b.redeploy(u, { free: true }));
h.step();
assert.equal(standing(h).length, 1, 'back with her');
h.run(60);
assert.equal(deploys.length, n0 + 1, 'exactly once');
noErrors(h);
});