Both leaks were found while planning the common board (Gitea#20 step 1), and both are live multiplayer bugs with or without that display, so they are fixed now rather than with 0.8.0. `game.log` is one shared list and `linesSince(seat)` slices it with no per-seat filter, so every line reaches every player. It carried the SEED in the opening line of each multiplayer game — the whole future of the deal — and the NAME OF A CARD DRAWN BLIND from the face-down Home Office deck. Solitaire deliberately keeps both: a one-seat table has nobody to leak to, the seed is what a bug report quotes, and a player's own history naming their own draw is the record. A Department slot is face up and stays named. The drawer still learns their card through `justDrawn`, which already goes to that seat alone. Neither was found by a test. Every test in `redaction.test.ts` passes an empty log, so the whole of narration has sat outside the redaction net since the net was built. Both now have tests there; TODO #91 carries what is still owed and supersedes #78, which described a gap that had already been closed and never mentioned this one. `docs/rules/` had no current description of the game, and `content.ts` named `card-reference.md` as the file that carries what the cards say — a file whose own banner says not to use its numbers, describing the v0.4.5 deck where 3/4 is a Mail-Express with three coaches. Every file in that directory is a deliberate historical record, so none of them is rewritten. `as-built.md` is new and GENERATED from the same catalogues the engine instantiates from, with a test that re-runs the generator and fails when the checked-in file disagrees. A hand-written replacement would have drifted the same way, for the same reason. TODO.md: #32 closed — the playtest migration note did its job and the jump is made; the durable fact it carried is kept. #78 retired in favour of #91. The "play it at a table" section now records that 0.7.4-0.7.9 were test-run without change requests, and that more testing comes at the end of the 0.7.9 series. 897 tests pass, up from 891. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
/**
|
|
* §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<string>([...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);
|
|
});
|
|
});
|