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).
340 lines
20 KiB
JavaScript
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);
|
|
});
|