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).
239 lines
13 KiB
JavaScript
239 lines
13 KiB
JavaScript
// Real-time co-op over websockets with the REAL simulation: the production server + lobby + Match (RealScheduler,
|
||
// real Battle, client-side combat — DESIGN §14), two scripted human clients (test/helpers/wsClient.js) that simulate
|
||
// their own battles from the b.start specs (test/match/simClient.js, the same sim the browser runs) and report
|
||
// b.progress / b.result, and two AI teammates whose fields the server simulates, from the room to the prep of round 4.
|
||
// Phase timers are scaled down and combat runs at 40× instead of 2× so the test takes seconds; everything else
|
||
// (throttled views, drafts, 联防, settlement, observing a teammate after the own battle) is the real path.
|
||
// Clients act only on what the server tells them (m.public / m.private / b.start), like the browser UI.
|
||
import { test, after } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { startServer } from '../../server/index.js';
|
||
import { Match } from '../../server/match/Match.js';
|
||
import { collectViolations } from '../../server/match/invariants.js';
|
||
import { TestClient } from '../helpers/wsClient.js';
|
||
import { attachWsSimClient } from './simClient.js';
|
||
import { DataSource } from '../../server/sim/simdata.js';
|
||
import { getData } from '../../server/data.js';
|
||
|
||
const DS = new DataSource(getData({ log: { warn() {}, error() {}, info() {} } }), null);
|
||
|
||
class RealtimeMatch extends Match {
|
||
constructor(o) { super({ ...o, timerScale: 0.1, combatSpeed: 40 }); RealtimeMatch.last = this; }
|
||
}
|
||
|
||
const errors = [];
|
||
const log = { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) };
|
||
let srv = null;
|
||
const clients = [];
|
||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
after(async () => {
|
||
for (const c of clients) await c.terminate().catch(() => {});
|
||
if (srv) await srv.close();
|
||
});
|
||
|
||
async function player(name) {
|
||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||
clients.push(c);
|
||
const w = await c.hello(name);
|
||
c.id = w.playerId;
|
||
c.sim = attachWsSimClient(c, { ds: DS, pace: 'instant', speed: 40 });
|
||
return c;
|
||
}
|
||
|
||
const latest = (c, type) => { for (let i = c.log.length - 1; i >= 0; i--) if (c.log[i].t === type) return c.log[i]; return null; };
|
||
|
||
/** Candidate board tiles in the own region (the server answers BAD_TILE for illegal ones). */
|
||
const TILES = [];
|
||
for (let r = 12; r >= 9; r--) for (let c = 2; c <= 10; c++) TILES.push([r, c]);
|
||
|
||
/**
|
||
* A scripted human: confirms the briefing, drafts a band on its turn (passing it once when not last), picks the first free
|
||
* 机变 card, and in every prep buys what it can afford, places chess on legal tiles, clears the temp slots and readies.
|
||
* During combat it watches a teammate's field. Stops when `until()` holds.
|
||
*/
|
||
let abort = false;
|
||
async function drive(c, opts) {
|
||
try { await driveLoop(c, opts); } catch (e) { abort = true; throw e; }
|
||
}
|
||
|
||
async function driveLoop(c, { skipOnce = false, until, stats }) {
|
||
const done = new Set();
|
||
let skipped = !skipOnce;
|
||
const deadlineAt = Date.now() + 90_000;
|
||
while (!abort && !until() && Date.now() < deadlineAt) {
|
||
await delay(20);
|
||
const pub = latest(c, 'm.public');
|
||
const priv = latest(c, 'm.private');
|
||
if (!pub) continue;
|
||
const me = pub.players.find((p) => p.playerId === c.id);
|
||
if (pub.phase === 'INFO_CHECK' && !done.has('info')) {
|
||
done.add('info');
|
||
assert.equal((await c.request({ t: 'g.infoReady' })).t, 'ok');
|
||
} else if (pub.phase === 'BAND_DRAFT' && pub.draft && pub.draft.turn === c.id) {
|
||
const key = `band:${pub.draft.order.join()}`;
|
||
if (done.has(key)) continue;
|
||
done.add(key);
|
||
const last = pub.draft.order.indexOf(c.id) === pub.draft.order.length - 1;
|
||
if (!skipped && !last) {
|
||
skipped = true;
|
||
const r = await c.request({ t: 'g.bandSkip' });
|
||
// a slow client may lose its (scaled) turn to the timer before the skip arrives
|
||
if (r.t === 'ok') stats.skips++;
|
||
else assert.ok(['NOT_YOUR_TURN', 'ALREADY'].includes(r.code), `bandSkip: ${JSON.stringify(r)}`);
|
||
stats.skipTries++;
|
||
} else {
|
||
const r = await c.request({ t: 'g.band', bandId: 'band_bldsk' });
|
||
if (r.t === 'ok') stats.bands++;
|
||
else stats.bandTimeouts++;
|
||
}
|
||
} else if (pub.phase === 'SP_DRAFT' && pub.sp && pub.sp.turn === c.id) {
|
||
const key = `sp:${pub.round}`;
|
||
if (done.has(key)) continue;
|
||
done.add(key);
|
||
const card = pub.sp.cards.find((x) => pub.sp.taken[x.idx] == null);
|
||
const r = await c.request({ t: 'g.choice', idx: card.idx });
|
||
assert.equal(r.t, 'ok', `g.choice: ${JSON.stringify(r)}`);
|
||
stats.cards++;
|
||
} else if (pub.phase === 'PREP' && me && me.alive && priv && !priv.ready && !done.has(`prep:${pub.round}`)) {
|
||
done.add(`prep:${pub.round}`);
|
||
await prep(c, stats);
|
||
} else if (pub.phase === 'COMBAT' && !done.has(`watch:${pub.round}`)) {
|
||
// research 09 §3.1: a teammate's battle can be observed only after the own battle is over
|
||
const own = pub.fields.find((f) => f.players.includes(c.id));
|
||
const other = pub.fields.find((f) => f.kind === 'normal' && !f.players.includes(c.id) && f.live);
|
||
if (!other) continue;
|
||
if (own && own.live) {
|
||
if (!done.has(`early:${pub.round}`)) {
|
||
done.add(`early:${pub.round}`);
|
||
const r = await c.request({ t: 'g.watch', fieldId: other.fieldId });
|
||
if (r.t === 'error') { assert.equal(r.code, 'WRONG_PHASE'); stats.refused++; }
|
||
}
|
||
continue;
|
||
}
|
||
done.add(`watch:${pub.round}`);
|
||
const r = await c.request({ t: 'g.watch', fieldId: other.fieldId });
|
||
if (r.t === 'ok') stats.watches.push(other.fieldId);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function prep(c, stats) {
|
||
const priv = () => latest(c, 'm.private');
|
||
// reward offer first (free)
|
||
if (priv().shop.rewardOffer) await c.request({ t: 'g.reward', idx: 0 });
|
||
// buy what is affordable (chess first)
|
||
for (let guard = 0; guard < 8; guard++) {
|
||
const p = priv();
|
||
const i = p.shop.slots.findIndex((s) => s && !s.sold && s.kind === 'chess' && s.price <= p.funds);
|
||
if (i < 0 || p.hand.every(Boolean)) break;
|
||
const r = await c.request({ t: 'g.buy', slot: i });
|
||
if (r.t !== 'ok') break;
|
||
stats.buys++;
|
||
await c.waitFor('m.private', (x) => x.shop.slots[i] && x.shop.slots[i].sold, 2000).catch(() => null);
|
||
}
|
||
// place hand chess on the board
|
||
for (let guard = 0; guard < 10; guard++) {
|
||
const p = priv();
|
||
if (p.deployCount >= p.deployCap) break;
|
||
const piece = [...p.hand, ...p.temp].find((x) => x && x.kind === 'chess');
|
||
if (!piece) break;
|
||
const taken = new Set(p.board.map((b) => `${b.row},${b.col}`));
|
||
let placed = false;
|
||
for (const [r, col] of TILES) {
|
||
if (taken.has(`${r},${col}`)) continue;
|
||
const res = await c.request({ t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col } });
|
||
if (res.t === 'ok') { placed = true; stats.placed++; break; }
|
||
assert.equal(res.code, 'BAD_TILE', `g.move: ${JSON.stringify(res)}`);
|
||
}
|
||
if (!placed) break;
|
||
await c.waitFor('m.private', (x) => x.board.some((b) => b.uid === piece.uid), 2000).catch(() => null);
|
||
}
|
||
// temp slots block Ready: sell chess / destroy items / place tokens
|
||
for (const t of priv().temp) {
|
||
if (!t) continue;
|
||
if (t.kind === 'chess') await c.request({ t: 'g.sell', uid: t.uid });
|
||
else if (t.kind === 'item') await c.request({ t: 'g.destroy', uid: t.uid });
|
||
}
|
||
const r = await c.request({ t: 'g.ready', ready: true });
|
||
if (r.t === 'ok') stats.readies++;
|
||
}
|
||
|
||
test('real-time co-op over websockets with the real simulation: 2 humans + 2 AI from the room to the prep of round 4', { timeout: 120_000 }, async () => {
|
||
srv = await startServer({ port: 0, host: '127.0.0.1', log, MatchClass: RealtimeMatch, seedFn: () => 20260928 });
|
||
const a = await player('Alpha');
|
||
const b = await player('Bravo');
|
||
assert.equal((await a.request({ t: 'room.create', mode: 'coop', difficulty: 'NORMAL' })).t, 'ok');
|
||
const st = await a.waitFor('room.state');
|
||
assert.equal((await b.request({ t: 'room.join', code: st.code })).t, 'ok');
|
||
assert.equal((await a.request({ t: 'room.addBot' })).t, 'ok');
|
||
assert.equal((await a.request({ t: 'room.addBot' })).t, 'ok');
|
||
assert.equal((await b.request({ t: 'room.ready', ready: true })).t, 'ok');
|
||
assert.equal((await a.request({ t: 'room.start' })).t, 'ok');
|
||
|
||
const reached = (c) => { const p = latest(c, 'm.public'); return !!p && ((p.phase === 'PREP' && p.round >= 4) || p.phase === 'RESULT' || p.round > 4); };
|
||
const stats = { a: { skips: 0, skipTries: 0, bands: 0, bandTimeouts: 0, cards: 0, buys: 0, placed: 0, readies: 0, watches: [], refused: 0 }, b: { skips: 0, skipTries: 0, bands: 0, bandTimeouts: 0, cards: 0, buys: 0, placed: 0, readies: 0, watches: [], refused: 0 } };
|
||
// snapshot of the prep → combat transitions the clients saw
|
||
await Promise.all([
|
||
drive(a, { skipOnce: true, until: () => reached(a), stats: stats.a }),
|
||
drive(b, { skipOnce: true, until: () => reached(b), stats: stats.b }),
|
||
]);
|
||
const m = RealtimeMatch.last;
|
||
assert.ok(m, 'the lobby built the match');
|
||
for (const c of [a, b]) {
|
||
const pub = latest(c, 'm.public');
|
||
assert.ok(pub.round >= 4, `${c.id} reached round ${pub.round} (${pub.phase})`);
|
||
}
|
||
assert.deepEqual(collectViolations(m), [], 'engine invariants hold in the live match');
|
||
assert.equal(m.errorCount, 0, `match errors: ${JSON.stringify(m.errors.slice(0, 3))}`);
|
||
assert.equal(m.dispatcher.errors, 0, 'meta handler errors');
|
||
assert.deepEqual(errors, [], 'no server errors logged');
|
||
|
||
assert.ok(stats.a.skipTries + stats.b.skipTries >= 1, 'a human tried to pass its band-draft turn (g.bandSkip)');
|
||
for (const [c, s] of [[a, stats.a], [b, stats.b]]) {
|
||
assert.equal(s.bands + s.bandTimeouts, 1, `${c.id} drafted a band (or its turn timed out → 华法琳)`);
|
||
assert.ok(s.buys >= 3 && s.placed >= 3 && s.readies >= 3, `${c.id} played its preps: ${JSON.stringify(s)}`);
|
||
// the SP round (NORMAL R3) gave every alive player a card
|
||
assert.equal(s.cards, 1, `${c.id} picked one 机变 card`);
|
||
const pubs = c.log.filter((x) => x.t === 'm.public');
|
||
const phases = new Set(pubs.map((x) => x.phase));
|
||
// (sub-100 ms presentation phases at this timer scale may fall between two throttled m.public frames)
|
||
for (const ph of ['INFO_CHECK', 'BAND_DRAFT', 'SP_DRAFT', 'PREP', 'COMBAT']) assert.ok(phases.has(ph), `${c.id} saw ${ph}`);
|
||
// m.public: throttled to ≤ 10/s (a 100 ms gap, small timer jitter tolerated)
|
||
for (let i = 1; i < pubs.length; i++) assert.ok(pubs[i].serverNow - pubs[i - 1].serverNow >= 95, `m.public ${pubs[i].serverNow - pubs[i - 1].serverNow} ms apart`);
|
||
const priv = latest(c, 'm.private');
|
||
assert.equal(priv.hand.length, 10);
|
||
assert.equal(priv.temp.length, 5);
|
||
assert.ok(priv.board.length >= 1 || !priv.alive);
|
||
// client-side combat: no combat streaming; the own spec every round (authoritative), results reported
|
||
assert.equal(c.log.filter((x) => x.t === 'b.snap' || x.t === 'b.ev').length, 0, 'no b.snap / b.ev');
|
||
const starts = c.log.filter((x) => x.t === 'b.start');
|
||
const own = starts.filter((x) => x.fieldId === `n:${c.id}` && x.authoritative);
|
||
assert.ok(own.length >= 3, `${c.id} got its own spec every round (${own.length})`);
|
||
for (const st of own) {
|
||
assert.equal(st.spec.kind, 'normal');
|
||
assert.equal(st.speed, 40);
|
||
assert.ok(c.sim.log.some((x) => x.t === 'b.result' && x.battleId === st.battleId), `${c.id} reported ${st.battleId}`);
|
||
}
|
||
assert.ok(c.sim.log.filter((x) => x.t === 'b.progress').length >= own.length);
|
||
// observing a teammate after the own battle: the watched field's spec arrives as a display replica
|
||
for (const fid of s.watches) assert.ok(starts.some((x) => x.fieldId === fid && x.watch && !x.authoritative), `${c.id} watched ${fid}`);
|
||
assert.ok(!c.sim.error, `${c.id} client sim error: ${c.sim.error}`);
|
||
const res = c.log.filter((x) => (x.t === 'error') && x.code === 'INTERNAL');
|
||
assert.deepEqual(res, [], 'no INTERNAL errors');
|
||
}
|
||
assert.equal(m.verifyStats.rejected, 0, 'honest clients are never rejected');
|
||
assert.equal(m.verifyStats.takeovers, 0, 'no takeovers');
|
||
assert.ok(stats.a.watches.length + stats.b.watches.length >= 1, 'a human observed a teammate after its own battle');
|
||
// the AI teammates played too (bought, placed, readied every prep)
|
||
for (const ps of m.order.filter((p) => p.isBot && p.alive)) assert.ok(ps.deployCount >= 3, `${ps.playerId} deployed ${ps.deployCount}`);
|
||
|
||
// both humans leave: the match ends as abandoned exactly once and the room returns to the lobby
|
||
assert.equal((await a.request({ t: 'g.leave' })).t, 'ok');
|
||
assert.equal((await b.request({ t: 'g.leave' })).t, 'ok');
|
||
await delay(50);
|
||
assert.equal(m.ended, true);
|
||
assert.equal(m.outcome.reason, 'abandoned');
|
||
assert.deepEqual(errors, []);
|
||
});
|