Found by looking rather than by being told. Gitea#21, #22, #94 and #96 were four instances of one fault in a row — the engine gains something that changes what a train may do, and nothing draws it — and every one was found by a player hitting it. So instead of waiting for the fifth, every field of GameState and its nested types was enumerated, checked for a reader in sim/view.ts, src/web/ and sim/narrate.ts, and the survivors verified BY RUNNING THE ENGINE rather than by trusting the grep. Four fields had no reader. `movedThisPhase` lives and dies inside one `advance` call and is nobody's business. The other three are below. What was ruled out matters as much: `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, `switchedSince` and `movesUsed` are invisible on purpose, their effect already showing as legality or as a complement already on the Frame. A field is not a display gap merely because nothing renders it. #98 — the Crew Tray pool. §7 scarcity is called an explicit mechanic and was explicit only in the engine. The blocked panel had one tray rule, keyed off the train due out this Stage, so a player who spent a card on an Extra or ordered a second section got an EMPTY panel while their train sat behind an exhausted pool — both having been announced once in the log in a line promising a future event that nothing then confirmed. The shared table carries the pool and the queue now, so the common board gets it too, and the panel reports all three with the count beside them. #99 — a train held at the Limits vanished off the board, and this one had shipped. The Interlocking stops an inbound train on the Limit Track rather than colliding with a full Office. `arriveAtOffice` removes the tray from the Mainline node's `transits` and the Interlocking branch pushes it onto `heldAtLimits` without assigning `tray.position` — and the map draws mainline nodes from `transits` and squares from `position.at === 'grid'`, so between the two it was drawn in NEITHER. It disappeared on arrival and reappeared in the Office some Stages later. Fixed in the view: the engine is right, and `position` is left alone deliberately so nothing treats the train as standing somewhere it could be switched from. #100 — the Campaign Train's speeches change its rules, and the card said the same thing before and after. Worse, the "EXPEDITED ... costs 1 Revenue" warning prints only under `rules.expedite`, so X17 became subject to a fault whose warning the game shows to every other expedited train and never to it. `trainRules` reads `speechMade` now and borrows `isExpedited` from advance.ts rather than restating the test. #45 — the 0.7.9 dead-field audit, finished, and the answer was different for each. `overHandLimit` is WIRED: its consumer existed all along and was inferring the hand limit from the ABSENCE of `draw.end` in the menu, which is sound only while `check` keeps refusing for exactly three reasons. `viewerSeat` is DOCUMENTED, with a condition — Gitea#20's board keys districts by seat, and the note says to delete it if step 2 ships without using it. The audit had missed a third limb. `game.mustPlayCard` was assigned on every submit and read by nothing: deleted. Chasing it turned up the thing worth fixing — the §6.2 hand-limit test existed in THREE places, all agreeing, which is the state #96's disagreement started from. One `overHandLimit(state, player)` in state.ts now, and the other two ask it. `Session.overHandLimit()` is deleted rather than kept: the Frame already carries the fact, so the method was a second path to it. 934 tests pass, up from 917. The 17 new ones were written red, and each fix checked by mutation: reverting `speechMade` fails 2, dropping the held-train projection fails 4, forgetting the tray queues fails 2. The empty blocked panel is reported beside its positive control, since an empty result from a broken function proves nothing. NOT VERIFIED AT A TABLE. Engine and view work, checked by tests and by running the engine. #39 and #35 still stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
463 lines
22 KiB
TypeScript
463 lines
22 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, as one string: their Frame, the public board, and their lines. */
|
|
const everythingSeatSees = (g: ReturnType<typeof newMultiplayerGame>, seat: PlayerIndex): string =>
|
|
JSON.stringify(snapshot(g.state, g.log, null, null, null, false, seat)) +
|
|
'\n' + JSON.stringify(publicSnapshot(g.state)) +
|
|
'\n' + g.log.map((l) => l.text).join('\n');
|
|
|
|
/**
|
|
* 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 }[] => {
|
|
const out: { what: string; value: string }[] = [];
|
|
// 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 });
|
|
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 });
|
|
}
|
|
}
|
|
}
|
|
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 seen = everythingSeatSees(g, seat);
|
|
for (const { what, value } of secretsOfOthers(g, seat)) {
|
|
assert.ok(
|
|
!seen.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(!seen.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('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',
|
|
);
|
|
});
|
|
});
|