diff --git a/CHANGELOG.md b/CHANGELOG.md index 859b428..abfac20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,88 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9.4 — 2026-09-07 + +Gitea#20 step 1, done as its own release rather than as the first hour of 0.8.0 — and a Red Flag you +can now see. + +### A Red Flag standing at the Limits is on the map (#94) + +`maneuver.redFlags` sets a flag on an Office's Division node, and from then on the next train +arriving from that side is held short until the flag is spent. It is a token standing on the board — +the same kind of object as a train — and it was announced once in the log and drawn nowhere. Three +Stages later a train stops and the only explanation has scrolled out of the panel. + +`DivisionView`'s office node carries `redFlag` now, and the map draws a staff and pennant **at the +end it guards** — west on the left, east on the right, since east is right on this map. Which +approach it covers is the whole of the information: a flag in the middle of the cell would say a +flag 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. + +### The public projection helpers (#95) + +`projectDistrict(state, seat)`, `projectDivision(state)`, `projectSharedTable(state)`, +`publicSnapshot(state)` and `currentActorOfState(state)` — and **`snapshot()` rebuilt to compose +from the same helpers** rather than keeping a second copy of the shared table. A player's frame and +a spectator's now cannot come to disagree about the clock, the phase, whose turn it is or the score. +Behaviour-neutral: the existing 897 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 warns about: `snapshot` exists to assemble one player's view, so a public view +made of player views starts by building every private field and then has to remember to strip it — +and it defaults its viewer to player zero, so a careless spectator call today would have served seat +0's hand. Composing upward means a private field cannot arrive by accident; it would have to be +added to a projection that has no business holding one. + +**Districts are keyed by seat, with the player resolved through `playerAtSeat`.** Employee Rotation +moves players between districts, so seat and player index are not interchangeable — a board that +assumed they were would relabel every district the first time anybody rotated. + +One finding from the plan is struck off rather than fixed: it warns that a display reading +`clock.currentActor` could highlight the wrong district during a decision, since that field is null +while an interruption stands. Measured across six seeds and 3,600 decision points, it and +`actingPlayer` never disagreed — both are only consulted when somebody is genuinely acting. +`currentActorOfState` exists anyway, as one place for the next reader to ask. + +### The redaction net, systematically (#91) + +v0.7.9.2 closed two leaks. Both were found by reading a plan rather than 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, not +a delta, 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 the reason it is public 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. + +**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 drawn as a cell label the moment anybody lays +track, so searching for it fails on correct code — which is worse than not searching. A name counts +as 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" and reported a leak that was not one. The seeds +here are nine digits deliberately. + +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. + +One item on the plan's list has no test because it has no referent: **there is no secret objective in +this game.** `objectiveOf` derives from `config.minCombinedRevenue` and the player's own Revenue, +both public. Recorded so the next reader does not go looking for the gap. + +909 tests pass, up from 897. + +--- + ## 0.7.9.3 — 2026-09-07 Documentation and the build script behind it. No engine change; 897 tests pass, unchanged. diff --git a/TODO.md b/TODO.md index ee93395..7b046fb 100644 --- a/TODO.md +++ b/TODO.md @@ -75,9 +75,9 @@ Not items. Things that are true of every change, and that have gone wrong when s ## Sections 1. **Play it at a table** — #39 #35 #42a #40 -2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #91 #75 +2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #75 3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79 -4. **The screen** — #94 #44 #81 #33 #36 +4. **The screen** — #44 #81 #33 #36 5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52 6. **Rules** — #12 #80 #82 #83 #85 7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74 @@ -149,17 +149,6 @@ cheaper. it needs the async stepped pump that Gitea#20 step 4 specifies**, which is why it lives here rather than under The screen. See **Reference · #18**. -- [ ] **#91** — **Narration is still outside the redaction net, and the two known leaks in it are - fixed but the net is not.** `game.log` is one shared list that `linesSince` slices with no - per-seat filter, so anything written into it reaches every player. The seed and the blind-draw - card name were closed in v0.7.9.2; what has NOT been done is the systematic check the plan - asks for — serialise the log alongside the Frame and search it for every opponent's card ids - AND display names, objective names, `justDrawn` for the wrong seat, and private decision data, - across a fresh game, a pending decision, the Superintendent acting, Employee Rotation, a - reconnect and a finished game. **This is Gitea#20 step 1's starting point and its acceptance - bar** — the plan is explicit that passing redaction tests alone is insufficient and that every - public property needs an allow-list review. See **Reference · #91**. - - [ ] **#75** — Let the game join a call and talk to the table — the chat, audio and nudge half of the idea Gitea#20 took the visual half of. Long-term. See **Reference · #75**. @@ -195,12 +184,6 @@ happening, and the turn structure that is still solitaire-shaped. What is drawn and where, for a player at the board. The three items settled on 2026-08-30 shipped in v0.7.9; what is left is the history panel and the end-of-game statistics. -- [ ] **#94** — **A Red Flag standing at an Office's Limits is drawn nowhere.** It is set out on the - board, it holds the next train from that side, and after the one log line announcing it there is - nothing on screen saying it is there. Confirmed in code rather than inferred. **The same class - as Gitea#21 and #22 — the engine is right and the screen is silent — and it is already on - Gitea#20 step 1's list, so doing it now is 0.8.0 groundwork.** See **Reference · #94**. - - [ ] **#44** — How much history the panel holds should be configurable. The cap is `slice(-60)` with no recorded reason anywhere. **Where the setting lives is an open question and the item exists to ask it** — a StartOS action, per game, or per browser. The replay viewer already answers the @@ -603,42 +586,6 @@ of enforced waiting per Stage, forty-eight per Day, and a player who has seen it will want it off. Whatever this becomes probably needs a speed control, or to scale with whether anything actually happened in the phase. -#### #91 — NARRATION IS OUTSIDE THE REDACTION NET. - -**NARRATION IS OUTSIDE THE REDACTION NET.** `test/redaction.test.ts` serialises a seat's whole - -`Frame` and asserts no other seat's card ids or deck order appear in it — and every one of those -tests passes `[]` for the log. So the shared narration has never been checked at all, while -`game.log` is ONE list and `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat -filter whatsoever. Every line written there reaches every player. - -**Two leaks found and closed in v0.7.9.2**, both discovered while planning Gitea#20 and both live in -multiplayer with or without that display: the **seed**, announced in the opening line of every -multiplayer game, and the **name of a card drawn blind** from the Home Office deck. The tests for -them are in `redaction.test.ts` now, so narration is no longer entirely unchecked — but two specific -strings are not a net. - -**Solitaire deliberately keeps both**, and that is the rule to apply to anything found next: a -one-seat table has nobody to leak to, the seed in the log is what a bug report quotes, and a solo -player's own history naming their own draw is the record. The rule is "do not tell the OTHER seats", -not "write less down". - -**What is still owed** is the plan's own list (`docs/plans/jitsi-common-board.md`, Step 1 § Tests): -serialise the public frame *and* the player pushes and search for every opponent hand-card id **and -display name**, objective ids and names, `justDrawn` for the wrong player, seed values and seed -narration, and private decision/menu data — across a newly created game, a blind draw, a pending -decision, the Superintendent acting, Employee Rotation before and after ownership changes, a -reconnect push, and a finished game. **And the plan's acceptance bar is not the tests**: it requires -an allow-list review of every public property, on the grounds that passing redaction tests alone is -insufficient. That is the right bar — the two leaks above would have passed any test nobody thought -to write. - -**Supersedes #78**, which said the exhaustive Frame check was "still missing… held for now, -2026-08-20". It is not missing: `test/redaction.test.ts` exists and does exactly what #78 described -— serialise a seat's Frame, assert no other seat's card ids and no deck order. #78 was written -before that file and was never revisited, so it read as live work for two weeks after it was done. -**The gap that is actually real is the log, which #78 never mentioned.** - #### #75 — Let the game join a call and talk to the table. **Let the game join a call and talk to the table.** Long-term. If the game could join a Zoom, @@ -802,39 +749,6 @@ and whether they took it the moment their turn arrived. ### The screen -#### #94 — A RED FLAG STANDING AT THE LIMITS IS DRAWN NOWHERE. - -**A RED FLAG STANDING AT AN OFFICE'S LIMITS IS DRAWN NOWHERE.** Found 2026-09-07 while checking - -which of the Gitea#20 step 1 findings were still real. It is real, and it is a play bug now rather -than a common-board one. - -**What the engine does**, traced rather than assumed. `maneuver.redFlags` emits `redFlagsSet`, whose -reducer sets `node.redFlag = side` on that Office's Division node (`apply.ts:2307`). From then on -`redFlagStop` holds the next train arriving from that side — `dest.redFlag === from` → `spendFlag`, -which removes the flag and emits `redFlagSpent` (`advance.ts:1216, 1173`). So it is a standing token -on the board that stops a train, exactly as a flag on the table would be. - -**What the screen does.** `narrate` announces it once — "RED FLAGS set out on the Eastern Limits" — -and then it is gone with the scroll. The Office node in `DivisionView` carries `kind`, `label`, -`trains`, `capacity`, `modifiers`, `gradeUp`, `seat`, `running` and `switching`, and **no `redFlag` -field at all**; `redFlag` appears nowhere in `board-svg.ts` and nowhere in the web layer. The map -draws the Office, its A/D tracks and the trains standing on it, and not the flag at its Limits. - -**So a player who set a flag out three Stages ago has nothing telling them it is still there, and an -opponent who missed the line never knew.** Then a train stops short, and the only explanation is a -log entry that has scrolled away. That is the shape of Gitea#21 — a correct refusal with no visible -reason — and of Gitea#22 — a board that does not show what the rules are acting on. - -**Why it belongs before 0.8.0 rather than in it:** the plan already lists "add the Red Flag holder to -the public player projection — it is public game state but is currently absent from `Frame`" as part -of step 1. The field has to exist on the projection either way, so every hour spent on it is 0.8.0 -groundwork rather than a detour. **The drawing is the open question, not the data** — a flag at the -Limits column is the obvious rendering, and `board-svg.ts` already draws those edges (`edge()`), so -there is somewhere to hang it. - -**Not fixed. Sized: small on the data, a judgement call on the picture.** - #### #44 — The history panel's 60-line cap is hard-coded, and how much history it… **The history panel's 60-line cap is hard-coded, and how much history it holds should be @@ -1816,11 +1730,68 @@ where it belongs, and it is still open. Closed items, kept because several are the only record of a ruling or a lesson. Newest first within each group. -### Shipped through v0.7.9.3, from the queue +### Shipped through v0.7.9.4, from the queue Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson; the numbers stay so cross-references above and below still resolve. +91. ~~**Narration was outside the redaction net, and so was everything else nobody had listed.**~~ — + done 2026-09-07 in v0.7.9.4. The systematic pass Gitea#20 step 1 asks for: serialise a seat's + Frame, the public board and the narration it receives, and search all three for every opponent + card id, every card NAME that is 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 and a played-out game. + **And the allow-list, which is the plan's actual acceptance bar:** every property of + `publicSnapshot` is written down and compared, so adding a field fails the suite until somebody + has said out loud that a spectator may see it. That is the check that would have caught both + v0.7.9.2 leaks, since both were fields nobody had asked the question about. + + **Worth knowing, and it cost two false failures to learn: a card NAME is a type, not an + identity.** "right-hand curve" names a dozen cards and one is legitimately drawn on the board as + a cell label the moment anybody lays track, so searching for it fails on correct code. A name + counts as evidence only when EVERY card bearing it is in the one hand. Ids need no such care. + **And a one-digit seed makes the seed check meaningless** — seed 7 matched "Train 7". The seeds + in this file are nine digits deliberately. + + Proved by mutation rather than by passing: restoring the seed line fails 6 tests, restoring the + blind-draw 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. + + **One item on the plan's list has no test because it has no referent:** there is no secret + objective in this game. `objectiveOf` derives from `config.minCombinedRevenue` and the player's + own Revenue, both public. Said here so the next reader does not go looking for the gap. + +94. ~~**A Red Flag standing at an Office's Limits was drawn nowhere.**~~ — done 2026-09-07 in + v0.7.9.4. It is a token set out ON the board that holds the next train arriving from that side; + it was announced once in the log and then existed only in the engine, so a train would stop + short with its only explanation scrolled out of the panel. `DivisionView`'s office node carries + `redFlag` now 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 would say one is out and leave the reader to hover for the half that decides whether + to run a train. **Worth knowing:** the same class as Gitea#21 and #22, and the third in a row — + when the engine gains a thing that CHANGES what a train may do, ask where it is drawn before + asking whether it works. + +95. ~~**The public projection helpers — Gitea#20 step 1's foundation.**~~ — done 2026-09-07 in + v0.7.9.4. `projectDistrict(state, seat)`, `projectDivision(state)`, `projectSharedTable(state)`, + `publicSnapshot(state)` and `currentActorOfState(state)`, with `snapshot()` **rebuilt to compose + from the same helpers** rather than keeping its own copy — so a player's frame and a spectator's + cannot come to disagree about the clock, the phase or the score. Behaviour-neutral, and the + existing 897 tests are the proof of that. + + **Districts are keyed by SEAT and the player resolved through `playerAtSeat`**, because Employee + Rotation moves players between districts; a public board that assumed seat and player index were + interchangeable would relabel every district the first time anybody rotated. **And the public + view is composed upward, never by calling `snapshot()` once per seat** — that shortcut builds + every private field and then has to remember to strip it, and `snapshot` defaults its viewer to + player zero, so a careless spectator call would have served seat 0's hand. + + **One plan finding struck off rather than fixed:** it warns that a public display reading + `clock.currentActor` could highlight the wrong district during a decision. Measured across six + seeds and 3,600 decision points, that field and `actingPlayer` never disagreed — both are only + consulted when somebody is genuinely acting. `currentActorOfState` exists anyway, as one place + for the next reader to ask. + 32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done 2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07: "a temporary document to help some of the playtesters out on making the big jump, but that is no diff --git a/package.json b/package.json index 6fc13b5..66d49a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.9.3", + "version": "0.7.9.4", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 8667527..1e6803d 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -144,6 +144,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul * register under the rail (Gitea#18). */ below?: Cell['trains']; + /** + * Office cells only: a Red Flag standing at this Office's Limits, and which approach it guards + * (#94). A token set out ON the board that holds the next train arriving from that side, so it + * is drawn like the other things standing on the map rather than left to the log. + */ + redFlag?: string | null; /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ regions: number; /** @@ -223,10 +229,17 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul (owner?.isYou ? ' — this is your railroad' : '') + // "their move" is wrong when the reader is the one being waited on. (owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') + + // #94 — first, and in full, because it is the one thing here that CHANGES what a train + // may do. Everything below it describes the cell; this describes a rule in force. + (n.redFlag === 'east' || n.redFlag === 'west' + ? `\n\nRED FLAG set out at the ${n.redFlag === 'east' ? 'East' : 'West'} Limits — the ` + + `next train arriving from the ${n.redFlag} is held short, and the flag is spent doing it.` + : '') + `\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` + `trains standing in it: those holding an A/D track on the rail, and any crew switching in ` + `the district below it.`, seat: n.seat ?? null, + redFlag: n.redFlag ?? null, // No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a // card outright rather than a part of one. regions: 0, @@ -364,6 +377,30 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul out += ``; } + /** + * A RED FLAG STANDING AT THE LIMITS (#94). + * + * Drawn at the END IT GUARDS — west on the left, east on the right, since east is right on this + * map — because which approach it covers is the whole of the information. A flag in the middle + * of the cell would say a flag is out and leave the reader to hover for the half that decides + * whether to run a train. + * + * A staff with a pennant, at rail height, standing clear of the chips: it is beside the rail, + * which is where a flag is. Red is otherwise unused on this map (`bs-full` tints a cell, it does + * not draw), so the mark does not compete with anything for meaning. + */ + if (c.redFlag === 'east' || c.redFlag === 'west') { + const west = c.redFlag === 'west'; + const fx = west ? c.x + 9 : c.x + c.w - 9; + const top = c.y + RAIL_Y - 22; + const dir = west ? 1 : -1; + out += ``; + out += ``; + // The pennant flies INTO the cell, so it can never overhang the card edge at either end. + out += ``; + out += ``; + } + /** * WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said. * @@ -1205,6 +1242,9 @@ export const BOARD_CSS = ` /* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the train is measured against, not something to look at instead of the train. */ .bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3} +/* #94 — the one red mark on the Division map, so it reads as a stop rather than as decoration. */ +.bs-flag line{stroke:#9aa3b0;stroke-width:1.6} +.bs-flag polygon{fill:#d2453f;stroke:#7d211d;stroke-width:0.8} /* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning. SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is diff --git a/src/sim/view.ts b/src/sim/view.ts index 3618482..9c60ac9 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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 & { + 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; diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 97bb5b8..5785bc1 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -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, 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, 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(); + 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(); + 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, 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)), `${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, 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)['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', + ); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index 90fa01c..b838c55 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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 => { + 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. *