Files
ark-stronghold-protocol/test/match/unitedown.test.js
T

123 lines
7.4 KiB
JavaScript

// test/match/unitedown.test.js — user playtest #5 item 2: an operator knocked out (濒死) in a 联防 helper's own combat
// vanished in the 联防 phase instead of keeping its redeploy countdown and redeploying (unite.js left it out of the
// battle input). Official (PRTS 卫戍协议/帮助 §联防阶段): "部署完成后,将对应单位的生命比例、技力修改至与上一阶段结束时相同
// (召唤物仅修改技力,上一阶段为退场状态的干员强制退场)" — it is deployed with everyone and forced out at once: down on its
// own tile with the redeploy ring, its timer running, and back like after any knock-out (tile free, DP ≥ cost).
// The match builds the input (both combat modes); the real sim runs it the way the authority, the display replicas
// (partner, observers) and the server fallback do.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE } from '../../shared/constants.js';
import { Battle } from '../../server/sim/Battle.js';
import { createBattleFromSpec } from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DOWN_STATE, FORCED_EXIT } from '../../server/sim/constants.js';
import { FakeBattle } from './fakeBattle.js';
import { DATA, makeMatch, give, chessOfTier, legalTileFor, checkInvariants } from './harness.js';
/** A Battle that never ends by itself (the check needs the whole redeploy timer, whatever the enemies do). */
class NoFinish extends Battle {
constructor(o) { super({ ...o, autoFinish: false, timeLimit: 1e6 }); }
}
const downOf = (b, id) => (b.snapshot().down || []).find((d) => d[0] === id) || null;
/**
* p_0 leaks; p_1 and p_2 are perfect. p_1 (more units: the right-hand field) and p_2 each have an operator knocked out
* at the end of their own combat next to one that stands.
*/
function scenario(clientCombat) {
const dead = new Set();
const h = makeMatch({
mode: 'coop', humans: 3, seed: 3702, fake: true, clientCombat,
script: (b) => (b.kind === 'normal' ? { leaks: { p_0: 3 }, deadUids: [...dead] } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const melee = chessOfTier(1, (c) => c.position === 'MELEE').filter((x) => m.pool.has(x));
const ranged = chessOfTier(1, (c) => c.position === 'RANGED').filter((x) => m.pool.has(x));
const place = (ps, id) => give(m, ps, id, 'board', legalTileFor(m, ps, id));
const p1 = h.ps('p_1'), p2 = h.ps('p_2');
const down1 = place(p1, melee[0]);
const up1 = place(p1, ranged[0]);
place(p1, ranged[1]); // p_1 fields more units: the first helper, on the right-hand half
const down2 = place(p2, melee[1]);
const up2 = place(p2, ranged[2]);
dead.add(down1.uid);
dead.add(down2.uid);
h.drive(() => m.phase === PHASE.UNITE);
assert.deepEqual(m.unitePlan.helpers.map((p) => p.playerId), ['p_1', 'p_2']);
return { h, m, down1, up1, down2, up2 };
}
/** The 联防 field's battle input and a real battle built from it, as each combat mode builds it. */
function uniteBattle(m, clientCombat) {
if (clientCombat) {
const f = m.fields[0];
assert.equal(f.fieldId, 'u');
// a browser (authority or display replica) builds the battle from the spec over its own copy of the data
return { players: f.spec.players, battle: createBattleFromSpec(f.spec, new DataSource(DATA, null), { BattleClass: NoFinish, recordEvents: true }) };
}
const u = FakeBattle.instances.find((b) => b.kind === 'unite');
return { players: u.opts.players, battle: new NoFinish({ ...u.opts, data: m.ds, logger: { warn() {}, error() {}, info() {}, debug() {} } }) };
}
for (const clientCombat of [true, false]) {
test(`联防 (${clientCombat ? 'client-side combat' : 'server-run fallback'}): a helper's operator knocked out in its own combat enters down on its tile, counts down and redeploys`, () => {
const { h, m, down1, up1, down2, up2 } = scenario(clientCombat);
const { players, battle: b } = uniteBattle(m, clientCombat);
// the input: the knocked-out operators are fielded, flagged `down`; the standing ones carry HP% / SP
const inp = (pid, uid) => players.find((p) => p.playerId === pid).units.find((x) => x.uid === uid);
assert.deepEqual(inp('p_1', down1.uid)?.carryState, { down: true }, 'the right-hand helper\'s knocked-out operator is in the 联防 battle');
assert.deepEqual(inp('p_2', down2.uid)?.carryState, { down: true }, 'the left-hand helper\'s too');
assert.deepEqual(inp('p_1', up1.uid).carryState, { hpPct: 0.5, sp: 3 });
assert.deepEqual(inp('p_2', up2.uid).carryState, { hpPct: 0.5, sp: 3 });
b.step();
const unitOf = (uid, pid) => b.allyUnits.find((u) => u.uid === uid && u.ownerId === pid);
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
assert.ok(u, `${pid}: its unit exists`);
assert.ok(!u.alive && b.isDown(u), `${pid}: knocked out on the field from the start`);
const d = downOf(b, u.id);
assert.ok(d, `${pid}: in b.snap down (the redeploy ring)`);
assert.ok(Math.abs(d[2] - u.base.respawnTime) < 0.011, `${pid}: its full redeploy timer (${d[2]} s)`);
assert.equal(d[3], DOWN_STATE.COUNTING);
assert.ok(b.fieldMeta().units.some((x) => x.id === u.id), `${pid}: a client joining the field shows it down`);
assert.equal(u.removeReason, FORCED_EXIT);
}
const board1 = [...h.ps('p_1').board.entries()].find(([, p]) => p.uid === down1.uid)[0].split(',').map(Number);
const u1 = unitOf(down1.uid, 'p_1');
assert.deepEqual([u1.tileR, u1.tileC], [board1[0], board1[1] + 8], 'the first helper holds the right-hand half (colOffset +8), on its board tile');
assert.ok([unitOf(up1.uid, 'p_1'), unitOf(up2.uid, 'p_2')].every((u) => u.alive && u.deployed), 'the standing operators fight');
// redeploy: after the timer, on its own tile, at full HP (DP init 10 + 1/s ≥ cost by then)
for (const [piece, pid] of [[down1, 'p_1'], [down2, 'p_2']]) {
const u = unitOf(piece.uid, pid);
const home = [u.homeR, u.homeC];
while (b.time < u.respawnAt + 0.2 && !u.alive) b.step();
assert.ok(u.alive && u.deployed, `${pid}: redeployed at ${b.time.toFixed(2)} s (respawnAt ${u.respawnAt.toFixed(2)})`);
assert.deepEqual([u.tileR, u.tileC], home, `${pid}: on its own tile`);
assert.ok(Math.abs(u.hp - u.s.maxHp) < 1e-6, `${pid}: full HP on redeploy (${u.hp} / ${u.s.maxHp})`);
assert.equal(downOf(b, u.id), null);
}
checkInvariants(m);
m.dispose();
});
}
test('the docs describe the rule (docs/META.md §4, docs/SIM.md §1.1 / §9, docs/PLAYING.md §5)', async () => {
const { readFileSync } = await import('node:fs');
const read = (f) => readFileSync(new URL(`../../docs/${f}`, import.meta.url), 'utf8');
const META = read('META.md'), SIM = read('SIM.md'), PLAYING = read('PLAYING.md');
assert.match(META, /knocked out at the end of the helper's own\s+combat carries `\{ down: true \}`/);
assert.ok(!/stays out of the 联防 battle/.test(META), 'META: the old "stays out" rule is gone');
assert.ok(!/do not take part in the 联防 battle/.test(META), 'META §7: the old assumption is gone');
assert.match(META, /knocked out at the end of its own combat is deployed and forced out at once/);
assert.match(SIM, /`carryState: \{ down: true \}`/);
assert.match(SIM, /reason `'killed'` or `FORCED_EXIT`/);
assert.match(PLAYING, /作战结束时已被击倒的干员在原位倒地/);
assert.ok(!/已倒下的干员不参加/.test(PLAYING));
});