v0.7.9.4 — Gitea#20 step 1, and a Red Flag you can see

Step 1 of the common board done as its own release rather than as the
first hour of 0.8.0, since both halves of it are worth having whether or
not anything is ever published to a call.

#95 — the public projection helpers. `projectDistrict(state, seat)`,
`projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)`, with
`snapshot()` REBUILT to compose from the same helpers rather than keeping
a second copy of the shared table, so a player's frame and a spectator's
cannot come to disagree about the clock, the phase, whose turn it is or
the score. Behaviour-neutral; the 897 existing tests passing unchanged is
the proof.

The public view is composed UPWARD, never by calling `snapshot()` once
per seat. That shortcut is the trap the plan names: `snapshot` assembles
one player's view, so a public view made of player views builds every
private field and then has to remember to strip it — and it defaults its
viewer to player zero, so a careless spectator call would have served
seat 0's hand. Districts are keyed by SEAT with the player resolved
through `playerAtSeat`, because Employee Rotation moves players between
districts and a board that treated seat and player index as
interchangeable would relabel every district the first time anybody
rotated.

One plan finding is struck off rather than fixed: it warns a display
reading `clock.currentActor` could highlight the wrong district during a
decision. Measured over six seeds and 3,600 decision points, that field
and `actingPlayer` never disagreed. `currentActorOfState` exists anyway,
as one place for the next reader to ask.

#91 — the redaction net, systematically. v0.7.9.2's two leaks were found
by reading a plan, not by a test, which is the whole argument for this: a
suite made of the leaks somebody happened to notice proves nothing about
the next one. Serialise a seat's Frame, the PublicFrame a spectator gets
and the narration they receive, then search all three for every opponent
card id, every card name unique to one opponent's hand, the seed and any
private decision or menu data — across a fresh game, a blind draw,
mid-game, a pending decision, Employee Rotation before and after the
seating moves, a reconnect push (a full Frame, and its own opportunity to
leak) and a played-out game. And the allow-list, which is the plan's
stated acceptance bar rather than the tests: every property of
`publicSnapshot` is written down with its reason and compared on every
run, so adding a field fails the suite until somebody has said out loud
that a spectator may see it. Both v0.7.9.2 leaks were fields nobody had
ever asked that question about.

Proved by mutation rather than by passing: restoring the seed line fails
6 tests, restoring the blind-draw card name fails 1, adding a private
field to the public projection fails 7, and making `players[]` carry hand
contents instead of a count fails 5.

Two false failures were worth the lesson. A card NAME is a type, not an
identity — "right-hand curve" names a dozen cards and one is legitimately
a cell label the moment anybody lays track, so searching for it fails on
correct code, which is worse than not searching; a name is evidence only
when every card bearing it is in the one hand. And a one-digit seed makes
the seed check meaningless: seed 7 matched "Train 7". One item on the
plan's list has no test because it has no referent — there is no secret
objective in this game, `objectiveOf` deriving from
`config.minCombinedRevenue` and the player's own Revenue, both public.

#94 — a Red Flag standing at an Office's Limits is on the map. It is a
token set out ON the board that holds the next train arriving from that
side, and it was announced once in the log and drawn nowhere, so a train
stops short three Stages later with its only explanation scrolled out of
the panel. `DivisionView`'s office node carries `redFlag` and the map
draws a staff and pennant AT THE END IT GUARDS — west on the left, east
on the right — because which approach it covers is the whole of the
information; a flag in the middle of the cell would say one is out and
leave the reader to hover for the half that decides whether to run a
train. The tooltip leads with it, ahead of everything that merely
describes the cell.

The third of these in a row after Gitea#21 and #22: when the engine gains
something that changes what a train may do, the question to ask is where
it is drawn, not whether it works.

909 tests pass, up from 897.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ss2y7FyhxkHjGj7xnUPCgY
This commit is contained in:
Jesse.Markowitz
2026-09-07 15:00:57 -04:00
co-authored by Claude Opus 5
parent e734481d65
commit ebd16983e2
7 changed files with 720 additions and 160 deletions
+243 -1
View File
@@ -17,8 +17,10 @@ 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 { 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',
@@ -214,3 +216,243 @@ describe('redaction — the shared narration log never carries a seat\'s secrets
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 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',
);
});
});
+66
View File
@@ -3254,6 +3254,72 @@ describe('the Division map shows the whole route', () => {
}
});
/**
* #94 — A RED FLAG IS A THING STANDING ON THE BOARD, AND IT WAS DRAWN NOWHERE.
*
* `maneuver.redFlags` sets `node.redFlag` on an Office's Division node, and from then on
* `redFlagStop` holds the next train arriving from that side until the flag is spent. It is a
* standing token that stops trains — the same kind of object as a train or an A/D track, not a
* transient event.
*
* It was announced once in the log and then existed only in the engine. The office node in
* `DivisionView` carried no field for it and `board-svg.ts` never mentioned one, so a player who
* set a flag out three Stages ago had nothing on screen saying it was still there, and an opponent
* who missed the line never knew at all. Then a train stops short and the only explanation has
* scrolled away.
*
* The same failure as Gitea#21 and #22: the engine is right and the screen is silent about what it
* is acting on. Asserted on both halves, because either alone would have looked fixed — the
* projection has to carry it AND the map has to draw it.
*/
describe('#94 — a Red Flag standing at the Limits is on the board and on the map', () => {
const withFlag = (side: 'east' | 'west' | null): ReturnType<typeof snapshot> => {
const s = createEngineGame({
id: 'redflag',
seed: 11,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const office = s.division.nodes.find((n) => n.kind === 'office');
assert.ok(office && office.kind === 'office', 'no Office node in the Division');
// Exactly what `redFlagsSet`'s reducer does (`apply.ts`).
if (side) (office as { redFlag?: string }).redFlag = side;
return snapshot(s, [], null);
};
it('carries the flag on the office node, and its side', () => {
const node = withFlag('east').division.find((n) => n.kind === 'office');
assert.ok(node, 'no office node in the Division view');
assert.equal(
(node as { redFlag?: string | null }).redFlag,
'east',
'the Division view does not carry the Red Flag standing at this Office',
);
});
it('carries nothing when no flag is out — it must not draw one by default', () => {
const node = withFlag(null).division.find((n) => n.kind === 'office');
const flag = (node as { redFlag?: string | null }).redFlag;
assert.ok(flag === null || flag === undefined, `an Office with no flag reported "${flag}"`);
});
it('draws it on the map, and says which side it guards', () => {
const svg = divisionSvg(withFlag('west').division);
assert.match(svg, /bs-flag/, 'the Red Flag is not drawn on the Division map');
// The side is the whole of the information: a flag guards ONE approach, and a player deciding
// whether to run a train needs to know which.
assert.match(svg, /RED FLAG[^"]*[Ww]est/, 'the map does not say which side the flag guards');
});
it('draws none when none is out', () => {
assert.ok(!/bs-flag/.test(divisionSvg(withFlag(null).division)), 'a flag was drawn with none set');
});
});
/**
* GITEA#22 — and the same invariant, applied to the trains standing on a card.
*