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
+228 -69
View File
@@ -320,6 +320,16 @@ export type DivisionView = {
what?: string;
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
regions?: number;
/**
* Office nodes only: a Red Flag standing at this Office's Limits, and which approach it guards
* (#94). `null` when none is out.
*
* PUBLIC STATE, and the reason it has to be here: the flag is a token set out ON the board that
* holds the next train arriving from that side until it is spent. It was announced once in the
* log and then drawn nowhere, so a train would stop short with its only explanation scrolled out
* of the panel.
*/
redFlag?: string | null;
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[];
/** Office nodes only: whose district this is. */
@@ -1171,28 +1181,30 @@ export function describeIntent(s: GameState, i: Intent): string {
* replay cannot drift into two different pictures of the same board.
*/
/**
* The board as ONE SEAT sees it.
* ONE DISTRICT'S BOARD, BY SEAT — the cards on the table and the cars standing on them (Gitea#20
* step 1).
*
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
* is what solitaire and every replay want, so existing callers are unaffected.
* A district's BOARD is public. Everyone at the table can see the cards somebody has laid, the cars
* standing on them and the trains in the Office Area; what is private is a player's HAND, their
* objective and their Revenue detail, none of which is here. That split is why this can be handed to
* a seatless spectator unchanged.
*
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
* player 0's hand, which is the one thing the state model calls secret.
* **KEYED BY SEAT, NOT BY PLAYER, and that is not a detail.** Employee Rotation moves players
* between districts, so district ownership cannot be assumed to match player index — the board
* belongs to the POSITION on the Division and the player is whoever is currently sitting there
* (`playerAtSeat`). Taking a player here would silently draw the wrong district the first time
* anybody rotated.
*
* `seat` is also the "home seat" for `carLabel`, which marks a load THIS district made — the printed
* game turns the chip upside down in the tray, and a load may not be broken in the Office Area that
* made it. For a player's own view that seat is theirs; for a spectator's view of district N it is
* N, which is the same fact asked from outside.
*/
export function snapshot(
export function projectDistrict(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
viewer: PlayerIndex = 0,
): Frame {
const area = areaOf(s, viewer);
const viewerSeat = seatOf(s, viewer);
seat: SeatIndex,
): { cells: CellView[]; facilities: FacilityView[]; runningRow: number; limits: { west: number; east: number } } {
const area = areaAtSeat(s, seat);
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
for (const [key, card] of area.grid) {
@@ -1210,7 +1222,7 @@ export function snapshot(
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
const fv = facilityView(card as never, officeProfile(area.tier).name, seat);
if (fv) facilities.push(fv);
cells.push({
@@ -1223,15 +1235,32 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
trains: trainsOnCard(s, viewerSeat, key),
trains: trainsOnCard(s, seat, key),
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
cars: carsOn(card).map((c) => carLabel(c, seat)),
standingWest: card.standingWest,
facility: fv,
});
}
const division: DivisionView[] = s.division.nodes.map((n) => {
return {
cells,
facilities,
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
};
}
/**
* THE DIVISION — every district's cell, the Mainline between them, and both Division Points.
*
* Wholly public and always was: it reads no hand, no objective and no per-viewer state, so a
* spectator's Division map and a player's are the same picture. It is extracted rather than
* rewritten for exactly that reason — the public view must not be a second implementation that can
* drift from the one players look at.
*/
export function projectDivision(s: GameState): DivisionView[] {
return s.division.nodes.map((n) => {
if (n.kind === 'divisionPoint') {
return {
kind: 'dp',
@@ -1366,51 +1395,53 @@ export function snapshot(
modifiers: [],
gradeUp: null,
seat: n.seat,
// Straight off the node the engine sets (#94). Public to every seat — a flag on the table is
// seen by everyone at it — so this is not redacted by viewer and must not become so.
redFlag: n.redFlag ?? null,
running,
switching: below,
};
});
}
/**
* WHO THE GAME IS WAITING ON — the one answer, asked one way (Gitea#20 step 1).
*
* `clock.currentActor` is not it. The engine sets that null while an interruption is standing —
* a §8.1 clearance goes to the Superintendent, a Yard Office offer to the district's owner — and a
* view reading the raw field therefore reports "nobody" during exactly the moments a player is being
* waited on. `actingPlayer` knows the rule and is the engine's own answer.
*
* Exported so the player views, the bot driver and the common board all ask the same question of the
* same function rather than three near-copies of it. Measured before adding it: across six seeds and
* 3,600 decision points the raw field and this never disagreed, because both are only consulted when
* somebody is genuinely acting — so this is a guard against the next reader, not a live fix.
*/
export function currentActorOfState(s: GameState): PlayerIndex | null {
return actingPlayer(s);
}
/**
* THE TABLE, AS EVERY SEAT SEES IT IDENTICALLY (Gitea#20 step 1).
*
* The clock, the phase, whose turn it is, the timetable, the yards, the deck COUNTS, the score and
* the house rules. Nothing here is redacted, and nothing here may become redacted: the whole point
* is that a spectator and a player read the same table state, so a field that has to differ by seat
* belongs in the player's own frame instead.
*
* Deck contents are counts and top-of-pile names only. A Department pile is FACE UP — a discard goes
* onto one precisely so a rival can take it — so naming its top card gives nothing away; the Home
* Office deck is face down and appears here as a length and nothing else.
*/
export function projectSharedTable(s: GameState) {
return {
day: s.clock.day,
stage: s.clock.stage,
clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase),
phaseKey: s.clock.phase,
actor: actingPlayer(s),
/**
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
*/
awaiting: (() => {
const d = s.clock.pendingDecision;
if (!d) return null;
const train = trainName(s, d.train);
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
return { asks: 'a Red Flag', train };
})(),
actor: currentActorOfState(s),
superintendent: s.clock.superintendent,
revenue: s.players[viewer]?.revenue ?? 0,
lines,
where,
whereFrom,
division,
cells,
facilities,
/**
* NEWEST FIRST, matching the play page (`actionMenu`).
*
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
* iteration — and every revenue measurement taken with it — is left alone.
*
* Both lines must reverse together or the descriptions come apart from the names.
*/
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
@@ -1435,9 +1466,6 @@ export function snapshot(
},
timetable: [...s.timetable],
timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))),
decision,
wasted,
option: turnOf(s, viewer).option,
houseRules: houseRules(s.config),
mode: s.config.mode,
optionalRules: s.config.optionalRules,
@@ -1458,23 +1486,14 @@ export function snapshot(
seat: seatOf(s, p.index),
name: p.name,
revenue: p.revenue,
// A COUNT, never the cards. Hand SIZE is public — you can see how many cards somebody holds
// across a table — and this is the only thing about another player's hand that may be here.
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
viewer,
viewerSeat,
openingRolls: {
division: [...s.openingRolls.division],
superintendent: [...s.openingRolls.superintendent],
},
handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
moves: switchingMoves(s, viewer),
blocked: impediments(s, viewer),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
@@ -1487,6 +1506,146 @@ export function snapshot(
};
}
/** One district as a spectator sees it: whose seat it is, who is sitting there, and its board. */
export type PublicDistrict = {
seat: SeatIndex;
player: PlayerIndex;
name: string;
cells: CellView[];
facilities: FacilityView[];
runningRow: number;
limits: { west: number; east: number };
};
/** What a seatless viewer may be shown: the table, the Division, and every district's board. */
export type PublicFrame = ReturnType<typeof projectSharedTable> & {
division: DivisionView[];
districts: PublicDistrict[];
};
/**
* THE WHOLE GAME AS A SPECTATOR MAY SEE IT — no seat, no hand, no secrets (Gitea#20 step 1).
*
* **Built from the same lower-level projections a player's frame is, and deliberately NOT by calling
* `snapshot()` once per seat.** That shortcut is the trap: `snapshot` exists to assemble one
* player's view and carries their hand, their objective, their Revenue detail and their legal moves,
* so a public view made of player views starts by constructing everything it then has to remember to
* strip. It also defaults its viewer to player zero, which means a careless spectator call today
* serves seat 0's hand. Composing upward instead means a private field cannot arrive here by
* accident: it would have to be added to a projection that has no business holding one.
*
* **Districts are keyed by SEAT and the player is resolved through `playerAtSeat`.** Employee
* Rotation moves players between districts, so seat and player index are not interchangeable, and
* a public board that assumed they were would relabel every district the first time anybody rotated.
*
* What is NOT here, and why each: `hand`/`handWhat`/`handDiscardable`/`handKeepWhy` and `handCount`
* (the cards a seat holds), `objective` (a private goal), `option`/`movesLeft`/`moves` (one player's
* legal actions, which describe what they are ABOUT to do), `blocked` (computed per viewer and
* partly about their own crews), `decision`, `viewer`/`viewerSeat`, and the narration log — which
* `session.ts` sends incrementally and which is checked separately, because two of the leaks found
* in v0.7.9.2 lived there rather than in any frame.
*/
export function publicSnapshot(s: GameState): PublicFrame {
return {
...projectSharedTable(s),
division: projectDivision(s),
districts: [...s.officeAreas.keys()].sort((a, b) => a - b).map((seat) => {
const player = playerAtSeat(s, seat);
return {
seat,
player,
// Through `seatLabel`, like every other seat a person reads: the internal index is
// zero-based and the spoken number is not (`session.test.ts` guards the conversion).
name: s.players[player]?.name ?? `Seat ${seatLabel(seat)}`,
...projectDistrict(s, seat),
};
}),
};
}
/**
* The board as ONE SEAT sees it.
*
* `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything
* else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which
* is what solitaire and every replay want, so existing callers are unaffected.
*
* This was hardcoded to 0 throughout. That was correct while there was one player and would have
* been a quiet disaster with more: every seat would have been shown player 0's railroad, including
* player 0's hand, which is the one thing the state model calls secret.
*/
export function snapshot(
s: GameState,
lines: { text: string; tone: string }[],
where: { row: number; col: number } | null,
whereFrom: { row: number; col: number } | null = null,
decision: Decision | null = null,
wasted = false,
viewer: PlayerIndex = 0,
): Frame {
const viewerSeat = seatOf(s, viewer);
const { cells, facilities, runningRow, limits } = projectDistrict(s, viewerSeat);
const division = projectDivision(s);
return {
/**
* THE SHARED TABLE COMES FROM THE SAME PROJECTION THE COMMON BOARD USES (Gitea#20 step 1).
*
* Spread rather than restated, 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. Everything after this point is
* either private to `viewer` or a viewer-specific slice; none of it shadows a shared field, and
* one that did would be exactly the bug this arrangement exists to make visible.
*/
...projectSharedTable(s),
/**
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
*/
awaiting: (() => {
const d = s.clock.pendingDecision;
if (!d) return null;
const train = trainName(s, d.train);
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
return { asks: 'a Red Flag', train };
})(),
revenue: s.players[viewer]?.revenue ?? 0,
lines,
where,
whereFrom,
division,
cells,
facilities,
/**
* NEWEST FIRST, matching the play page (`actionMenu`).
*
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
* iteration — and every revenue measurement taken with it — is left alone.
*
* Both lines must reverse together or the descriptions come apart from the names.
*/
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
decision,
wasted,
option: turnOf(s, viewer).option,
viewer,
viewerSeat,
handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
objective: objectiveOf(s, viewer),
runningRow,
limits,
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
moves: switchingMoves(s, viewer),
blocked: impediments(s, viewer),
};
}
/** A card id turned into something a person can read. */
export function cardName(s: GameState, id: string): string {
const k = s.cards.get(id)?.kind;