diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7aada..265b6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,71 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9.2 — 2026-09-07 + +Two multiplayer information leaks, and the documentation problem that let a wrong table sit in +`docs/rules/` for several releases with the code pointing at it. + +### The shared narration log was telling every seat things only one seat should know + +Both found while planning the public common board (Gitea#20 step 1), and **both are live multiplayer +bugs with or without that display** — which is why they are fixed here rather than waiting for +0.8.0. + +`game.log` is ONE list. `linesSince(seat)` (`server/session.ts:181`) slices it with no per-seat +filter at all, so every line written there reaches every player. Two things were being written into +it that should never have left the seat that caused them: + +**The seed, in the opening line of every multiplayer game.** `competitive · 3 players · seed 4242` +handed each player the entire future of the deal — every card order, every die roll. + +**The name of a card drawn blind from the Home Office deck.** `Player Cy drew Red Flags from the +Home Office deck`, to the whole table, from a face-down deck. + +**Solitaire deliberately keeps both, and that is the rule rather than an exception.** A one-seat +table has nobody to leak to; the seed in the log is what a bug report quotes — both of the issues +fixed in 0.7.9.1 opened by naming it — and a solo player's own history naming their own draw is the +record, not a leak. The rule is *do not tell the OTHER seats*, not *write less down*. A Department +slot stays named for the same reason: those piles are face up, a discard goes onto one precisely so +a rival can take it, so the card was public before it was drawn. + +The drawing seat still learns what it got. `justDrawn` is the owner-only channel and `session.ts` +already sends it to that seat alone, so hiding the name from the shared log costs the drawer nothing. +The seed remains in `game.seed`, in every save and in the lobby record, so nothing administrative or +replayable loses it. + +**These were not found by a test. They were found by reading a plan.** Every test in +`redaction.test.ts` passes `[]` for the narration log, so the whole of it has sat outside the +redaction net since the net was built. Tests for both now live there, but two strings are not a net — +TODO #91 carries what is still owed, and supersedes #78, which described a gap that had already been +closed and never mentioned this one. + +### `docs/rules/` had no current description of the game, and `content.ts` pointed at a superseded one + +`content.ts` named `docs/rules/card-reference.md` as "the place that now carries what the cards say". +That file opens with its own banner — **"⚠ SUPERSEDED… Do not use its numbers"** — and describes the +v0.4.5 deck: twelve numbered trains, `3 / 4 | Mail-Express | 3 coaches, no caboose`, against a +`content.ts` whose train 3 is the Express, two freight cars, `oneFreightPerLocation`. The code was +sending readers to a table it had itself replaced. + +Every file in `docs/rules/` turns out to be a historical record and says so: `rules-v0.1.md` is a +faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker, `rules-v0.2.md` +and `card-reference.md` both carry superseded banners. **So the fix is not to rewrite one of them** — +the record is worth more intact than patched, and there was simply no current reference at all. + +`docs/rules/as-built.md` is new and is **generated** — trains, Mainline cards, Offices, freight +facilities, modifiers and track, emitted from the same exported catalogues the engine instantiates +from, by `scripts/build-card-reference.ts` (`npm run build:cards`). +`test/card-reference.test.ts` re-runs the generator and asserts the checked-in file matches, so +changing a card face without regenerating turns the suite red. + +**A hand-written replacement would have drifted exactly the same way**, and for the same reason: +nothing fails when a table falls behind a constant. That is the whole lesson of the file it replaces. + +897 tests pass, up from 891. + +--- + ## 0.7.9.1 — 2026-09-07 Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule diff --git a/TODO.md b/TODO.md index efb5ede..294153a 100644 --- a/TODO.md +++ b/TODO.md @@ -74,8 +74,8 @@ 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 #32 -2. **The common board, and watching play happen — Gitea#20** — #13 #15 #18 #78 #75 +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 3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79 4. **The screen** — #44 #81 #33 #36 5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52 @@ -94,8 +94,14 @@ ruling or a lesson). ## Play it at a table The largest gap in the project, and none of it is a coding gap. Features are shipped, packed, -running on `phoenix.local` — and no person has met them at a board. Everything else in this file -waits behind a release; this waits behind an afternoon. +running on `phoenix.local` — and the items below name the ones no person has met at a board. +Everything else in this file waits behind a release; this waits behind an afternoon. + +**Test runs WERE made across 0.7.4 through 0.7.9** (Jesse, 2026-09-07) and produced no change +requests — the two bugs that did come out of them are Gitea#21 and #22, fixed in v0.7.9.1. So this +section is not "nobody has touched it since 0.7.4"; it is the narrower and still-true claim that the +specific paths below have not been exercised at a table. **More testing is planned at the end of the +0.7.9 series, before 0.8.0 starts** — that is the moment to close these, not a separate errand. - [ ] **#39** — **None of v0.7.4 has been played by a human.** The Yard Office offer, the Red Flag hold and its out-of-phase prompt, and the loaded-Extra make-up rules are tested end to end, @@ -118,11 +124,6 @@ waits behind a release; this waits behind an afternoon. in a release note rather than code, and worth knowing when a bug report arrives with a save that will not load. See **Reference · #40**. -- [ ] **#32** — **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same - shape as #40 for the playtest line; the saves attached to Gitea#15 and #17 are among them. - `PLAYTEST-0.7.4.md` at the repo root is the note drafted for this and leads with it. See - **Reference · #32**. - --- ## The common board, and watching play happen — Gitea#20 @@ -148,8 +149,16 @@ 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**. -- [ ] **#78** — The redaction test is more done than the plan suggests, but the remaining gap is real - and is Gitea#20 step 1's starting point. See **Reference · #78**. +- [ ] **#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**. @@ -453,14 +462,6 @@ the move and leaves the file untouched — and `WHISTLE-4086` did survive on `ph is "may not" rather than "will not". Worth a line wherever the build is announced, and worth knowing when a bug report arrives with a save that will not load. -#### #32 — Tell the 0.4.9 playtesters their saves are dead, before they find out. - -**Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same shape as #40 -but for the playtest line: the deck change shipped as v0.4.9h, so every save filed before it — -including the ones attached to Gitea#15 and #17 — stops replaying at its first `card.play`. They -fail safe and the files are kept, but nobody has been told. `PLAYTEST-0.7.4.md` (untracked, at -the repo root) is the note drafted for this and leads with it. - ### The common board, and watching play happen — Gitea#20 #### #13 — I CANNOT SEE WHAT THE OTHER PLAYERS DID — BOTS INCLUDED. @@ -582,22 +583,41 @@ 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. -#### #78 — The redaction test (multiplayer.md §7) is more done than the plan sugg… +#### #91 — NARRATION IS OUTSIDE THE REDACTION NET. -**The redaction test (multiplayer.md §7) is more done than the plan suggests, but the +**NARRATION IS OUTSIDE THE REDACTION NET.** `test/redaction.test.ts` serialises a seat's whole -exhaustive check is still missing.** `test/multiplayer.test.ts`'s "the view shows one seat at -a time" section (added earlier) already proves `snapshot(s, ..., viewer)` gives each seat its -own hand, board, Revenue and impediments — traced `snapshot()` itself -(`src/sim/view.ts:1180-1219`): `hand` reads only `s.decks.hands.get(viewer)`, `deck` is a -count, other seats' hands appear only as `.length`, and `Frame`'s type has no `seed`, -`rngState` or card-id-dictionary field for anything to leak through by accident. What exists -is all spot-checks, though — "this seat's Frame has the right hand length." What's still -missing is the exhaustive one §7 actually calls for: serialize a seat's `Frame` and assert it -contains none of another seat's actual card ids and no deck order, so a future careless edit -is caught rather than assumed safe. Doesn't need a server — buildable now against `snapshot()` -and the existing `game()`/`playGame` harness already in `multiplayer.test.ts`. Held for now, -2026-08-20. +`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. @@ -1729,11 +1749,43 @@ re-verified turn out to have been re-verified against a premise rather than agai 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.1, from the queue +### Shipped through v0.7.9.2, 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. +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 + longer needed." The jump is made, so the note is retired rather than committed. **The durable + fact, which is why this entry stays:** a save written by v0.4.9h does not replay on 0.7.x — the + deck changed, so it stops at its first `card.play` — and that includes the saves attached to + Gitea#15 and #17. It fails safe, naming the move and leaving the file untouched, so a bug report + arriving with a save that will not load is this and not a new fault. #40 is the same shape on the + main line and is still open. + +92. ~~**Two multiplayer information leaks in the shared narration log.**~~ — done 2026-09-07 in + v0.7.9.2, found while planning Gitea#20 step 1. The **seed** was announced in the opening line of + every multiplayer game and a **blind Home Office draw named the card** — and `linesSince(seat)` + slices one shared `game.log` with no per-seat filter, so both went to every player. **Ruling: + solitaire keeps both**, because a one-seat table has nobody to leak to, the seed is what a bug + report quotes, and a solo player's history naming their own draw is the record. A Department + slot is face up and stays named for the same reason. **Worth knowing:** these were not found by + a test, they were found by reading the plan — every redaction test passes `[]` for the log, so + the whole of narration was unchecked. #91 is what remains. + +93. ~~**`docs/rules/` had no current description of the game, and the code pointed at a superseded + one.**~~ — done 2026-09-07 in v0.7.9.2. `content.ts` named `card-reference.md` as "the place + that now carries what the cards say" while that file's own banner said not to use its numbers; + it describes the v0.4.5 deck, where 3/4 is a Mail-Express with three coaches against a `content.ts` + whose train 3 is the Express with two freight cars. **The fix is not a rewritten table** — every + file in `docs/rules/` is a deliberate historical record and worth more intact than patched. A new + `as-built.md` is GENERATED from the same catalogues the engine instantiates from, by + `scripts/build-card-reference.ts` (`npm run build:cards`), and `test/card-reference.test.ts` + re-runs the generator and fails if the checked-in file disagrees. **Worth knowing:** a + hand-written replacement would have drifted the same way and for the same reason — nothing fails + when a table falls behind a constant. Generate it or check it; do not retype it. + 89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the collision rules want; the map wanted "which printed box, left to right" and used the same number, diff --git a/docs/rules/as-built.md b/docs/rules/as-built.md new file mode 100644 index 0000000..dc87845 --- /dev/null +++ b/docs/rules/as-built.md @@ -0,0 +1,177 @@ +# Station Master — the cards as built + +> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by +> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file +> and the code disagree. + +This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything +else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md) +transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in +them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and +[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the +reasoning; read this for the numbers. + +The engine instantiates from the same constants this is emitted from, so a disagreement between +this page and the game is a bug in the generator, not a stale table. + +--- + +## Trains + +12 timetabled and 10 Extras, 22 in all. +Odd numbers run west, even run east; a pair shares a class and is the same card face in two +directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no +train with a caboose carries more than three revenue cars. + +### Timetabled + +| # | Class | Speed | Runs | Consist | Printed rules | +| ---: | --- | --- | --- | --- | --- | +| 1 | Crack Limited | fast | west | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* | +| 2 | Crack Limited | fast | east | 2 coaches (2 pieces) | terminals only; no switching; expedite; *"Stop at Terminals only."* | +| 3 | Express | fast | west | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* | +| 4 | Express | fast | east | 2 freight (2 pieces) | one freight per location; expedite; *"May drop or pick up one freight car at every location."* | +| 5 | The Sparrow | fast | west | 3 coaches (3 pieces) | no switching; expedite | +| 6 | The Sparrow | fast | east | 3 coaches (3 pieces) | no switching; expedite | +| 7 | Local | slow | west | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* | +| 8 | Local | slow | east | 1 freight + 1 coach (2 pieces) | coach stays on station track; *"Maximum one freight, one coach."* | +| 9 | Heavy Freight | slow | west | 3 freight + 1 caboose (4 pieces) | — | +| 10 | Heavy Freight | slow | east | 3 freight + 1 caboose (4 pieces) | — | +| 11 | Drag Freight | slow | west | 2 freight + 1 caboose (3 pieces) | — | +| 12 | Drag Freight | slow | east | 2 freight + 1 caboose (3 pieces) | — | + +### Extras + +| # | Class | Speed | Runs | Consist | Printed rules | +| ---: | --- | --- | --- | --- | --- | +| X13 | Appleseed Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces), empties only | drop only; *"May drop MTs but not pick up anything."* | +| X14 | Fruit Growers Express | fast | player's choice | 2 reefers + 1 caboose (3 pieces) | expedite; *"Reefers only. May pick up one extra loaded reefer."* | +| X15 | Yard Xfer | slow | player's choice | 2 freight + 1 caboose (3 pieces) | — | +| X16 | Light Engine Move | fast | player's choice | engine only | no switching; *"No cars at all."* | +| X17 | Campaign Train | fast | player's choice | 1 coach (1 piece) | no switching; stop then expedite; stop earns point; must run loaded; *"One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard."* | +| X18 | Circus Train | slow | player's choice | 2 freight + 1 coach + 1 caboose (4 pieces) | no switching; stop earns point; must run loaded; *"One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded."* | +| X19 | Military Train | slow | player's choice | 1 freight + 2 coaches (3 pieces) | no switching; no passenger work; expedite; must run loaded; *"Troops and materiel: runs loaded where the yard can supply it."* | +| X20 | Director's private car | slow | player's choice | 2 freight + 1 coach (3 pieces) | no passenger work | +| X21 | Freight Extra | slow | player's choice | 3 freight + 1 caboose (4 pieces) | — | +| X22 | Pee-Dee | slow | player's choice | 1 caboose (1 piece) | pick up empties only; *"Per-diem train. May only pick up MTs."* | + +--- + +## Mainline cards + +A card is divided into **regions**, and a train advances one region per Stage — so the regions a +card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast +train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than +part of the road all change the entry point rather than the card's length. + +| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars | +| --- | ---: | ---: | --- | :---: | :---: | +| Plains | 1 | 0 | — | — | — | +| Curves | 2 | 0 | — | — | — | +| Hilly | 2 | 0 | 1 / 0 | — | — | +| Heavy Grade | 3 | 0 | — | — | — | +| Double Track | 1 | 0 | — | yes | — | +| Uncontrolled Siding | 2 | 1 | — | — | — | +| Tunnel | 2 | 0 | — | — | — | +| Trestle | 1 | 0 | — | — | — | +| Interchange | 2 | 1 | — | — | yes | + +The Mainline deck is 10 cards; the two Division Points are the fixed ends of +the Division and are not dealt. What each card does, in the words the game uses on screen: + +- **Plains** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. +- **Curves** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. +- **Hilly** — 2 regions — one Stage each. · This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses in 1 Stage, a slow one in 2 Stages. No other card cares which it is. · One train at a time — anything following has to wait for it to clear. +- **Heavy Grade** — 3 regions — one Stage each. · A grade, climbing eastward. 3 Stages to climb it and 3 Stages to run down, before help. Helpers start an UPHILL train a region further on; Brakeman does the same DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. Never less than one Stage. · One train at a time — anything following has to wait for it to clear. +- **Double Track** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held behind a slower one. +- **Uncontrolled Siding** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region behind it. You are not in the same place, so you do not run into it; it costs you the extra Stage instead. · One train at a time — anything following has to wait for it to clear. +- **Tunnel** — 2 regions — one Stage each. · 2 Stages for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. +- **Trestle** — 1 region — one Stage each. · 1 Stage for every train — the printed speed is scenery. · One train at a time — anything following has to wait for it to clear. +- **Interchange** — 2 regions — one Stage each. · A train with the card to itself starts past the back region and is across in 1 Stage. · An Extra beginning its run here starts in the back region and takes the extra Stage. · One train at a time — anything following has to wait for it to clear. · Cars may be sorted into any new order here. + +--- + +## Office cards + +Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in +order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal** +to Porters rather than one more. + +| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red | In deck | +| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: | +| Whistle Post | — | — | 1 | 0 | 0 | 0 | — | +| Depot | yes | yes | 2 | 1 | 1 | 1 | 4 | +| Station | yes | yes | 3 | 2 | 2 | 2 | 2 | +| Terminal | yes | yes | 4 | 3 | 3 | 3 | 1 | + +Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a +supply of 8. + +--- + +## Freight facilities + +Each lists the car types it works, which way its traffic flows, and the industries it may not sit +beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may +build one end of a chain or the other, never both, which is what forces traffic to run between +districts rather than in circles inside one. No two of the same industry may share an Office Area, +and that rule is enforced for every kind rather than repeated in each row. + +| Industry | Cars | Flow | Green | Red | Laborers | Locked out with | Copies | +| --- | --- | --- | ---: | ---: | ---: | --- | ---: | +| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse | 2 | +| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant | 2 | +| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant | 1 | +| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery | 2 | +| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse | 1 | +| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House | 1 | + +--- + +## Modifier cards + +Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger +Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which +is not one. + +| Modifier | Hosts | +Green | +Red | +Laborers | +Porters | Copies | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| Waiting area | any Passenger Facility | 1 | — | — | 1 | 3 | +| Restaurant | any Passenger Facility | 1 | — | — | 1 | 2 | +| Hotel | any Passenger Facility | 1 | — | — | 1 | 1 | +| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — | 2 | +| Railroad Express Agency | Freight House | 1 | — | 1 | — | 1 | +| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — | 2 | +| Prep Plant | Mine Tipple | 1 | — | 1 | — | 1 | +| Coal Piles | Mine Tipple | 1 | — | 1 | — | 1 | +| Conveyor Belts | Mine Tipple | 1 | — | 1 | — | 1 | +| Pipelines | Refinery | 1 | — | 1 | — | 1 | +| Oil Depot | Refinery | 1 | — | 1 | — | 1 | +| Viscosity breakers | Refinery | 1 | — | 1 | — | 1 | +| Transmission lines | Power Plant | — | — | 1 | — | 1 | +| Rotary Dumps | Power Plant | — | — | 1 | — | 1 | +| Steam Turbines | Power Plant | — | — | 1 | — | 1 | +| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — | 2 | +| Local small groceries | Grocer's Warehouse | — | — | 1 | — | 1 | + +--- + +## Track cards + +Track is **in the Home Office deck** and is drawn and played like any other card — not a separate +per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout +may be run through but not stopped on. + +| Track | Geometry | Hand | Operational rail | Move cost | In deck | +| --- | --- | --- | :---: | ---: | ---: | +| Straight track | straight | none | yes | 1 | 16 | +| Curved track (right) | curved | right | yes | 1 | 8 | +| Curved track (left) | curved | left | yes | 1 | 8 | +| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | — | +| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | — | +| Turnout (right) | turnout | right | — | 1 | 8 | +| Turnout (left) | turnout | left | — | 1 | 8 | + +48 track cards are dealt in total. Rows showing no copies are shapes the engine +understands but the deck does not currently print. + diff --git a/docs/rules/card-reference.md b/docs/rules/card-reference.md index e5f282f..8320df6 100644 --- a/docs/rules/card-reference.md +++ b/docs/rules/card-reference.md @@ -4,6 +4,11 @@ > 2026-07-30 in `docs/Deck cards2.xlsx`, `Trains3.pdf` and `Mainline Cards.pdf`, and is transcribed > in `src/engine/content.ts`. See [`implications.md`](implications.md) for the full comparison. > +> **For what the cards say today, read [`as-built.md`](as-built.md)** — generated from +> `content.ts` and checked against it by the test suite, so it cannot fall behind the way this file +> did. For several releases `content.ts` named *this* page as the current reference while the banner +> here said otherwise, and a reader following the code landed on the v0.4.5 deck. +> > Kept for the reasoning it records — the economy analysis in §7 was how we knew what questions to > ask the design. **Do not use its numbers.** diff --git a/package.json b/package.json index 9c3490a..585ba4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.9.1", + "version": "0.7.9.2", "private": true, "type": "module", "description": "Station Master — a railroad operations game", @@ -13,7 +13,8 @@ "test": "node --test test/*.test.ts test/**/*.test.ts", "build:web": "node scripts/build-web.ts", "serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1", - "deploy:web": "node scripts/deploy-web.ts" + "deploy:web": "node scripts/deploy-web.ts", + "build:cards": "node scripts/build-card-reference.ts" }, "devDependencies": { "@types/node": "^26.1.2", diff --git a/scripts/build-card-reference.ts b/scripts/build-card-reference.ts new file mode 100644 index 0000000..ccd95d3 --- /dev/null +++ b/scripts/build-card-reference.ts @@ -0,0 +1,212 @@ +/** + * Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them. + * + * WHY THIS IS GENERATED RATHER THAN WRITTEN. + * + * Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a + * faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker, + * `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the + * game as built, and none of them should be edited to — the record is worth more intact than + * patched. + * + * So there was no current reference at all, and `content.ts` spent several releases pointing at + * `card-reference.md` as "the place that now carries what the cards say" while that file's own + * banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5 + * deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train + * 3 is the Express with two freight cars and a per-location freight rule. + * + * A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing + * fails when a table falls behind a constant. So the reference is emitted from the same exported + * catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator + * and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red + * until the doc is regenerated — which is the only mechanism this project has found that keeps a + * document honest. + * + * `npm run build:cards` writes it. Nothing at runtime reads it; it is for people. + */ + +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + ALL_TRAINS, EXTRA_TRAINS, INDUSTRY_PROFILES, LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_PROFILES, + MODIFIER_PROFILES, OFFICE_PROFILES, TIMETABLED_TRAINS, TRACK_CARDS, TRACK_IN_DECK, + WHISTLE_POST_SUPPLY, consistSize, mainlineDescription, +} from '../src/engine/content.ts'; +import type { ConsistSpec, TrainProfile, TrainRules } from '../src/engine/content.ts'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** Title Case a camelCase key, so `oneFreightPerLocation` reads as a rule rather than an identifier. */ +const words = (k: string): string => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim(); + +const consistOf = (c: ConsistSpec): string => { + const parts: string[] = []; + if (c.freight > 0) { + const kinds = c.freightTypes ? c.freightTypes.join(' or ') : 'freight'; + parts.push(`${c.freight} ${kinds}${c.freight === 1 ? '' : c.freightTypes ? 's' : ''}`); + } + if (c.coach > 0) parts.push(`${c.coach} coach${c.coach === 1 ? '' : 'es'}`); + if (c.caboose > 0) parts.push(`${c.caboose} caboose`); + if (!parts.length) return 'engine only'; + const n = consistSize(c); + // "Empties only" qualifies the whole consist rather than adding to it, so it reads after the count. + return `${parts.join(' + ')} (${n} piece${n === 1 ? '' : 's'})${c.emptiesOnly ? ', empties only' : ''}`; +}; + +const rulesOf = (r: TrainRules): string => { + const out: string[] = []; + for (const [k, v] of Object.entries(r)) { + if (k === 'note' || v === false || v === undefined) continue; + out.push(words(k)); + } + if (typeof r.note === 'string') out.push(`*"${r.note}"*`); + return out.length ? out.join('; ') : '—'; +}; + +const trainRow = (t: TrainProfile): string => + `| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` + + `${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`; + +const lines: string[] = []; +const w = (s = ''): void => void lines.push(s); + +w('# Station Master — the cards as built'); +w(); +w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by'); +w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file'); +w('> and the code disagree.'); +w(); +w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything'); +w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)'); +w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in'); +w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and'); +w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the'); +w('reasoning; read this for the numbers.'); +w(); +w('The engine instantiates from the same constants this is emitted from, so a disagreement between'); +w('this page and the game is a bug in the generator, not a stale table.'); +w(); +w('---'); +w(); + +w('## Trains'); +w(); +w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`); +w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two'); +w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no'); +w('train with a caboose carries more than three revenue cars.'); +w(); +w('### Timetabled'); +w(); +w('| # | Class | Speed | Runs | Consist | Printed rules |'); +w('| ---: | --- | --- | --- | --- | --- |'); +for (const t of TIMETABLED_TRAINS) w(trainRow(t)); +w(); +w('### Extras'); +w(); +w('| # | Class | Speed | Runs | Consist | Printed rules |'); +w('| ---: | --- | --- | --- | --- | --- |'); +for (const t of EXTRA_TRAINS) w(trainRow(t)); +w(); +w('---'); +w(); + +w('## Mainline cards'); +w(); +w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a'); +w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast'); +w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than'); +w('part of the road all change the entry point rather than the card\'s length.'); +w(); +w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |'); +w('| --- | ---: | ---: | --- | :---: | :---: |'); +for (const m of MAINLINE_PROFILES) { + const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—'; + w(`| ${m.name} | ${m.regions} | ${m.defaultStart} | ${ss} | ${m.trainsMayPass ? 'yes' : '—'} | ${m.sortsCars ? 'yes' : '—'} |`); +} +w(); +w(`The Mainline deck is ${MAINLINE_DECK.length} cards; the two Division Points are the fixed ends of`); +w('the Division and are not dealt. What each card does, in the words the game uses on screen:'); +w(); +for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`); +w(); +w('---'); +w(); + +w('## Office cards'); +w(); +w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in'); +w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**'); +w('to Porters rather than one more.'); +w(); +w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red | In deck |'); +w('| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |'); +for (const o of OFFICE_PROFILES) { + w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` + + `${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} | ${o.copiesInDeck || '—'} |`); +} +w(); +w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`); +w(`supply of ${LIMITS_SUPPLY}.`); +w(); +w('---'); +w(); + +w('## Freight facilities'); +w(); +w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit'); +w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may'); +w('build one end of a chain or the other, never both, which is what forces traffic to run between'); +w('districts rather than in circles inside one. No two of the same industry may share an Office Area,'); +w('and that rule is enforced for every kind rather than repeated in each row.'); +w(); +w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with | Copies |'); +w('| --- | --- | --- | ---: | ---: | ---: | --- | ---: |'); +for (const f of INDUSTRY_PROFILES) { + const lo = f.lockouts.length + ? f.lockouts.map((k) => INDUSTRY_PROFILES.find((p) => p.kind === k)?.name ?? k).join(', ') + : '—'; + w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} | ${f.copies} |`); +} +w(); +w('---'); +w(); + +w('## Modifier cards'); +w(); +w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger'); +w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which'); +w('is not one.'); +w(); +w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters | Copies |'); +w('| --- | --- | ---: | ---: | ---: | ---: | ---: |'); +for (const m of MODIFIER_PROFILES) { + const hosts = m.hosts + .map((h) => (h === 'office' ? 'any Passenger Facility' : INDUSTRY_PROFILES.find((p) => p.kind === h)?.name ?? h)) + .join(', '); + w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} | ${m.copies} |`); +} +w(); +w('---'); +w(); + +w('## Track cards'); +w(); +w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate'); +w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout'); +w('may be run through but not stopped on.'); +w(); +w('| Track | Geometry | Hand | Operational rail | Move cost | In deck |'); +w('| --- | --- | --- | :---: | ---: | ---: |'); +for (const t of TRACK_CARDS) { + w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck || '—'} |`); +} +w(); +w(`${TRACK_IN_DECK} track cards are dealt in total. Rows showing no copies are shapes the engine`); +w('understands but the deck does not currently print.'); +w(); + +writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`); +console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`); diff --git a/src/engine/content.ts b/src/engine/content.ts index a913025..01e2735 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -481,8 +481,11 @@ export const TIMETABLED_TRAINS: readonly TrainProfile[] = [ * COACH COUNTS ON 1/2 AND 5/6 WERE SWAPPED BY JESSE (Gitea#7, v0.4.9e playtest): the Crack Limited * drops from three coaches to two, and The Sparrow rises from two to three. A change to the card * faces themselves, not a transcription fix — `Trains3.pdf` and the tables that transcribe it - * still print the old numbers, so `docs/rules/card-reference.md` is the place that now carries - * what the cards say. + * still print the old numbers, so `docs/rules/as-built.md` is the place that now carries what + * the cards say — GENERATED from the constants below by `scripts/build-card-reference.ts`, with + * `test/card-reference.test.ts` failing if the two disagree. This comment used to name + * `card-reference.md`, which describes the v0.4.5 deck and carries a banner saying not to use its + * numbers; the code sent readers to a table it had itself superseded. */ ...pair(1, 'Crack Limited', 'fast', { freight: 0, coach: 2, caboose: 0 }, { terminalsOnly: true, noSwitching: true, expedite: true, note: 'Stop at Terminals only.' }), diff --git a/src/web/game.ts b/src/web/game.ts index 5cb6c50..80f4dfd 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -333,7 +333,21 @@ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames const state = createGame({ id: `mp-${seed}`, seed, config, playerNames }); const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; game.log.push({ text: 'Game Begins', tone: 'start' }); - game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' }); + /** + * NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1). + * + * `game.log` is one shared list and `linesSince(seat)` (`server/session.ts`) slices it with no + * per-seat filter, so every line here reaches every player. Announcing the seed therefore handed + * each of them the whole future of the deal — every card order, every die — in the opening line + * of the game. Found while planning the public common board; it is a multiplayer leak with or + * without that display, which is why it is fixed here rather than waiting for it. + * + * `newGame` still records it, deliberately: a solitaire table has nobody to leak to, and the seed + * in the log is what a bug report quotes. The rule is "do not tell the OTHER seats", not "write + * less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the + * lobby record, so nothing administrative or replayable loses it. + */ + game.log.push({ text: `${config.mode} · ${playerNames.length} players`, tone: 'quiet' }); drain(game); return game; } @@ -1149,10 +1163,28 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul cardName: (id) => cardName(game.state, id), trainName: (id) => trainName(game.state, id), }); + /** + * A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1). + * + * Everybody at the table sees a hand go to the Home Office deck, so the draw itself belongs in + * the shared log. The card's NAME does not: the deck is face down, and this log goes to every + * seat unfiltered, so naming it told three opponents exactly what the fourth was holding. + * + * A DEPARTMENT SLOT IS NOT THE SAME and stays named. Those piles are face up — a discard goes + * onto one precisely so a rival can take it — so the card was public before it was drawn, and + * hiding it would lose real information for no gain. + * + * The drawing seat still learns what it got. `justDrawn` below is the owner-only channel and + * `session.ts` sends it to that seat alone, so this costs the drawer nothing. Solitaire keeps + * the name for the same reason it keeps the seed: a one-seat table has nobody to leak to, and + * a solo player's history naming their own draw is the record rather than a leak. + */ + const blindDraw = e.type === 'cardDrawn' && e.source === 'homeOffice' && game.state.players.length > 1; + const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text; // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // one seat. Only events the player caused are attributed; the Division running itself is not. const mine = who !== null && 'player' in e; - const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text; + const text = mine ? `Player ${who} ${uncapitalise(said)}` : said; game.log.push({ text, tone: mine ? 'act' : n.tone }); } diff --git a/test/card-reference.test.ts b/test/card-reference.test.ts new file mode 100644 index 0000000..2ad4ee1 --- /dev/null +++ b/test/card-reference.test.ts @@ -0,0 +1,54 @@ +/** + * The card reference must not drift from the cards. + * + * `docs/rules/card-reference.md` spent several releases describing the v0.4.5 deck — twelve numbered + * trains, "3 / 4 Mail-Express, 3 coaches" — while `content.ts` had train 3 as the Express with two + * freight cars and a per-location freight rule. Worse, `content.ts` named that file as "the place + * that now carries what the cards say", so the code sent readers to a table its own banner told them + * not to trust. Nothing failed, because nothing checked. + * + * `docs/rules/as-built.md` is emitted from the same exported catalogues the engine instantiates + * from, and this re-runs the generator and compares. Change a card face without regenerating and + * this goes red — which is the whole point: a document nothing verifies is a document that will be + * wrong, and this project's own history is the evidence. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const doc = join(root, 'docs/rules/as-built.md'); + +describe('docs/rules/as-built.md is generated, and current', () => { + it('matches what the generator emits from content.ts today', () => { + const before = readFileSync(doc, 'utf8'); + execFileSync(process.execPath, [join(root, 'scripts/build-card-reference.ts')], { cwd: root }); + const after = readFileSync(doc, 'utf8'); + assert.equal( + after, + before, + 'the checked-in card reference is stale — run `npm run build:cards` and commit the result', + ); + }); + + it('carries the current train catalogue, not the v0.4.5 deck', () => { + // The specific drift that went unnoticed for several releases, asserted by name so a future + // regeneration against an old content.ts cannot quietly reintroduce it. + const md = readFileSync(doc, 'utf8'); + assert.match(md, /Crack Limited/); + assert.match(md, /\| 3 \| Express \|/); + assert.ok(!/Mail-Express/.test(md), 'the superseded v0.4.5 train names are back'); + assert.ok(!/Manifest Freight/.test(md), 'the superseded v0.4.5 train names are back'); + }); + + it('says it is generated, so nobody edits it by hand', () => { + const md = readFileSync(doc, 'utf8'); + assert.match(md, /Generated from `src\/engine\/content\.ts`/); + assert.match(md, /Do not edit by/); + }); +}); diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 2ad0f69..97bb5b8 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -17,7 +17,8 @@ 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 { snapshot } from '../src/sim/view.ts'; +import { cardName, snapshot } from '../src/sim/view.ts'; +import { newGame, newMultiplayerGame, submit } from '../src/web/game.ts'; const config: GameConfig = { mode: 'competitive', @@ -139,3 +140,77 @@ describe('redaction — a seat\'s Frame never carries another seat\'s secrets', } }); }); + + +/** + * THE OTHER HALF OF §7, AND THE HALF THAT WAS NEVER LOOKED AT. + * + * Every test above serializes a `Frame`, and every one of them passes `[]` for the narration log — + * so the entire shared log has sat outside the redaction net since the net was built. It is not a + * hypothetical hole: `game.log` is ONE list, and `linesSince(seat)` (`server/session.ts`) slices it + * with no per-seat filter at all, so every line written into it reaches every player. + * + * Two things were being written into it that should never have left the seat that caused them, both + * found while planning the public common board (Gitea#20 step 1) and both live in multiplayer today, + * with or without that display: + * + * 1. the SEED, announced in the opening line of every multiplayer game — which hands every player + * the whole future of the deal; + * 2. the NAME OF A CARD DRAWN BLIND from the Home Office deck. + * + * SOLITAIRE IS DELIBERATELY LEFT ALONE in both cases. There is nobody to leak to at a one-seat + * table, the seed in the log is what a bug report quotes, and a solo player's own history naming + * the card they drew is the record, not a leak. The rule is "do not tell the OTHER seats", not + * "write less down" — so both checks below assert the solitaire text is still there. + */ +describe('redaction — the shared narration log never carries a seat\'s secrets', () => { + const names = ['Ann', 'Bob', 'Cy']; + + it('never announces the seed to the table (Gitea#20 step 1)', () => { + const g = newMultiplayerGame(550943578, config, names); + const log = g.log.map((l) => l.text).join('\n'); + assert.ok( + !/550943578/.test(log), + `the seed was announced to every seat:\n${log}`, + ); + // The opening line must still say what the game IS — the leak is the number, not the line. + assert.match(log, /Game Begins/); + assert.match(log, /3 players/); + }); + + it('still tells a solitaire player their own seed — there is nobody to leak it to', () => { + const g = newGame(550943578); + const log = g.log.map((l) => l.text).join('\n'); + assert.match(log, /550943578/, 'a solo game stopped recording the seed its bug reports quote'); + }); + + it('never names a card drawn blind from the Home Office deck (Gitea#20 step 1)', () => { + const g = newMultiplayerGame(4242, config, names); + + // Drive to the first Home Office draw any seat makes, and note what it actually drew. + let drawn: string | null = null; + for (let i = 0; i < 400 && drawn === null; i++) { + const actor = g.state.clock.currentActor; + if (actor === null) break; + const before = g.log.length; + if (!submit(g, { type: 'localOps.choose', option: 'draw' }, actor as PlayerIndex)) continue; + if (!submit(g, { type: 'draw.fromHomeOffice' }, actor as PlayerIndex)) continue; + drawn = g.justDrawn; + void before; + } + assert.ok(drawn, 'no seat ever drew from the Home Office deck'); + + const name = cardName(g.state, drawn!); + const log = g.log.map((l) => l.text).join('\n'); + assert.ok( + !log.includes(name), + `a blind draw named "${name}" to the whole table:\n${log.split('\n').slice(-6).join('\n')}`, + ); + // The draw itself is public — everyone saw a hand go to the deck. Only WHICH card is not. + assert.match(log, /Home Office/i); + + // And the drawing seat still learns what it got: `justDrawn` is the owner-only channel, and + // `session.ts` sends it to that seat alone. + assert.equal(g.justDrawn, drawn); + }); +});