contradiction Two things found by playing v0.8.0.1, neither in the mechanism itself. ?pace= never worked. index.html's doors are play.html?lobby and play.html?solitaire, so arriving through the splash replaces the query string and the play page only ever saw ?lobby — a whole game was played at 1x while believing it was at 7x. v0.8.0 shipped that parameter as the only way to change speed and the game's own front door destroyed it. There is a control on the play screen now, beside zoom, persisted per viewer; the doors carry pace through as well, so the URL lever is honest for handing two playtesters different speeds. PACE_LEVELS moved to sim/pacing.ts with DWELL and MAX_PACE — the tuning surface in one file, and testable. The committed default is unchanged: what it should be is a question for a game played at a speed that took effect. And the Day-end dialog said "0 today, 2 in all". advance.ts increments the Day and then zeroes collisionsToday, and noteDayEnd() fires when the Day goes up — so the dialog reporting the Day that just finished was drawn from the very frame in which that Day's count was reset. Reproduced on four of five seeds before changing anything. The count is captured at the rollover now; it is not derivable on the client, because in multiplayer the push announcing the new Day is the same push that carries the reset. And "today" was the wrong word regardless: it names the Day instead — "Collisions: 2 on Day 1, 2 in all". Unrelated to v0.8.0 — that one has been wrong since the dialog was built for Gitea#10, and needed somebody to play a Day with a collision in it and then read the summary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
554 lines
28 KiB
TypeScript
554 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',
|
|
// What the Day that just ended finished on. Public for the same reason the running counts are:
|
|
// a collision happens on the Mainline in front of everybody.
|
|
'collisionsPrevDay',
|
|
'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',
|
|
);
|
|
});
|
|
});
|