feedback3 WE: spectator seats on the server — up to 2 per co-op room, never players, watch like an eliminated player, no private view (community report #26)

This commit is contained in:
sganggs committed 2026-10-04 17:20:51 +08:00
1 parent bdb0765c55
commit 42766172d1
9 files changed
+628 -56

No files matched your search

+3 -3
View File
@@ -3,8 +3,8 @@
// const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed: 7, fake: true });
// h.start(); h.runToPhase('PREP'); const ps = h.ps('p_0'); …; h.invariants();
//
// Options: mode, difficulty, humans (count) | seats (explicit), bots, seed, matchNo (the room's match number: part of
// the battleId prefix), data (default: real data/*.json),
// Options: mode, difficulty, humans (count) | seats (explicit), bots, spectators (spectator seat ids, opts.spectators),
// seed, matchNo (the room's match number: part of the battleId prefix), data (default: real data/*.json),
// fake (true → test/match/fakeBattle.js as BattleClass), script (FakeBattle.script), registry, instant (virtual
// scheduler runs battles synchronously; default true), timerScale, battleContent, botRehearsal (default 0),
// botSliceMs (bot rehearsal slice budget; default: unbounded in virtual time).
@@ -55,7 +55,7 @@ export function makeMatch(o = {}) {
if (o.script) FakeBattle.script = o.script;
}
h.m = new Match({
roomCode: 'TEST', mode, difficulty, seats, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log,
roomCode: 'TEST', mode, difficulty, seats, spectators: o.spectators, seed: o.seed ?? 1, matchNo: o.matchNo, data: o.data ?? DATA, log,
send: (id, msg) => {
for (const fn of h.onSend) fn(id, msg);
if (msg.t === 'b.snap' || msg.t === 'b.ev') { h.frames++; if (!captureFrames) return true; }
+65
View File
@@ -127,6 +127,71 @@ test('co-op over websockets: two humans + AI, reconnect with the token mid-match
assert.deepEqual(errors, []);
});
// community report #26 (a remake feature): a spectator seat — server/lobby.js header, test/match/spectator.test.js
test('co-op spectator over websockets: watches the real match like an eliminated player, never a private view, cannot act, resumes', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const watcher = async (name, token) => {
const s = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${s.port}/ws`); // no simulated browser: it only watches
clients.push(c);
const w = await c.hello(name, token);
c.id = w.playerId;
c.token = w.token;
return c;
};
const a = await player('A');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(a, { t: 'room.addBot' });
const s = await watcher('Spec');
await ok(s, { t: 'room.spectate', code: st.code });
await ok(a, { t: 'room.start' });
const info = await s.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
assert.ok(!info.players.some((p) => p.playerId === s.id), 'never a player');
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.band', bandId: 'band_bldsk' }, { t: 'g.emote', id: EMOTES[0] }]) {
const r = await s.request(msg);
assert.equal(r.code, 'SPECTATOR', msg.t);
}
await ok(a, { t: 'g.infoReady' });
for (let i = 0; i < 40; i++) { // every draft frame in order: pick on the own turn (the AI picks at once)
const p = await a.waitFor('m.public', (x) => x.phase !== 'INFO_CHECK', 10000);
if (p.phase !== 'BAND_DRAFT') break;
if (p.draft && p.draft.turn === a.id && !p.draft.picks[a.id]) await ok(a, { t: 'g.band', bandId: 'band_bldsk' });
}
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
for (const msg of [{ t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.refresh' }]) {
const r = await s.request(msg);
assert.equal(r.code, 'SPECTATOR', msg.t);
}
// prep: the player's board, read-only (what a teammate scouting it gets)
await ok(s, { t: 'g.watch', fieldId: `n:${a.id}` });
const scout = await s.waitFor('m.field', (f) => f.prep === true);
assert.equal(scout.fieldId, `n:${a.id}`);
await ok(a, { t: 'g.ready', ready: true });
const start = await s.waitFor('b.start', (x) => x.kind === 'normal', 10000);
assert.equal(start.watch, true);
assert.equal(start.authoritative, false);
assert.ok(!start.spec.players.some((p) => Object.hasOwn(p.contentInfo || {}, 'funds')), 'no player funds in a spectator\'s spec');
// drop and resume with the token: the seat comes back with the match state
await s.terminate();
await a.waitFor('room.state', (x) => x.spectators.some((y) => y.playerId === s.id && !y.connected));
const back = await watcher('Spec', s.token);
assert.equal(back.id, s.id);
const rs = await back.waitFor('room.state');
assert.deepEqual(rs.spectators, [{ playerId: s.id, name: 'Spec', connected: true }]);
await back.waitFor('m.public');
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
for (const c of [s, back]) {
assert.equal(c.log.filter((x) => x.t === 'm.private' || x.t === 'm.toast' || x.t === 'm.unitStats').length, 0, 'no private frame');
assert.ok(!c.log.some((x) => /"(funds|hand|shop|temp)":/.test(JSON.stringify(x))), 'no private player data');
}
// the last player leaves: the match ends and the room closes for its spectator
await ok(a, { t: 'g.leave' });
assert.equal((await back.waitFor('room.closed', () => true, 5000)).reason, 'empty');
assert.deepEqual(errors, []);
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
+145
View File
@@ -0,0 +1,145 @@
// Spectator seats, the match side (community report #26, owner's decision 2026-10-04 — a remake feature: the official
// room has no spectator seat). A spectator (opts.spectators / addSpectator, server/lobby.js spectate) is shown fields like
// an ELIMINATED player in every phase — the first field of each battle (b.start watch), any field on g.watch, the 联防
// spec, the first boss field, a prep board on g.watch 'n:<pid>' — and the settlement; it never receives an m.private
// (shop, hand, funds …), an m.toast or m.unitStats, is never a field's player or authority, and every intent but g.watch
// is refused (SPECTATOR). The platform half (seats, cap, host removal, reconnect): test/lobby.test.js; over sockets with
// the real Match: test/match/lobby-integration.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ERR, PHASE, EMOTES } from '../../shared/constants.js';
import { makeMatch } from './harness.js';
const S = 's_spec';
/** Keys only the private player view carries (DESIGN §8.3 m.private): none may reach a spectator, at any depth. */
const PRIVATE_KEYS = new Set(['funds', 'pendingFunds', 'hand', 'temp', 'shop', 'canReady', 'rewardOffer', 'freeRefreshes', 'loadout']);
function privateKeys(v, path = '', out = []) {
if (Array.isArray(v)) v.forEach((x, i) => privateKeys(x, `${path}[${i}]`, out));
else if (v && typeof v === 'object') {
for (const [k, x] of Object.entries(v)) {
if (PRIVATE_KEYS.has(k)) out.push(`${path}.${k}`);
privateKeys(x, `${path}.${k}`, out);
}
}
return out;
}
const frames = (h, id) => h.sent.filter(([pid]) => pid === id).map(([, x]) => x);
/** The intents a player has, refused for a spectator (the platform never routes them; the match refuses them too). */
const REFUSED = [
{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.refresh' }, { t: 'g.levelUp' }, { t: 'g.ready', ready: true },
{ t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }, { t: 'g.unitStats', seq: 1 }, { t: 'g.choice', idx: 0 },
{ t: 'g.band', bandId: 'band_bldsk' }, { t: 'b.progress', battleId: 'x', gt: 1, killed: 0, total: 1 },
];
test('a spectator seat watches a whole match (各自行动, 联防, 最终攻势, settlement) like an eliminated player — never a private view', () => {
const h = makeMatch({
mode: 'coop', difficulty: 'FUNNY', humans: 3, seed: 9301, fake: true, clientCombat: true, spectators: [S],
// round 1: p_0 leaks → p_1 / p_2 (perfect) hold a 联防; the Final Assault falls fast
script: (b) => (b.kind === 'boss' ? { bossDps: 20000 } : b.kind === 'normal' && b.round === 1 ? { duration: 1, leaks: { p_0: 4 } } : { duration: 1 }),
}).start();
const m = h.m;
h.autoHumans();
assert.ok(m.spectators.has(S) && !m.players.has(S), 'a spectator, never a player');
for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t);
h.toPrep(1);
for (const msg of REFUSED) assert.deepEqual(m.handle(S, msg), { error: ERR.SPECTATOR }, msg.t);
// prep: a player's board, like a teammate scouting it (m.field prep: true)
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const scout = h.lastTo(S, 'm.field');
assert.equal(scout.fieldId, 'n:p_1');
assert.equal(scout.prep, true);
assert.deepEqual(scout, m.prepFieldMeta(h.ps('p_1')), 'exactly what a teammate scouting the board gets');
assert.equal(m.handle(S, { t: 'g.watch', fieldId: `n:${S}` }).error, ERR.BAD_TARGET, 'a spectator has no board');
const end = h.runToEnd();
assert.equal(end.victory, true);
const got = frames(h, S);
for (const x of got) assert.ok(['m.public', 'm.field', 'b.start', 'b.end', 'm.result'].includes(x.t), `${x.t} sent to a spectator`);
assert.equal(h.allTo(S, 'm.private').length, 0, 'no private player view');
const starts = h.allTo(S, 'b.start');
assert.ok(starts.length > 0);
for (const st of starts) {
assert.equal(st.watch, true, `${st.fieldId}: watched`);
assert.equal(st.authoritative, false, `${st.fieldId}: never the authority`);
assert.ok(!st.spec.players.some((p) => p.playerId === S), 'never a player of a field');
}
const kinds = new Set(starts.map((x) => x.kind));
for (const k of ['normal', 'unite', 'boss']) assert.ok(kinds.has(k), `a ${k} field was shown (${[...kinds]})`);
// what it was shown in 各自行动 is what an eliminated player is shown: the first field of the phase
for (const st of starts.filter((x) => x.kind === 'normal')) assert.equal(st.fieldId, 'n:p_0');
// the Final Assault's end reaches the boss field it watched, like every human shown that field
const ends = h.allTo(S, 'b.end');
assert.ok(ends.some((e) => e.fieldId === starts.filter((x) => x.kind === 'boss').pop().fieldId), 'b.end of the watched boss field');
// the settlement: every player's public row, none of its own
const res = h.lastTo(S, 'm.result');
assert.ok(res, 'the spectator gets the result');
assert.equal(res.playerId, S);
assert.deepEqual(res.players.map((p) => p.playerId), ['p_0', 'p_1', 'p_2']);
assert.deepEqual({ ...res, playerId: null }, { ...h.lastTo('p_1', 'm.result'), playerId: null }, 'the same rows a player gets');
// nothing shown to it — unicast or broadcast — carries a private key
for (const x of [...got, ...h.bc]) assert.deepEqual(privateKeys(x), [], `${x.t}: private keys`);
assert.equal(m.errorCount, 0, JSON.stringify(m.errors.slice(0, 2)));
m.dispose();
});
test('during a battle a spectator may watch any field at once; one added mid-battle gets the state; a removed one gets nothing', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 9302, fake: true, clientCombat: true, instant: false, pace: 'paced', spectators: [S],
script: () => ({ duration: 30 }),
}).start();
const m = h.m;
h.toPrep(1);
for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.COMBAT);
h.sched.advance(500);
const first = h.lastTo(S, 'b.start');
assert.deepEqual([first.fieldId, first.watch, first.authoritative], ['n:p_0', true, false], 'auto-observes the first field');
// a fighting player may not look elsewhere — a spectator (like an eliminated player) may
assert.equal(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }).error, ERR.WRONG_PHASE);
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
const st = h.lastTo(S, 'b.start');
assert.deepEqual([st.fieldId, st.watch, st.authoritative], ['n:p_1', true, false]);
// the field's spec as every watcher gets it — minus the player's funds at the battle start (no battle effect reads it)
const spec = m.fields.find((f) => f.fieldId === 'n:p_1').spec;
assert.ok(Object.hasOwn(spec.players[0].contentInfo, 'funds'), 'a player / teammate replica gets contentInfo.funds');
const { funds, ...counters } = spec.players[0].contentInfo;
assert.ok(Number.isFinite(funds));
assert.deepEqual(st.spec, { ...spec, players: [{ ...spec.players[0], contentInfo: counters }] }, 'the same spec without funds');
assert.deepEqual(privateKeys(st), []);
assert.equal(m.watchers.get(S), 'n:p_1');
for (const f of m.fields) assert.notEqual(f.authority, S);
// a spectator seat taken mid-battle: m.public and the first field at once
const LATE = 's_late';
m.addSpectator(LATE);
assert.ok(h.lastTo(LATE, 'm.public'));
assert.equal(h.lastTo(LATE, 'b.start').fieldId, 'n:p_0');
assert.equal(h.allTo(LATE, 'm.private').length, 0);
m.addSpectator(LATE); // a resync: idempotent registration, the state again
assert.equal(h.allTo(LATE, 'm.public').length, 2);
assert.equal(m.spectators.size, 2);
m.addSpectator('p_0'); // a player's id is never a spectator
assert.ok(!m.spectators.has('p_0'));
assert.equal(h.allTo('p_0', 'm.private').length > 0, true);
// removed: nothing reaches it any more, its watch is gone
m.removeSpectator(LATE);
const before = frames(h, LATE).length;
assert.equal(m.watchers.has(LATE), false);
assert.equal(m.handle(LATE, { t: 'g.watch', fieldId: 'n:p_0' }).error, ERR.NOT_IN_ROOM);
h.run(() => m.phase === PHASE.PREP && m.round === 2, { maxSteps: 2e6 });
assert.equal(frames(h, LATE).length, before, 'a removed spectator is sent nothing');
assert.equal(h.allTo(S, 'm.private').length, 0);
m.dispose();
});
test('server-run combat (SP_COMBAT=server): a spectator is streamed the first field like an eliminated player', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 9303, fake: true, spectators: [S] }).start();
const m = h.m;
h.toPrep(1);
for (const pid of ['p_0', 'p_1']) m.handle(pid, { t: 'g.ready', ready: true });
h.run(() => m.phase === PHASE.SETTLE);
const meta = h.allTo(S, 'm.field');
assert.ok(meta.length > 0 && meta[0].fieldId === 'n:p_0', 'm.field of the first field');
assert.ok(h.allTo(S, 'b.snap').length > 0, 'its snapshots');
assert.equal(h.allTo(S, 'm.private').length, 0);
m.dispose();
});