Files
ark-stronghold-protocol/test/match/leftovers.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

282 lines
15 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.
// Server leftovers (DESIGN §14): battleIds unique across the matches of a room (a late report from the previous match
// is ignored), duplicate b.result idempotent, the Final Assault's m.public throttled to ~1 Hz (b.pool is live), the
// solo pause (g.pause → m.public.paused: field clock, deadlines, boss clock, server pacers frozen), protocol additions
// (g.pause, g.equip replaceUid).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, ERR } from '../../shared/constants.js';
import { validateC2S } from '../../shared/protocol.js';
import { createBattleFromSpec, compactResult } from '../../server/sim/spec.js';
import { RESULT_GRACE_MS, BOSS_SILENCE_MS } from '../../server/match/fields.js';
import { FakeBattle } from './fakeBattle.js';
import { makeMatch, checkInvariants } from './harness.js';
/** A plausible client result for a spec (FakeBattle stepped to its end, like test/match/simClient.js). */
function fakeResult(m, spec) {
const b = createBattleFromSpec(spec, m.ds, { BattleClass: FakeBattle, recordEvents: false, quiet: true });
for (let i = 0; i < 1e5 && !b.finished; i++) b.step();
return compactResult(b.result());
}
const pub = (h) => { h.m.flush(true); return h.lastBc('m.public'); };
// ---- battleId ---------------------------------------------------------------------------------------------------
test('battleIds carry a per-match prefix (seed + the room\'s match number): a late b.progress / b.result of the previous match is ignored', () => {
const mk = (matchNo) => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 4242, matchNo, fake: true, clientCombat: true, clients: false }).start();
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT));
return h;
};
const a = mk(1);
const b = mk(2);
const sa = a.lastTo('p_0', 'b.start');
const sb = b.lastTo('p_0', 'b.start');
assert.notEqual(sa.battleId, sb.battleId, 'same seed, same field, another match of the room');
assert.ok(sa.battleId.startsWith(`${(4242).toString(36)}-1.1.`) && sb.battleId.startsWith(`${(4242).toString(36)}-2.1.`), `${sa.battleId} / ${sb.battleId}`);
assert.deepEqual({ ...sa.spec, battleId: null }, { ...sb.spec, battleId: null }, 'the very same battle otherwise');
const result = fakeResult(a.m, sa.spec);
const f = b.m.fields[0];
// the previous match's reports reach the new match (same socket, same room): ignored, never an error
assert.deepEqual(b.m.handle('p_0', { t: 'b.progress', battleId: sa.battleId, gt: 5, killed: 1, total: f.progress.total }), { ok: true });
assert.equal(f.progress.killed, 0);
assert.deepEqual(b.m.handle('p_0', { t: 'b.result', battleId: sa.battleId, result }), { ok: true });
assert.equal(f.done, false, 'the stale result did not settle the new match\'s battle');
assert.deepEqual(b.m.handle('p_0', { t: 'b.result', battleId: sb.battleId, result }), { ok: true });
assert.equal(f.done, true);
assert.equal(f.resultSource, 'client');
a.m.dispose();
b.m.dispose();
});
test('battleIds: unique within a match, valid protocol ids (≤ 64 chars) even for long player ids; no matchNo ⇒ the seed alone', () => {
const long = `p_${'x'.repeat(60)}`;
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: long, name: 'L', isBot: false, connected: true }, { seat: 1, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], seed: 77, fake: true, clientCombat: true, clients: false }).start();
const ids = new Set();
for (let r = 1; r <= 3; r++) {
assert.ok(h.drive(() => h.m.phase === PHASE.COMBAT && h.m.round === r));
for (const f of h.m.fields) {
assert.ok(!ids.has(f.battleId), `unique ${f.battleId}`);
ids.add(f.battleId);
assert.ok(f.battleId.length <= 64 && f.battleId.startsWith(`${(77).toString(36)}.${r}.`), f.battleId);
assert.equal(validateC2S({ t: 'b.progress', battleId: f.battleId, gt: 0, killed: 0, total: 0 }), null);
}
h.drive(() => h.m.phase === PHASE.PREP || h.ended != null);
}
assert.equal(h.m.errorCount, 0);
h.m.dispose();
});
// ---- duplicate b.result -------------------------------------------------------------------------------------------
test('a duplicate b.result (a re-send after a lost reply) is accepted idempotently: ok, nothing changes', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 4243, fake: true, clientCombat: true, clients: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
const start = h.lastTo('p_0', 'b.start');
const f = m.fields[0];
const result = fakeResult(m, start.spec);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
assert.equal(f.done, true);
const kept = f.result;
const stats = { ...m.verifyStats };
const lp = m.players.get('p_0').lp;
for (let i = 0; i < 3; i++) assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
assert.equal(f.result, kept);
assert.deepEqual(m.verifyStats, stats);
// and once the round moved on
h.run(() => m.phase === PHASE.SETTLE);
assert.deepEqual(m.handle('p_0', { t: 'b.result', battleId: start.battleId, result }), { ok: true });
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.players.get('p_0').lp, lp - Math.min(10, (kept.perPlayer.p_0.leaked || []).filter((l) => l.counted !== false).length));
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
// ---- Final Assault m.public throttle ------------------------------------------------------------------------------
test('Final Assault: boss b.progress (4 Hz per field) no longer re-marks m.public — ~1 Hz, while b.pool stays ≤ 4 Hz with the exact numbers', () => {
const h = makeMatch({ mode: 'coop', difficulty: 'FUNNY', humans: 2, seed: 9111, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 300, leakEvents: [{ at: 3, lpr: 2 }, { at: 5, lpr: 1 }] } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
assert.ok(h.drive(() => m.phase === PHASE.FINAL_ASSAULT));
const pubs = [];
const pools = [];
h.onBroadcast.push((msg) => {
if (msg.t === 'm.public' && msg.phase === PHASE.FINAL_ASSAULT) pubs.push({ at: h.sched.now(), msg });
if (msg.t === 'b.pool') pools.push(msg);
});
const lp0 = m.teamLp;
const t0 = h.sched.now();
h.sched.advance(8000);
const secs = (h.sched.now() - t0) / 1000;
const progress = h.clients.get('p_0').log.filter((x) => x.t === 'b.progress').length;
assert.ok(progress >= secs * 3, `the boss field reports at ~4 Hz (${progress})`);
assert.ok(pubs.length <= secs + 3, `m.public ≈ 1 Hz during the boss fight (${pubs.length} in ${secs} s)`);
assert.ok(pubs.length >= secs - 2, `…but it keeps following the fight (${pubs.length})`);
assert.ok(pools.length >= secs * 3, `b.pool stays live (${pools.length})`);
// the throttled view is at most one period behind: team LP and the players' shares agree with it
assert.equal(m.teamLp, lp0 - 3);
h.sched.advance(1100);
const last = pub(h);
assert.equal(last.teamLp, Math.round(m.teamLp));
assert.equal(last.players.filter((p) => p.alive).reduce((s, p) => s + p.lp, 0), Math.round(m.teamLp), 'Σ lp = team LP');
assert.equal(last.bossHp.hp, Math.max(0, Math.round(m.bossPool.hp)));
assert.equal(pools[pools.length - 1].teamLp, Math.round(m.teamLp));
const end = h.runToEnd({ maxSteps: 5e6 });
assert.ok(end);
assert.equal(m.errorCount, 0);
checkInvariants(m);
m.dispose();
});
// ---- solo pause ---------------------------------------------------------------------------------------------------
test('protocol: g.pause { on } and g.equip replaceUid (optional)', () => {
assert.equal(validateC2S({ t: 'g.pause', on: true }), null);
assert.equal(validateC2S({ t: 'g.pause', on: false, rid: 3 }), null);
assert.notEqual(validateC2S({ t: 'g.pause' }), null);
assert.notEqual(validateC2S({ t: 'g.pause', on: 1 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 3 }), null);
assert.equal(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: null }), null);
assert.notEqual(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 'x' }), null);
assert.notEqual(validateC2S({ t: 'g.equip', itemUid: 1, targetUid: 2, replaceUid: 0 }), null);
});
test('solo pause: co-op never pauses; solo only while a battle runs; m.public.paused', () => {
const co = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, seed: 5150, fake: true, clientCombat: true, clients: false, instant: false }).start();
assert.ok(co.drive(() => co.m.phase === PHASE.COMBAT));
assert.equal(co.m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE);
assert.equal(pub(co).paused, false);
co.m.dispose();
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5151, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.equal(pub(h).paused, false, 'always present');
assert.ok(h.drive(() => m.phase === PHASE.PREP, { ready: false }));
assert.equal(m.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE, 'prep is untimed in solo: nothing to pause');
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
assert.equal(m.paused, false);
m.dispose();
});
test('solo pause: the field clock, the result deadline and the HUD countdown stand still; resume shifts them by the pause', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5152, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
const f = m.fields[0];
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
const deadline0 = m.deadline;
const due0 = f.startAt + Math.round((f.spec.timeLimit / m.gameSpeed) * 1000) + RESULT_GRACE_MS;
h.sched.advance(2000);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true }, 'idempotent');
assert.equal(pub(h).paused, true);
const el = m._fieldElapsed(f);
const PAUSE = 10 * 60 * 1000;
h.sched.advance(PAUSE);
assert.equal(m.phase, PHASE.COMBAT);
assert.deepEqual([f.mode, f.done], ['client', false], 'no takeover while paused (the deadline passed long ago)');
assert.equal(m._fieldElapsed(f), el, 'the field clock stood still');
m.onReconnect('p_0'); // a resync while paused: the replica is built at the frozen clock
assert.equal(h.lastTo('p_0', 'b.start').elapsed, Math.round(el * 1000) / 1000);
assert.equal(h.lastTo('p_0', 'm.public').paused, true, 'm.public first: the runner is paused before it builds the battle');
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
const p = pub(h);
assert.equal(p.paused, false);
assert.equal(p.deadline, deadline0 + PAUSE, 'the countdown resumes where it stopped');
assert.ok(Math.abs(m._fieldElapsed(f) - el) < 1e-9);
h.sched.advance(due0 + PAUSE - h.sched.now() - 50);
assert.equal(f.mode, 'client', 'the result deadline moved by the pause');
h.sched.advance(100);
assert.equal(f.mode, 'server', 'then the silent client is taken over');
assert.equal(m.pausedMs, PAUSE);
assert.equal(m.errorCount, 0);
m.dispose();
});
test('solo pause: a disconnect resumes (the server takes the field over); the battle phase ending clears the pause', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5153, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
h.sched.advance(5000);
m.onDisconnect('p_0');
assert.equal(m.paused, false);
assert.equal(m.fields[0].mode, 'server');
assert.equal(pub(h).paused, false);
m.onReconnect('p_0');
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.errorCount, 0);
m.dispose();
// the client's result crosses the pause: the phase ends, the pause goes with it
const h2 = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5154, fake: true, clientCombat: true, clients: false, instant: false }).start();
const m2 = h2.m;
assert.ok(h2.drive(() => m2.phase === PHASE.COMBAT));
const start = h2.lastTo('p_0', 'b.start');
assert.deepEqual(m2.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
assert.deepEqual(m2.handle('p_0', { t: 'b.result', battleId: start.battleId, result: fakeResult(m2, start.spec) }), { ok: true });
assert.equal(m2.paused, false);
assert.equal(pub(h2).paused, false);
h2.run(() => m2.phase === PHASE.PREP && m2.round === 2);
assert.equal(m2.handle('p_0', { t: 'g.pause', on: true }).error, ERR.WRONG_PHASE);
assert.equal(m2.errorCount, 0);
m2.dispose();
});
test('solo pause in the Final Assault: no overtime drain, no silence handover, the boss clock stops; resume shifts overtimeAt', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5155, fake: true, clientCombat: true, instant: false,
script: (b) => (b.kind === 'boss' ? { bossDps: 0 } : { duration: 2 }) }).start();
const m = h.m;
h.autoHumans();
assert.ok(h.drive(() => m.phase === PHASE.FINAL_ASSAULT || h.ended != null, { maxSteps: 5e6 }));
assert.equal(m.phase, PHASE.FINAL_ASSAULT);
h.clients.closeAll(); // the browser goes quiet: only the silence watchdog would move the field
const f = m.fields[0];
assert.deepEqual([f.mode, f.authority], ['client', 'p_0']);
h.sched.advance(1000);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
const lp = m.teamLp;
const overtimeAt = m.overtimeAt;
const el = m._fieldElapsed(f);
const PAUSE = 400 * 1000; // far beyond the overtime start (150 s) and the silence watchdog (12 s)
h.sched.advance(PAUSE);
assert.equal(m.teamLp, lp, 'no overtime drain while paused');
assert.equal(f.mode, 'client', 'no silence handover while paused');
assert.equal(m._fieldElapsed(f), el);
assert.equal(m._bossClock, null);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
assert.equal(m.overtimeAt, overtimeAt + PAUSE);
assert.equal(pub(h).overtimeAt, overtimeAt + PAUSE);
assert.ok(m._bossClock, 'the boss clock runs again');
h.sched.advance(BOSS_SILENCE_MS + 1000);
assert.equal(f.mode, 'server', 'the watchdog works again after the resume');
assert.equal(m.teamLp, lp, 'still before the overtime start on the field clock');
assert.equal(m.errorCount, 0);
m.dispose();
});
test('solo pause with server-run combat (SP_COMBAT=server): the FieldRunner does not step while paused', () => {
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', humans: 1, seed: 5156, fake: true, instant: false, script: () => ({ duration: 20 }) }).start();
const m = h.m;
assert.ok(h.drive(() => m.phase === PHASE.COMBAT));
h.sched.advance(1000);
const b = m.fields[0].battle;
const t = b.time;
assert.ok(t > 1.5, `running (${t})`);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: true }), { ok: true });
h.sched.advance(30000);
assert.equal(b.time, t);
assert.equal(m.phase, PHASE.COMBAT);
assert.deepEqual(m.handle('p_0', { t: 'g.pause', on: false }), { ok: true });
h.sched.advance(1000);
assert.ok(b.time > t + 1.5 && b.time < t + 2.5, `continues at 2× without a catch-up burst (${t} → ${b.time})`);
h.run(() => m.phase === PHASE.PREP);
assert.equal(m.errorCount, 0);
m.dispose();
});