/** * §7's redaction test — "the single most important test in the plan." * * Everything else about `Frame` degrades gracefully; a redaction bug hands one player's hand to * another and cannot be walked back once it has been seen. `test/multiplayer.test.ts`'s "the view * shows one seat at a time" section already proves `snapshot(s, ..., viewer)` gives each seat its * own hand, board and Revenue — spot-checks that today's code does the right thing. This is the * different, exhaustive check: serialize a seat's whole `Frame` and assert none of some OTHER seat's * actual secret data appears anywhere in it, so a future careless edit is caught rather than assumed * safe. No server needed — `snapshot()` and a multi-player `GameState` are all this exercises. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { pump } from '../src/engine/advance.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { cardName, snapshot } from '../src/sim/view.ts'; import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts'; const config: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false, }, }; /** Plays a real multi-player game partway — enough for every seat to hold a real, distinct hand. */ function midGame(players: number, seed: number): GameState { const s = createGame({ id: `redact-${players}`, seed, config, playerNames: Array.from({ length: players }, (_, i) => `p${i}`), }); const r = playGame(s, developerBot, pump, 400); // A partial or finished game both exercise real hands — either is fine for this check. void r; return s; } describe('redaction — a seat\'s Frame never carries another seat\'s secrets', () => { it('never contains another seat\'s actual hand-card ids', () => { for (const players of [3, 4]) { const s = midGame(players, 1000 + players); for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) { const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer)); for (let other = 0 as PlayerIndex; other < players; other++) { if (other === viewer) continue; for (const cardId of s.decks.hands.get(other) ?? []) { assert.ok( !serialized.includes(`"${cardId}"`), `${players}p seed ${1000 + players}: seat ${viewer}'s Frame contains seat ${other}'s ` + `hand card id "${cardId}"`, ); } } } } }); it('never contains the Home Office deck\'s order or contents, only its count', () => { for (const players of [3, 4]) { const s = midGame(players, 2000 + players); // The deck's own card ids are the thing that must never leak — distinct from any hand's ids, // since a card once dealt is removed from `homeOffice` (state.ts). const deckIds = new Set(s.decks.homeOffice); for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) { const frame = snapshot(s, [], null, null, null, false, viewer); assert.equal(frame.deck, s.decks.homeOffice.length, 'deck field is not a plain count'); const serialized = JSON.stringify(frame); for (const cardId of deckIds) { assert.ok( !serialized.includes(`"${cardId}"`), `${players}p seed ${2000 + players}: seat ${viewer}'s Frame contains a Home Office deck id "${cardId}"`, ); } } } }); it('never carries the seed or rngState — Frame has no field for either', () => { // A structural guarantee, not a runtime one: confirmed here so a future field addition to Frame // that reintroduces one of these is at least forced past a reader of this test, if not the type // system directly (see Frame in src/sim/view.ts, which carries neither today). const s = midGame(3, 3003); const frame = snapshot(s, [], null, null, null, false, 0); assert.ok(!('seed' in frame), 'Frame gained a seed field'); assert.ok(!('rngState' in frame), 'Frame gained an rngState field'); const serialized = JSON.stringify(frame); assert.ok(!serialized.includes(String(s.seed)), 'the seed value leaked into the Frame some other way'); }); it('the tally that rides the Frame is aggregate counts, never a card id (Gitea#16)', () => { // Gitea#16's statistics live on `GameState` and reach a remote client on the Frame, which is // only safe because nothing in a Tally identifies a card. That is a property of what // `tally.ts` chooses to count, and nothing in the type system enforces it — so it is asserted // here, where a future counter that stashed a `cardId` "just for badges" would be caught. for (const players of [3, 4]) { const s = midGame(players, 5000 + players); const secrets = new Set([...s.decks.homeOffice]); for (const hand of s.decks.hands.values()) for (const id of hand) secrets.add(id); for (let viewer = 0 as PlayerIndex; viewer < players; viewer++) { const serialized = JSON.stringify(snapshot(s, [], null, null, null, false, viewer).tally); for (const cardId of secrets) { assert.ok(!serialized.includes(`"${cardId}"`), `the tally carries card id "${cardId}"`); } } } }); it('every seat sees the SAME tally — it is the table\'s account, not a private one', () => { const s = midGame(3, 5555); const tallies = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex).tally); for (const t of tallies) assert.deepEqual(t, tallies[0], 'the tally differs by seat'); }); it('only the viewer\'s own hand and handCount are non-public — everything else matches across seats', () => { // The redaction surface is four fields (§7), not sixty event types. Cross-check that seats agree // on everything else a Frame carries about shared state. const s = midGame(3, 4004); const frames = [0, 1, 2].map((p) => snapshot(s, [], null, null, null, false, p as PlayerIndex)); for (const f of frames) { assert.deepEqual(f.timetable, frames[0]!.timetable, 'the public timetable differs by seat'); assert.deepEqual(f.deck, frames[0]!.deck, 'the deck count differs by seat'); assert.deepEqual( f.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })), frames[0]!.players.map((p) => ({ index: p.index, revenue: p.revenue, hand: p.hand })), 'public standing (names, Revenue, hand COUNTS) differs by seat', ); } }); }); /** * THE OTHER HALF OF §7, AND THE HALF THAT WAS NEVER LOOKED AT. * * Every test above serializes a `Frame`, and every one of them passes `[]` for the narration log — * so the entire shared log has sat outside the redaction net since the net was built. It is not a * hypothetical hole: `game.log` is ONE list, and `linesSince(seat)` (`server/session.ts`) slices it * with no per-seat filter at all, so every line written into it reaches every player. * * Two things were being written into it that should never have left the seat that caused them, both * found while planning the public common board (Gitea#20 step 1) and both live in multiplayer today, * with or without that display: * * 1. the SEED, announced in the opening line of every multiplayer game — which hands every player * the whole future of the deal; * 2. the NAME OF A CARD DRAWN BLIND from the Home Office deck. * * SOLITAIRE IS DELIBERATELY LEFT ALONE in both cases. There is nobody to leak to at a one-seat * table, the seed in the log is what a bug report quotes, and a solo player's own history naming * the card they drew is the record, not a leak. The rule is "do not tell the OTHER seats", not * "write less down" — so both checks below assert the solitaire text is still there. */ describe('redaction — the shared narration log never carries a seat\'s secrets', () => { const names = ['Ann', 'Bob', 'Cy']; it('never announces the seed to the table (Gitea#20 step 1)', () => { const g = newMultiplayerGame(550943578, config, names); const log = g.log.map((l) => l.text).join('\n'); assert.ok( !/550943578/.test(log), `the seed was announced to every seat:\n${log}`, ); // The opening line must still say what the game IS — the leak is the number, not the line. assert.match(log, /Game Begins/); assert.match(log, /3 players/); }); it('still tells a solitaire player their own seed — there is nobody to leak it to', () => { const g = newGame(550943578); const log = g.log.map((l) => l.text).join('\n'); assert.match(log, /550943578/, 'a solo game stopped recording the seed its bug reports quote'); }); it('never names a card drawn blind from the Home Office deck (Gitea#20 step 1)', () => { const g = newMultiplayerGame(4242, config, names); // Drive to the first Home Office draw any seat makes, and note what it actually drew. let drawn: string | null = null; for (let i = 0; i < 400 && drawn === null; i++) { const actor = g.state.clock.currentActor; if (actor === null) break; const before = g.log.length; if (!submit(g, { type: 'localOps.choose', option: 'draw' }, actor as PlayerIndex)) continue; if (!submit(g, { type: 'draw.fromHomeOffice' }, actor as PlayerIndex)) continue; drawn = g.justDrawn; void before; } assert.ok(drawn, 'no seat ever drew from the Home Office deck'); const name = cardName(g.state, drawn!); const log = g.log.map((l) => l.text).join('\n'); assert.ok( !log.includes(name), `a blind draw named "${name}" to the whole table:\n${log.split('\n').slice(-6).join('\n')}`, ); // The draw itself is public — everyone saw a hand go to the deck. Only WHICH card is not. assert.match(log, /Home Office/i); // And the drawing seat still learns what it got: `justDrawn` is the owner-only channel, and // `session.ts` sends it to that seat alone. assert.equal(g.justDrawn, drawn); }); });