From ebd16983e2bed05377b3b0d21673b009f1c49312 Mon Sep 17 00:00:00 2001 From: "Jesse.Markowitz" Date: Mon, 7 Sep 2026 15:00:57 -0400 Subject: [PATCH] =?UTF-8?q?v0.7.9.4=20=E2=80=94=20Gitea#20=20step=201,=20a?= =?UTF-8?q?nd=20a=20Red=20Flag=20you=20can=20see?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ss2y7FyhxkHjGj7xnUPCgY --- CHANGELOG.md | 82 ++++++++++++ TODO.md | 149 +++++++++------------ package.json | 2 +- src/sim/board-svg.ts | 40 ++++++ src/sim/view.ts | 297 +++++++++++++++++++++++++++++++---------- test/redaction.test.ts | 244 ++++++++++++++++++++++++++++++++- test/web.test.ts | 66 +++++++++ 7 files changed, 720 insertions(+), 160 deletions(-) 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. *