feedback3: merge W3E — two spectator seats per co-op room (GitHub-relayed community request #26; a remake feature): watch like an eliminated player, never act, no private data
This commit is contained in:
commit
8707c161b9
28 files changed
+976
-82
No files matched your search
@@ -1047,6 +1047,31 @@ describe('screen helpers', () => {
|
||||
assert.match(inviteLink('ABCD'), /\?room=ABCD$/);
|
||||
});
|
||||
|
||||
test('room: spectator seats (community report #26) — isSpectating; roomFacts never counts a spectator as a player', async () => {
|
||||
const { roomFacts } = await mod('screens/room.js');
|
||||
const { isSpectating } = await mod('store.js');
|
||||
const room = {
|
||||
code: 'ABCD', hostId: 'h', mode: 'coop', difficulty: 'HARD',
|
||||
seats: [{ seat: 0, playerId: 'h', name: 'Host', isBot: false, ready: false, connected: true }, null, null, null],
|
||||
spectators: [{ playerId: 's', name: 'Spec', connected: true }],
|
||||
};
|
||||
assert.equal(isSpectating(room, 's'), true);
|
||||
assert.equal(isSpectating(room, 'h'), false);
|
||||
assert.equal(isSpectating({ ...room, spectators: undefined }, 's'), false, 'a room.state without the list');
|
||||
assert.equal(isSpectating(room, null), false);
|
||||
assert.equal(isSpectating(null, 's'), false);
|
||||
const f = roomFacts(room, 's');
|
||||
assert.equal(f.spectating, true);
|
||||
assert.equal(f.mine, null);
|
||||
assert.equal(f.humans.length, 1, 'never a player');
|
||||
assert.equal(f.emptySeats, 3, 'a free player seat stays free (入座)');
|
||||
assert.deepEqual(f.spectators.map((x) => x.playerId), ['s']);
|
||||
const hf = roomFacts(room, 'h');
|
||||
assert.equal(hf.canStart, true, 'a spectator never blocks the start');
|
||||
assert.equal(hf.spectating, false);
|
||||
assert.deepEqual(roomFacts({ ...room, spectators: 'bad' }, 'h').spectators, []);
|
||||
});
|
||||
|
||||
test('components: roman / doctorNo / secondsLeft', async () => {
|
||||
const { roman, doctorNo, secondsLeft } = await mod('ui/components.js');
|
||||
assert.deepEqual([1, 2, 3, 4, 5, 6].map(roman), ['I', 'II', 'III', 'IV', 'V', 'VI']);
|
||||
|
||||
+179
-2
@@ -19,7 +19,7 @@ import { sanitizeName, TokenBucket, SessionRegistry, clientAddress, normalizeIp,
|
||||
import { StubMatch as Match } from '../server/match/StubMatch.js';
|
||||
import { Match as RealMatch } from '../server/match/Match.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR, MAX_SEATS, PHASE } from '../shared/constants.js';
|
||||
import { ERR, MAX_SEATS, MAX_SPECTATORS, PHASE, EMOTES } from '../shared/constants.js';
|
||||
|
||||
const CODE_RE = new RegExp(`^[${CODE_ALPHABET}]{4}$`);
|
||||
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
@@ -606,6 +606,121 @@ describe('websocket lobby', () => {
|
||||
await expectError(solo, { t: 'room.addBot' }, ERR.ROOM_FULL);
|
||||
});
|
||||
|
||||
// community report #26 (a remake feature): spectator seats — server/lobby.js header
|
||||
test('spectator seats: join / cap / leave / host removal; never a player, never acting, never keeping a room', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
assert.deepEqual(st.spectators, []);
|
||||
const guest = await pool.player('Guest');
|
||||
await joinRoom(guest, st.code);
|
||||
const s1 = await pool.player('Watcher1');
|
||||
await expectOk(s1, { t: 'room.spectate', code: st.code.toLowerCase() });
|
||||
const v1 = await s1.waitFor('room.state', (s) => s.spectators.some((x) => x.playerId === s1.id));
|
||||
assert.deepEqual(v1.spectators, [{ playerId: s1.id, name: 'Watcher1', connected: true }]);
|
||||
assert.ok(!v1.seats.some((x) => x && x.playerId === s1.id), 'never in a player seat');
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 1);
|
||||
await expectOk(s1, { t: 'room.spectate', code: st.code }); // idempotent
|
||||
await expectError(guest, { t: 'room.spectate', code: st.code }, ERR.ALREADY); // a player never switches in place
|
||||
// a spectator may not act
|
||||
await expectError(s1, { t: 'room.ready', ready: true }, ERR.SPECTATOR);
|
||||
for (const msg of [{ t: 'room.start' }, { t: 'room.addBot' }, { t: 'room.setDifficulty', difficulty: 'HARD' }, { t: 'room.removeSpectator', playerId: s1.id }]) {
|
||||
await expectError(s1, msg, ERR.NOT_HOST);
|
||||
}
|
||||
await expectError(s1, { t: 'g.ready', ready: true }, ERR.WRONG_PHASE);
|
||||
// the cap; spectators never count toward the players: two player seats still free
|
||||
const s2 = await pool.player('Watcher2');
|
||||
await expectOk(s2, { t: 'room.spectate', code: st.code });
|
||||
assert.equal(MAX_SPECTATORS, 2);
|
||||
const s3 = await pool.player('Watcher3');
|
||||
await expectError(s3, { t: 'room.spectate', code: st.code }, ERR.ROOM_FULL);
|
||||
await joinRoom(s3, st.code);
|
||||
await expectOk(host, { t: 'room.addBot' });
|
||||
const full = await host.waitFor('room.state', (s) => s.seats.every(Boolean));
|
||||
assert.equal(full.spectators.length, 2);
|
||||
await expectOk(s3, { t: 'room.ready', ready: true });
|
||||
await expectOk(guest, { t: 'room.ready', ready: true });
|
||||
// a spectator in the lobby takes a free player seat with room.join of the same code
|
||||
await expectError(s2, { t: 'room.join', code: st.code }, ERR.ROOM_FULL);
|
||||
await expectOk(host, { t: 'room.removeBot', seat: 3 });
|
||||
const sat = await joinRoom(s2, st.code);
|
||||
assert.equal(seatOf(sat, s2.id).seat, 3);
|
||||
assert.deepEqual(sat.spectators.map((x) => x.playerId), [s1.id]);
|
||||
// leave
|
||||
host.clearInbox();
|
||||
await expectOk(s1, { t: 'room.leave' });
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 0);
|
||||
await expectError(s1, { t: 'room.leave' }, ERR.NOT_IN_ROOM);
|
||||
await expectError(s1, { t: 'room.ready', ready: true }, ERR.NOT_IN_ROOM);
|
||||
// host removal: room.closed {kicked}
|
||||
const s4 = await pool.player('Watcher4');
|
||||
await expectOk(s4, { t: 'room.spectate', code: st.code });
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 1);
|
||||
await expectError(host, { t: 'room.removeSpectator', playerId: guest.id }, ERR.BAD_TARGET);
|
||||
await expectError(guest, { t: 'room.removeSpectator', playerId: s4.id }, ERR.NOT_HOST);
|
||||
await expectOk(host, { t: 'room.removeSpectator', playerId: s4.id });
|
||||
assert.equal((await s4.waitFor('room.closed')).reason, 'kicked');
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 0);
|
||||
await expectError(s4, { t: 'room.leave' }, ERR.NOT_IN_ROOM);
|
||||
// an offline spectator removed: freed at once, told on its next resume
|
||||
const s5 = await pool.player('Watcher5');
|
||||
await expectOk(s5, { t: 'room.spectate', code: st.code });
|
||||
await s5.terminate();
|
||||
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === s5.id)?.connected === false);
|
||||
await expectOk(host, { t: 'room.removeSpectator', playerId: s5.id });
|
||||
const s5back = await pool.connect();
|
||||
await s5back.hello('Watcher5', s5.token);
|
||||
assert.equal((await s5back.waitFor('room.closed')).reason, 'kicked');
|
||||
await s5back.expectNone('room.state');
|
||||
// solo rooms have no spectator seat; unknown codes
|
||||
const solo = await pool.player('Solo');
|
||||
const soloSt = await createRoom(solo, 'solo', 'FUNNY');
|
||||
await expectError(s4, { t: 'room.spectate', code: soloSt.code }, ERR.ROOM_FULL);
|
||||
await expectError(s4, { t: 'room.spectate', code: st.code === 'ZZZZ' ? 'YYYY' : 'ZZZZ' }, ERR.ROOM_NOT_FOUND);
|
||||
// a spectator never keeps a room alive: its last human leaving closes it for the spectator
|
||||
const lone = await pool.player('Lone');
|
||||
const loneSt = await createRoom(lone);
|
||||
await expectOk(s4, { t: 'room.spectate', code: loneSt.code });
|
||||
await lone.waitFor('room.state', (s) => s.spectators.length === 1);
|
||||
await expectOk(lone, { t: 'room.leave' });
|
||||
assert.equal((await s4.waitFor('room.closed')).reason, 'empty');
|
||||
await expectError(s4, { t: 'room.spectate', code: loneSt.code }, ERR.ROOM_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('spectators during a match: broadcasts but no private view, join mid-match, only watching, reconnect gets the seat back', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const spec = await pool.player('Spec');
|
||||
await expectOk(spec, { t: 'room.spectate', code: st.code });
|
||||
await expectOk(host, { t: 'room.start' }); // the spectator is no player to wait for
|
||||
const pub = await spec.waitFor('m.public');
|
||||
assert.deepEqual(pub.players.map((p) => p.playerId), [host.id], 'never one of the match\'s players');
|
||||
const late = await pool.player('Late');
|
||||
await expectError(late, { t: 'room.join', code: st.code }, ERR.ROOM_STARTED);
|
||||
await expectOk(late, { t: 'room.spectate', code: st.code }); // a running match: a spectator seat is still free
|
||||
const lateState = await late.waitFor('room.state', (s) => s.inMatch);
|
||||
assert.deepEqual(lateState.spectators.map((x) => x.playerId), [spec.id, late.id]);
|
||||
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.buy', slot: 0 }, { t: 'g.ready', ready: true }, { t: 'g.emote', id: EMOTES[0] }, { t: 'g.autoplay', on: true }]) {
|
||||
await expectError(spec, msg, ERR.SPECTATOR);
|
||||
}
|
||||
await expectOk(spec, { t: 'room.loadout', entries: {} }); // kept for its session, never handed to the match
|
||||
// drop and resume with the token: the seat is kept meanwhile and given back
|
||||
await late.terminate();
|
||||
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false);
|
||||
const back = await pool.connect();
|
||||
const w = await back.hello('Late', late.token);
|
||||
assert.equal(w.resumed, true);
|
||||
const rs = await back.waitFor('room.state');
|
||||
assert.deepEqual(rs.spectators.find((x) => x.playerId === late.id), { playerId: late.id, name: 'Late', connected: true });
|
||||
await expectError(back, { t: 'g.infoReady' }, ERR.SPECTATOR);
|
||||
// the match ends: the spectators see the result (a broadcast) and stay in the room, which returns to its lobby
|
||||
await expectOk(host, { t: 'g.infoReady' });
|
||||
await spec.waitFor('m.result');
|
||||
const lobby = await spec.waitFor('room.state', (s) => !s.inMatch && s.spectators.length === 2);
|
||||
assert.deepEqual(lobby.spectators.map((x) => x.playerId), [spec.id, late.id]);
|
||||
assert.equal(spec.log.filter((x) => x.t === 'm.private').length, 0, 'no private view, ever');
|
||||
assert.equal(back.log.filter((x) => x.t === 'm.private').length, 0);
|
||||
});
|
||||
|
||||
test('host-only commands, difficulty change un-readies guests, host migration on leave', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host, 'coop', 'NORMAL');
|
||||
@@ -873,6 +988,12 @@ describe('websocket lobby', () => {
|
||||
if (m.t === 'g.emote' && rnd() < 0.5) m.id = 'autochess_battle_happy';
|
||||
return m;
|
||||
}
|
||||
// spectator seats (community report #26)
|
||||
if (r < 0.95) {
|
||||
return rnd() < 0.7
|
||||
? { t: 'room.spectate', code: pick([...codes(), 'ZZZZ', ...codes().map((c) => c.toLowerCase())]) || 'QQQQ' }
|
||||
: { t: 'room.removeSpectator', playerId: pick([...clients.map((x) => x.id), 'p_nope', 1]) };
|
||||
}
|
||||
return pick([{ t: 'hello', name: pick(['Re', '', 'x'.repeat(20)]) }, { t: 'ping', c: pick([1, 'x']) }, { t: pick(['nope', '__proto__', 'toString']) }, { t: 'room.join', code: pick(junk) }]);
|
||||
};
|
||||
const okCounts = {};
|
||||
@@ -910,9 +1031,20 @@ describe('websocket lobby', () => {
|
||||
assert.ok(sess, 'seated session exists');
|
||||
assert.equal(sess.roomCode, room.code);
|
||||
}
|
||||
// spectator seats: capped, none in solo rooms, never also a player (here or elsewhere), never the host
|
||||
assert.ok(room.spectators.length <= MAX_SPECTATORS, `room ${room.code} spectator cap`);
|
||||
if (room.mode === 'solo') assert.equal(room.spectators.length, 0, 'no spectator seat in a solo room');
|
||||
for (const sp of room.spectators) {
|
||||
assert.ok(!seen.has(sp.playerId), 'a spectator is seated (or spectating) twice');
|
||||
seen.add(sp.playerId);
|
||||
assert.ok(!room.seatOf(sp.playerId) && room.hostId !== sp.playerId);
|
||||
assert.equal(srv.registry.byId(sp.playerId)?.roomCode, room.code, 'the spectator\'s session points at its room');
|
||||
}
|
||||
}
|
||||
if (okCounts['room.spectate'] == null) assert.fail('fuzz never took a spectator seat');
|
||||
for (const sess of srv.registry.all()) {
|
||||
if (sess.roomCode) assert.ok(srv.lobby.rooms.get(sess.roomCode)?.seatOf(sess.playerId), 'session points at its seat');
|
||||
const r = sess.roomCode ? srv.lobby.rooms.get(sess.roomCode) : null;
|
||||
if (sess.roomCode) assert.ok(r?.seatOf(sess.playerId) || r?.spectatorOf(sess.playerId), 'session points at its seat');
|
||||
}
|
||||
const h = JSON.parse((await httpReq(srv.port, '/healthz')).body.toString());
|
||||
assert.equal(h.ok, true);
|
||||
@@ -1012,6 +1144,8 @@ class RecordingMatch extends Match {
|
||||
onDisconnect(playerId) { this.calls.push(['onDisconnect', playerId]); super.onDisconnect(playerId); }
|
||||
onReconnect(playerId) { this.calls.push(['onReconnect', playerId]); super.onReconnect(playerId); }
|
||||
onLeave(playerId) { this.calls.push(['onLeave', playerId]); super.onLeave(playerId); }
|
||||
addSpectator(playerId) { this.calls.push(['addSpectator', playerId]); }
|
||||
removeSpectator(playerId) { this.calls.push(['removeSpectator', playerId]); }
|
||||
dispose() { this.calls.push(['dispose']); super.dispose(); }
|
||||
}
|
||||
|
||||
@@ -1063,6 +1197,49 @@ describe('lobby timers and match interface', () => {
|
||||
await expectOk(guest, { t: 'room.addBot' });
|
||||
});
|
||||
|
||||
test('spectator seats: lobby grace like a player; never the host; the match hooks (opts.spectators, addSpectator / removeSpectator, only g.watch)', async () => {
|
||||
const host = await pool.player('Host');
|
||||
const st = await createRoom(host);
|
||||
const spec = await pool.player('Spec');
|
||||
await expectOk(spec, { t: 'room.spectate', code: st.code });
|
||||
// a dropped spectator keeps its seat for the lobby grace, then is freed and told room.closed {timeout} on resume
|
||||
const gone = await pool.player('Gone');
|
||||
await expectOk(gone, { t: 'room.spectate', code: st.code });
|
||||
await gone.terminate();
|
||||
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === gone.id)?.connected === false);
|
||||
host.clearInbox();
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 1, GRACE + 1000);
|
||||
const goneBack = await pool.connect();
|
||||
await goneBack.hello('Gone', gone.token);
|
||||
assert.equal((await goneBack.waitFor('room.closed')).reason, 'timeout');
|
||||
// the match: the spectator is passed apart from the seats
|
||||
RecordingMatch.instances.length = 0;
|
||||
await expectOk(host, { t: 'room.start' });
|
||||
const m = RecordingMatch.instances[0];
|
||||
assert.deepEqual(m.opts.spectators, [spec.id]);
|
||||
assert.deepEqual(m.opts.seats.map((s) => s.playerId), [host.id]);
|
||||
// only g.watch of a spectator reaches the match
|
||||
await expectError(spec, { t: 'g.levelUp' }, ERR.SPECTATOR);
|
||||
await spec.request({ t: 'g.watch', fieldId: `n:${host.id}` });
|
||||
assert.deepEqual(m.calls.filter((c) => c[0] === 'handle' && c[1] === spec.id).map((c) => c[2]), ['g.watch']);
|
||||
// joining mid-match, a drop (the match is not told), a resume (resync) → addSpectator; leaving → removeSpectator
|
||||
const late = await pool.player('Late');
|
||||
await expectOk(late, { t: 'room.spectate', code: st.code });
|
||||
await late.terminate();
|
||||
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === false);
|
||||
host.clearInbox();
|
||||
const back = await pool.connect();
|
||||
await back.hello('Late', late.token);
|
||||
await host.waitFor('room.state', (s) => s.spectators.find((x) => x.playerId === late.id)?.connected === true);
|
||||
await expectOk(back, { t: 'g.leave' });
|
||||
await host.waitFor('room.state', (s) => s.spectators.length === 1);
|
||||
const hooks = m.calls.filter((c) => c[1] === late.id).map((c) => c[0]);
|
||||
assert.deepEqual(hooks, ['addSpectator', 'addSpectator', 'removeSpectator'], 'never onDisconnect / onReconnect / onLeave');
|
||||
// the host leaves mid-match: a spectator never becomes the host and never keeps the room
|
||||
await expectOk(host, { t: 'room.leave' });
|
||||
assert.equal((await spec.waitFor('room.closed')).reason, 'empty');
|
||||
});
|
||||
|
||||
test('match number: every match of a room gets the next matchNo (battleIds stay unique even with the same seed)', async () => {
|
||||
const solo = await pool.player('Solo');
|
||||
await createRoom(solo, 'solo', 'FUNNY');
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// Real-server browser E2E — spectator seats (community report #26, a remake feature: the official room has none). The
|
||||
// lobby's 观战 takes a spectator seat; the room lists it under 观战席 (the host's ✕ frees one — that spectator is told
|
||||
// room.closed {kicked}); the spectator's room view has 观战中 instead of the ready button; in the match it has no shop,
|
||||
// no ready toggle, no emote wheel and reads 观战中 (never "你已被淘汰"), is shown the first player's board in 休整期 by
|
||||
// itself and the first field in battle, never holds an m.private, sends nothing but g.watch (plus its own room.spectate /
|
||||
// room.loadout), gets its seat back after a reload, and 离开观战 returns it to the lobby.
|
||||
// Server side: test/lobby.test.js, test/match/spectator.test.js, test/match/lobby-integration.test.js.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/spectator.e2e.test.js
|
||||
//
|
||||
// Screenshots: test/e2e/out/spectator-*.png.
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
|
||||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||||
const ALLOWED = new Set(['room.spectate', 'room.loadout', 'g.watch', 'g.leave', 'room.leave']);
|
||||
|
||||
/** The spectator's own view of the store: still a spectator, never a private view. */
|
||||
const specState = (c) => c.page.evaluate(() => {
|
||||
const s = globalThis.__SP__.store.get();
|
||||
return {
|
||||
spectating: !!s.room?.spectators?.some((x) => x.playerId === s.me.playerId),
|
||||
priv: s.match.private != null,
|
||||
battle: s.match.battle ? { watch: !!s.match.battle.watch, authoritative: !!s.match.battle.authoritative, fieldId: s.match.battle.fieldId } : null,
|
||||
field: s.match.field ? { fieldId: s.match.field.fieldId, prep: !!s.match.field.prep } : null,
|
||||
};
|
||||
});
|
||||
|
||||
describe('spectator seats (community report #26, real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('观战 from the lobby → 观战席 in the room → the match: watching only, no private view, resume, 离开观战', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 4, startRound: 1, kit: 3, autoPlace: true } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const host = new Client(P, srv.base, 'host', { prefix: 'spectator' });
|
||||
const spec = new Client(P, srv.base, 'spec', { prefix: 'spectator', w: 1280, h: 720 });
|
||||
let extra = null;
|
||||
try {
|
||||
await host.open();
|
||||
await host.enter('凯尔希');
|
||||
await host.click('.mode-card', '同盟模拟');
|
||||
await host.click('.diff-card', '标准模拟');
|
||||
await host.click('.create-box button', '创建同盟');
|
||||
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
|
||||
await host.click('.seat--empty button', '添加 AI 队友');
|
||||
|
||||
// the lobby's 观战 entry
|
||||
await spec.open();
|
||||
await spec.hookRequests();
|
||||
await spec.enter('阿米娅');
|
||||
await spec.click('.join-row input');
|
||||
await spec.page.keyboard.type(room.code);
|
||||
await spec.click('.join-row button', '观战');
|
||||
await spec.page.waitForSelector('.room-screen .specbar__who.is-me', { timeout: 10000 });
|
||||
const roomView = await spec.page.evaluate(() => ({
|
||||
bar: document.querySelector('.room-bar__right .btn--xl')?.textContent || '',
|
||||
barDisabled: !!document.querySelector('.room-bar__right .btn--xl')?.disabled,
|
||||
status: document.querySelector('.room-bar__status')?.textContent || '',
|
||||
seats: [...document.querySelectorAll('.seat__name')].map((e) => e.textContent),
|
||||
}));
|
||||
assert.match(roomView.bar, /观战中/);
|
||||
assert.equal(roomView.barDisabled, true);
|
||||
assert.match(roomView.status, /观战中/);
|
||||
assert.ok(!roomView.seats.includes('阿米娅'), 'never in a player seat');
|
||||
await spec.shot('room-spectator');
|
||||
|
||||
// a second spectator (a bare socket) — the host's ✕ removes it: room.closed {kicked}
|
||||
extra = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await extra.hello('华法琳');
|
||||
assert.equal((await extra.request({ t: 'room.spectate', code: room.code })).t, 'ok');
|
||||
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 2, { timeout: 8000 });
|
||||
await host.shot('room-host-specbar');
|
||||
await host.click('.specbar__who button[aria-label*="华法琳"]');
|
||||
assert.equal((await extra.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
|
||||
await host.page.waitForFunction(() => document.querySelectorAll('.specbar__who').length === 1, { timeout: 8000 });
|
||||
|
||||
// the match: a spectator is no player to wait for
|
||||
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
for (const c of [host, spec]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
assert.match(await spec.page.evaluate(() => document.querySelector('.brief__foot .btn--primary')?.textContent || ''), /观战中/);
|
||||
await host.click('.brief__foot .btn--primary', '准备就绪');
|
||||
const t0 = Date.now();
|
||||
for (;;) {
|
||||
const s = await host.st();
|
||||
if (s.phase === 'PREP') break;
|
||||
assert.ok(Date.now() - t0 < 120000, `host stuck in ${s.phase}`);
|
||||
if (s.phase === 'BAND_DRAFT' && s.draft?.turn === s.me) {
|
||||
await host.click('.dband:not(.is-taken)', null, { nth: 2, optional: true, timeout: 2000 });
|
||||
await sleep(200);
|
||||
await host.click('.draft-detail__btns .btn--primary', '确认选择', { optional: true, timeout: 2000 });
|
||||
}
|
||||
await sleep(300);
|
||||
}
|
||||
// 休整期: the first player's board by itself, read-only; no shop / ready / 交流
|
||||
await spec.waitFor((s) => s.phase === 'PREP', 'spectator in prep', 30000);
|
||||
await spec.page.waitForSelector('.gm__watching', { timeout: 10000 });
|
||||
await spec.page.waitForSelector('.gm__dead--spectator', { timeout: 10000 });
|
||||
const prep = await spec.page.evaluate(() => ({
|
||||
watching: document.querySelector('.gm__watching')?.textContent || '',
|
||||
back: [...document.querySelectorAll('.gm__watching button')].length,
|
||||
pill: document.querySelector('.gm__dead')?.textContent || '',
|
||||
shop: !!document.querySelector('.shopbar, .shopbar-tab'),
|
||||
ready: !!document.querySelector('.readybtn'),
|
||||
emote: !!document.querySelector('.ewheel'),
|
||||
}));
|
||||
assert.match(prep.watching, /凯尔希/, 'the host\'s board is shown');
|
||||
assert.equal(prep.back, 0, 'no 返回自己 — a spectator has no board');
|
||||
assert.match(prep.pill, /观战中/);
|
||||
assert.doesNotMatch(prep.pill, /淘汰/);
|
||||
assert.deepEqual([prep.shop, prep.ready, prep.emote], [false, false, false]);
|
||||
const sp1 = await specState(spec);
|
||||
assert.deepEqual([sp1.spectating, sp1.priv], [true, false]);
|
||||
assert.equal(sp1.field?.prep, true);
|
||||
await sleep(1200);
|
||||
await spec.shot('prep');
|
||||
|
||||
// battle: the first field, a display replica (watch), never the authority
|
||||
await host.click('.readybtn', null, { timeout: 10000 });
|
||||
await spec.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
await spec.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.battle?.watch, { timeout: 20000 });
|
||||
const sp2 = await specState(spec);
|
||||
assert.deepEqual([sp2.priv, sp2.battle.watch, sp2.battle.authoritative], [false, true, false]);
|
||||
// the auto-observed field reads "👁 name" (as for an eliminated player), with no 返回战场 — never "你已被淘汰"
|
||||
await spec.page.waitForSelector('.chud__observe', { timeout: 10000 });
|
||||
const hud = await spec.page.evaluate(() => ({
|
||||
text: document.querySelector('.chud')?.textContent || '',
|
||||
back: !!document.querySelector('.chud__back'),
|
||||
emote: !!document.querySelector('.ewheel'),
|
||||
}));
|
||||
assert.match(hud.text, /凯尔希/);
|
||||
assert.doesNotMatch(hud.text, /淘汰/);
|
||||
assert.deepEqual([hud.back, hud.emote], [false, false]);
|
||||
await sleep(1500);
|
||||
await spec.shot('battle');
|
||||
|
||||
// a reload: the seat comes back with the match
|
||||
await spec.page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await spec.page.waitForFunction(() => !!globalThis.__SP__ && !!globalThis.__SP__.store.get().match.public, { timeout: 30000 });
|
||||
const sp3 = await specState(spec);
|
||||
assert.deepEqual([sp3.spectating, sp3.priv], [true, false]);
|
||||
|
||||
// 离开观战: back to the lobby, the match goes on
|
||||
await spec.click('.gtop__exit', null, { timeout: 15000 });
|
||||
await spec.click('.modal button', '离开观战');
|
||||
await spec.page.waitForSelector('.lobby-screen', { timeout: 10000 });
|
||||
await host.page.waitForFunction(() => (globalThis.__SP__.store.get().room?.spectators || []).length === 0, { timeout: 8000 });
|
||||
const sent = await spec.requests();
|
||||
assert.deepEqual([...new Set(sent.map((r) => r[0]))].filter((t) => !ALLOWED.has(t)), [], 'a spectator sends no player action');
|
||||
assert.deepEqual(problemsOf([host, spec]), []);
|
||||
} finally {
|
||||
await extra?.terminate().catch(() => {});
|
||||
for (const c of [host, spec]) await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user