卫戍协议:盟约 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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
// 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();
});