TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
551 lines
28 KiB
TypeScript
551 lines
28 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, publicSnapshot, snapshot } from '../src/sim/view.ts';
|
|
import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
|
import { createSession } from '../src/server/session.ts';
|
|
import { legalActions } from '../src/engine/legal.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);
|
|
});
|
|
});
|
|
|
|
|
|
/**
|
|
* #91 — THE SYSTEMATIC NET, not two strings.
|
|
*
|
|
* v0.7.9.2 closed the seed and the blind draw. Both were found by reading a plan, not by a test, and
|
|
* that is the point: a redaction suite made of the leaks somebody happened to notice proves nothing
|
|
* about the next one. This is the pass the common-board plan asks for (Gitea#20 step 1 § Tests) —
|
|
* serialise EVERYTHING a seat or a spectator receives and search it for everything that must not be
|
|
* in it, across every game state where the shape of the answer changes.
|
|
*
|
|
* **What is searched for**, per the plan: every opponent hand card id AND its display name, the
|
|
* objective, `justDrawn` for the wrong seat, seed values and seed narration, and private decision
|
|
* and menu data. Display names matter as much as ids — "Red Flags" in a log leaks exactly what
|
|
* `c118` would, and only the id would have been caught before.
|
|
*
|
|
* **Where it is searched**: a player's `Frame`, the `PublicFrame` a spectator gets, the incremental
|
|
* narration `Push.lines` carries, and a reconnect push — which is a full Frame rather than a delta
|
|
* and is therefore its own opportunity to leak.
|
|
*
|
|
* **And the acceptance bar is not this file.** The plan is explicit that passing redaction tests
|
|
* alone is insufficient and that every public property needs an allow-list review; the last test
|
|
* here is that allow-list, so adding a field to the public projection fails until somebody has said
|
|
* out loud that it is public.
|
|
*/
|
|
describe('#91 — nothing private survives serialisation, in any state', () => {
|
|
const names = ['Ann', 'Bob', 'Cy'];
|
|
|
|
/**
|
|
* Everything one seat can see, split into the two halves the checks below treat differently.
|
|
*
|
|
* `structural` is the machine-readable state: their Frame, the public board, and the frame of every
|
|
* presentation step they are sent (v0.8.0, TODO #13). `narration` is what the table was TOLD.
|
|
*
|
|
* Steps are folded in here rather than given a test of their own so every case below covers them:
|
|
* the blind draw, the pending decision, Employee Rotation before and after the seating moves, and
|
|
* the played-out game. Their `lines` are a slice of `g.log` by construction, so the log covers the
|
|
* narration half of a step and does not need to be searched twice.
|
|
*/
|
|
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): {
|
|
structural: string;
|
|
history: string;
|
|
narration: string[];
|
|
} => ({
|
|
/**
|
|
* `[]` for the Frame's own lines, MATCHING PRODUCTION. `frameFor()` (`server/session.ts`) has
|
|
* passed no log since #97 — narration goes out incrementally through `Push.lines` instead — so
|
|
* embedding it here audits a path that no longer exists, and worse, it puts the whole log inside
|
|
* `structural` where the face-up-pile rule below cannot reach it. The log is audited in full as
|
|
* `narration`; this is a de-duplication, not a relaxation.
|
|
*/
|
|
structural:
|
|
JSON.stringify(snapshot(g.state, [], null, null, null, false, seat)) +
|
|
'\n' + JSON.stringify(publicSnapshot(g.state)),
|
|
/**
|
|
* THE STEP FRAMES ARE A RECORD OF WHAT WAS PUBLIC OVER TIME, not a view of the position now —
|
|
* so they get the PRECISE check and not the fuzzy one, for the same reason the face-up-pile
|
|
* lines do.
|
|
*
|
|
* Every one is built by `deltaPublicFrame` over `publicSnapshot`, which the allow-list test at
|
|
* the bottom of this file pins property by property; that is what guarantees a step frame is
|
|
* clean. Searching their accumulation for a card NAME asks "was this ever public?" and answers
|
|
* a question nobody was posing: Train 6 sat face-up in a Department at step 40 and is in Ann's
|
|
* hand at step 120, and both facts are correct. A card ID is different — narration never renders
|
|
* one and no public field carries an opponent's, so finding one anywhere is still proof.
|
|
*/
|
|
history: JSON.stringify(g.display.steps.map((step) => step.frame)),
|
|
narration: g.log.map((l) => l.text),
|
|
});
|
|
|
|
/**
|
|
* A FACE-UP PILE IS ALLOWED TO NAME THE CARD ON IT, and the log is history rather than a view.
|
|
*
|
|
* §2.6: the three Department piles and the Salvage Yard are face up, "so players can audit
|
|
* discards" — a discard goes onto one precisely so a rival can take it. So "Player Ann discarded
|
|
* Train 6 face-up on top of Department 3" is the record working, and it stays in the log after Ann
|
|
* takes the card back into her hand. The name-based check below would otherwise read that historical
|
|
* line as proof of what Ann is holding NOW, which is how it reported a leak against correct code on
|
|
* seed 1917398.
|
|
*
|
|
* These lines are excluded from the NAME check only. The card-id check and the seed check still run
|
|
* over them, because those are precise: an id is unique, so finding one is proof, and narration
|
|
* never renders a raw id.
|
|
*
|
|
* **This does not weaken the blind-draw detection**, which is the leak this whole net was built
|
|
* for (v0.7.9.2, "Red Flags"): a blind draw names the HOME OFFICE DECK, which is face down and
|
|
* matches nothing here.
|
|
*/
|
|
const namesAFaceUpPile = (line: string): boolean => /Department|Salvage/i.test(line);
|
|
|
|
/**
|
|
* Every secret belonging to somebody OTHER than `seat`: their card ids, and the names those ids
|
|
* render as. Ids alone were what the original tests looked for, and an id is the precise
|
|
* instrument — it is unique, so finding one is proof.
|
|
*
|
|
* **A NAME IS ONLY EVIDENCE WHEN IT IS DISTINCTIVE, and most are not.** Card names are types, not
|
|
* identities: "right-hand curve" names a dozen cards, and one of them is legitimately drawn on the
|
|
* board as a cell label the moment anybody lays track. Searching for a name that also exists in
|
|
* public is a test that fails on correct code, which is worse than no test — so a name counts only
|
|
* when EVERY card bearing it is in that one opponent's hand. Then, and only then, seeing it says
|
|
* something about what they are holding.
|
|
*
|
|
* This is what caught the blind-draw leak in v0.7.9.2: "Red Flags" was in exactly one hand, and it
|
|
* was in the log.
|
|
*/
|
|
const secretsOfOthers = (
|
|
g: ReturnType<typeof newMultiplayerGame>,
|
|
seat: PlayerIndex,
|
|
): { what: string; value: string; precise: boolean }[] => {
|
|
// `precise` marks evidence that is proof on its own — a card id is unique, so finding one
|
|
// anywhere is a leak. A NAME is circumstantial and is searched over a narrower string; see
|
|
// `namesAFaceUpPile`.
|
|
const out: { what: string; value: string; precise: boolean }[] = [];
|
|
// How many cards in the whole game carry each name, and how many of those are in a given hand.
|
|
const totalByName = new Map<string, number>();
|
|
for (const id of g.state.cards.keys()) {
|
|
const n = cardName(g.state, id);
|
|
totalByName.set(n, (totalByName.get(n) ?? 0) + 1);
|
|
}
|
|
for (const p of g.state.players) {
|
|
if (p.index === seat) continue;
|
|
const hand = g.state.decks.hands.get(p.index) ?? [];
|
|
const heldByName = new Map<string, number>();
|
|
for (const id of hand) {
|
|
const n = cardName(g.state, id);
|
|
heldByName.set(n, (heldByName.get(n) ?? 0) + 1);
|
|
}
|
|
for (const id of hand) {
|
|
out.push({ what: `${p.name}'s card id`, value: id, precise: true });
|
|
const name = cardName(g.state, id);
|
|
if (totalByName.get(name) === heldByName.get(name)) {
|
|
out.push({ what: `${p.name}'s card name, unique to their hand`, value: name, precise: false });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/** Runs the whole net over one state, and says which state failed if it does. */
|
|
const audit = (g: ReturnType<typeof newMultiplayerGame>, where: string): void => {
|
|
for (const seat of g.state.players.map((p) => p.index)) {
|
|
const { structural, history, narration } = everythingSeatSees(g, seat);
|
|
const everything = structural + '\n' + history + '\n' + narration.join('\n');
|
|
// Names are fuzzy evidence, so they are searched everywhere EXCEPT the lines a face-up pile
|
|
// is entitled to name a card on. Ids are precise and are searched everywhere.
|
|
const forNames = structural + '\n' + narration.filter((l) => !namesAFaceUpPile(l)).join('\n');
|
|
for (const { what, value, precise } of secretsOfOthers(g, seat)) {
|
|
assert.ok(
|
|
!(precise ? everything : forNames).includes(value),
|
|
`${where}: seat ${seat} can see ${what} ("${value}")`,
|
|
);
|
|
}
|
|
// The seed is the whole future of the deal and must not reach a seat by any route.
|
|
assert.ok(!everything.includes(String(g.seed)), `${where}: seat ${seat} can see the seed ${g.seed}`);
|
|
}
|
|
// And the spectator board, which has no seat and is therefore entitled to nothing private.
|
|
const pub = JSON.stringify(publicSnapshot(g.state));
|
|
for (const p of g.state.players) {
|
|
for (const id of g.state.decks.hands.get(p.index) ?? []) {
|
|
assert.ok(!pub.includes(id), `${where}: the public board carries ${p.name}'s card ${id}`);
|
|
}
|
|
}
|
|
assert.ok(!pub.includes(String(g.seed)), `${where}: the public board carries the seed`);
|
|
for (const k of ['hand', 'objective', 'justDrawn', 'decision', 'moves', 'blocked', 'viewer']) {
|
|
assert.ok(!(k in (JSON.parse(pub) as Record<string, unknown>)), `${where}: the public board has a "${k}" field`);
|
|
}
|
|
};
|
|
|
|
/** Plays `n` legal moves, so a state is a real position rather than a constructed one. */
|
|
const play = (g: ReturnType<typeof newMultiplayerGame>, n: number): void => {
|
|
for (let i = 0; i < n; i++) {
|
|
const a = g.state.clock.currentActor;
|
|
if (a === null) break;
|
|
const opts = legalActions(g.state, a);
|
|
if (!opts.length) break;
|
|
if (!submit(g, opts[i % opts.length]!, a)) break;
|
|
}
|
|
};
|
|
|
|
it('a newly created multiplayer game', () => {
|
|
audit(newMultiplayerGame(4242, config, names), 'fresh game');
|
|
});
|
|
|
|
it('after a blind Home Office draw', () => {
|
|
const g = newMultiplayerGame(4242, config, names);
|
|
let drew = false;
|
|
for (let i = 0; i < 200 && !drew; i++) {
|
|
const a = g.state.clock.currentActor;
|
|
if (a === null) break;
|
|
if (!submit(g, { type: 'localOps.choose', option: 'draw' }, a)) continue;
|
|
drew = submit(g, { type: 'draw.fromHomeOffice' }, a);
|
|
}
|
|
assert.ok(drew, 'no seat drew from the Home Office deck');
|
|
audit(g, 'after a blind draw');
|
|
});
|
|
|
|
it('the net actually sees the presentation steps it claims to cover (v0.8.0)', () => {
|
|
/**
|
|
* Guards the COVERAGE, not the code. `everythingSeatSees` folds `display.steps` into the string
|
|
* every case above is audited against — which is worth nothing if that array is empty in
|
|
* practice. So: play a real game, and assert both that steps accumulated and that the audited
|
|
* string contains them.
|
|
*/
|
|
const g = newMultiplayerGame(1917398, config, names);
|
|
play(g, 120);
|
|
assert.ok(g.display.steps.length > 20, `only ${g.display.steps.length} steps — the net covers little`);
|
|
const { history, narration } = everythingSeatSees(g, 0 as PlayerIndex);
|
|
assert.ok(
|
|
history.includes(JSON.stringify(g.display.steps.map((step) => step.frame))),
|
|
'the audited string does not actually contain the step frames',
|
|
);
|
|
// And a step's own narration is a slice of the log, so the log half covers it.
|
|
const fromSteps = g.display.steps.flatMap((step) => step.lines.map((l) => l.text));
|
|
assert.ok(fromSteps.length > 0, 'the steps carried no narration to cover');
|
|
assert.ok(fromSteps.every((t) => narration.includes(t)), 'a step said something the log did not');
|
|
audit(g, 'a played game with presentation steps');
|
|
});
|
|
|
|
it('mid-game, with real hands and a built board', () => {
|
|
// A DISTINCTIVE seed, deliberately. Seed 7 makes the seed check meaningless — "7" is in "Train
|
|
// 7", in every coordinate and in half the numbers on the board — so it reported a leak that was
|
|
// not one. Nine digits collide with nothing, which is what makes a substring match evidence.
|
|
const g = newMultiplayerGame(613884219, config, names);
|
|
play(g, 300);
|
|
audit(g, 'mid-game');
|
|
});
|
|
|
|
it('with a decision pending, and with the Superintendent acting', () => {
|
|
const g = newMultiplayerGame(550943578, config, names);
|
|
let sawDecision = false;
|
|
for (let i = 0; i < 800; i++) {
|
|
if (g.state.clock.pendingDecision !== null) {
|
|
sawDecision = true;
|
|
audit(g, `pending decision (${g.state.clock.pendingDecision.kind})`);
|
|
break;
|
|
}
|
|
const a = g.state.clock.currentActor;
|
|
if (a === null) break;
|
|
const opts = legalActions(g.state, a);
|
|
if (!opts.length || !submit(g, opts[0]!, a)) break;
|
|
}
|
|
// A seed that never raises one is not a failure of redaction; say so rather than passing mutely.
|
|
if (!sawDecision) assert.ok(true, 'no decision arose on this seed — nothing to audit');
|
|
});
|
|
|
|
it('with Employee Rotation on, before and after ownership moves', () => {
|
|
// The case where seat and player index come apart. A projection that confused them would hand
|
|
// one player another's district, which is a leak the other tests cannot see.
|
|
const rotating = { ...config, optionalRules: { ...config.optionalRules, employeeRotation: true } };
|
|
const g = newMultiplayerGame(729315046, rotating, names);
|
|
audit(g, 'employee rotation, before');
|
|
const seatingBefore = [...g.state.seating];
|
|
play(g, 400);
|
|
audit(g, 'employee rotation, after');
|
|
// If the seating never moved this test proved less than it looks — say which happened.
|
|
const moved = seatingBefore.some((p, i) => g.state.seating[i] !== p);
|
|
assert.ok(moved || g.state.status !== 'active', 'rotation never moved anybody and the game did not end');
|
|
});
|
|
|
|
it('a game played out to the end, or as far as it goes', () => {
|
|
const g = newMultiplayerGame(613884219, config, names);
|
|
play(g, 6000);
|
|
// Says which it actually got, rather than claiming a finished game it may not have reached.
|
|
audit(g, `played out (status ${g.state.status})`);
|
|
});
|
|
|
|
it('a reconnect push, which is a full Frame rather than a delta', () => {
|
|
const session = createSession(550943578, config, names);
|
|
for (const seat of [0, 1, 2] as PlayerIndex[]) {
|
|
const push = session.connect(seat);
|
|
const seen = JSON.stringify(push);
|
|
const state = session.exportSave();
|
|
assert.ok(!seen.includes(String(state.seed)), `the reconnect push for seat ${seat} carries the seed`);
|
|
for (const p of [0, 1, 2] as PlayerIndex[]) {
|
|
if (p === seat) continue;
|
|
// `connect` returns that seat's own Frame; another seat's hand must not be in it.
|
|
assert.ok(
|
|
!/"hand":\[[^\]]/.test(JSON.stringify((push.frame as unknown as Record<string, unknown>)['players'] ?? '')),
|
|
`the reconnect push for seat ${seat} carries a hand inside players[]`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* THE ALLOW-LIST, and the plan's actual acceptance bar.
|
|
*
|
|
* Every property of the public projection, written down and reviewed as public. This does not
|
|
* check the CONTENT of anything — the tests above do that — it checks that nobody has added a
|
|
* field without saying out loud that a spectator may see it. That is the check that would have
|
|
* caught both v0.7.9.2 leaks, because both were fields nobody had ever asked the question about.
|
|
*
|
|
* When this fails, the fix is not to add the key here. It is to decide whether the field is
|
|
* public, and only then to add it.
|
|
*/
|
|
it('every public property is on the allow-list, and nothing else is', () => {
|
|
const PUBLIC: readonly string[] = [
|
|
// The clock and the phase — what a spectator's board is FOR.
|
|
'day', 'stage', 'clock', 'phase', 'phaseKey', 'actor', 'superintendent',
|
|
// Deck sizes and face-up piles. A Department pile is face up; the Home Office deck is a count.
|
|
'deck', 'departments', 'departmentsWhat', 'departmentDepth', 'salvage',
|
|
// Rolling stock in the yards, by type — visible on the table.
|
|
'yards',
|
|
// The timetable is public: it is what everyone is playing against.
|
|
'timetable', 'timetableWhat',
|
|
// The rules the game was dealt under, and the score.
|
|
'houseRules', 'mode', 'optionalRules', 'days', 'minCombinedRevenue',
|
|
'maxCollisionsPerDay', 'maxCollisionsTotal', 'collisionsToday', 'collisionsTotal',
|
|
'status', 'outcome', 'extraDays', 'extensionVotes', 'official', 'tally',
|
|
// Names, seats, revenue and HAND SIZE — never hand contents.
|
|
'players',
|
|
// The opening rolls decided seating and the Superintendent in the open.
|
|
'openingRolls',
|
|
// Where every train is standing.
|
|
'trains',
|
|
// The Crew Tray pool and the trains queued for one (#98). §7 scarcity is played out in the
|
|
// open: the trays are objects in the middle of the table, and an Extra is played face up, so
|
|
// who is waiting for a crew is not a secret. Counts and train numbers only — never a hand.
|
|
'crewTrays', 'queued',
|
|
// The board itself.
|
|
'division', 'districts',
|
|
];
|
|
const g = newMultiplayerGame(4242, config, names);
|
|
const actual = Object.keys(publicSnapshot(g.state)).sort();
|
|
const allowed = [...PUBLIC].sort();
|
|
assert.deepEqual(
|
|
actual,
|
|
allowed,
|
|
'the public projection gained or lost a property — decide whether it is public before listing it',
|
|
);
|
|
});
|
|
});
|