v0.7.9.3 — the reference says what only the code knows, and saves get a rule

The generated card reference now carries the half TODO #15a said was the
point of generating it: every Enhancement's `live` / `dormantSolo` /
`unbuilt` status — whether its printed effect actually resolves yet — and
the opponent-directed Action and Space-use cards, none of which is dealt
in any deck. A transcription cannot carry either fact.

Card counts are removed throughout, as ruled: they move with play balance
so a document printing them is stale on the next retune. Where a count
matters it is a yes/no "is this dealt at all", which is a fact about the
design rather than the current tuning. #88 closes with it — it asked
whether `card-reference.md`'s industry rows were stale, deliberately
without rewriting them since Laborer counts are a balance decision. They
are; nothing in the engine changed; that file is simply no longer where
anyone looks. The balance question it guarded is #70.

Save compatibility becomes a general rule in `README.md` § Design notes
rather than a fact restated per version: a save is a list of moves and
reopens by being re-played through the CURRENT rules, so any change that
makes a once-legal move illegal stops an older one there — a deck change
being the likeliest breaker. It fails safe every time. #40 generalised,
#32's version-specific note dropped, #52 carries the ruling that
versioned replays are a post-1.0 question.

#94 opened: a Red Flag set out at an Office's Limits holds the next train
from that side and is drawn nowhere. `DivisionView`'s office node has no
`redFlag` field and `board-svg.ts` never mentions one, so after the single
log line announcing it there is nothing on screen. Same class as Gitea#21
and #22, and already on the common board's step 1 list.

No engine change. 897 tests pass, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
This commit is contained in:
Jesse.Markowitz
2026-09-07 14:50:11 -04:00
co-authored by Claude Opus 5
parent 819996faa2
commit e734481d65
6 changed files with 353 additions and 105 deletions
+31
View File
@@ -19,6 +19,37 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.7.9.3 — 2026-09-07
Documentation and the build script behind it. No engine change; 897 tests pass, unchanged.
### The generated reference covers what only the implementation knows
TODO #15a asked for this in 2026-08-22 and specified more than a table of card faces: every
Enhancement carries a `live` / `dormantSolo` / `unbuilt` status in `content.ts` saying **whether its
printed effect actually resolves yet**, and the opponent-directed Action and Space-use cards are held
out of every deck until the attacks are implemented. A transcription cannot carry either fact. Both
are in the generated page, which is the argument for generating it.
**No card counts appear, as ruled** — the counts move with play balance, so a document that prints
them is stale on the next retune. Where a count matters it is rendered as a yes/no "is this dealt at
all", which is a fact about the design rather than about the current tuning. #88 closes with it: it
asked whether `card-reference.md`'s industry rows were stale, deliberately without rewriting them
since Laborer counts are a balance decision. They are stale, nothing in the engine changed, and that
file is simply no longer where anyone looks. The balance question it was guarding is #70.
### Save compatibility is a rule now, not a per-version note
`README.md` § Design notes carries it: a save is a list of moves and reopens by being re-played
through the *current* rules, so any change that makes a once-legal move illegal stops an older one
there — **a deck change being the likeliest breaker**, since a history naming a card the deck no
longer deals has no legal answer at all. It fails safe every time. #40 is generalised to match and
#32 closed; per-version compatibility facts are no longer tracked (Jesse, 2026-09-07). Versioned,
migratable replays are a post-1.0 question, deliberately deferred — every migration would be written
against rules that change again next release.
---
## 0.7.9.2 — 2026-09-07
Two multiplayer information leaks, and the documentation problem that let a wrong table sit in
+12
View File
@@ -131,6 +131,18 @@ is the thing this machinery exists to prevent.
and they do not reconstruct the position. The phase driver mutates state and then describes it, so
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
replays the intents.
- **A save is only guaranteed to replay on the version that wrote it.** This is the cost of the
property above and is not a bug to be fixed case by case: a save is a list of moves, so it reopens
by being *re-played through the current rules*. Any rules change that makes a once-legal move
illegal will stop an older save at that move — **a change to the deck is the likeliest breaker**,
since a history that names a card the deck no longer deals has no legal answer at all, but any
narrowing of what is permitted does it. It fails safe in every case: the load is declined, the
offending move is named, and the file is left untouched, so nothing a player has is destroyed.
Assume an older save may not open, tell players so wherever a build is announced, and read "this
save will not load" in a bug report as this before treating it as a fault. **Versioned, migratable
replays are a post-1.0 question** — deliberately not worth the effort while the rules are still
moving this fast, since every migration would have to be written against rules that changed again
next release.
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
+108 -42
View File
@@ -77,7 +77,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
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
4. **The screen** — #94 #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
@@ -119,10 +119,10 @@ specific paths below have not been exercised at a table. **More testing is plann
the dealt game matches what was chosen. It took three attempts to become reachable at all —
reachable is not the same as correct. See **Reference · #42a**.
- [ ] **#40** — **A save from before v0.7.4 may not replay, and nobody has been told.** It fails safe
— the server declines the save, names the move and leaves the file untouched — so this is a line
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**.
- [ ] **#40** — **An older save may not replay, and players are not told so anywhere they will see
it.** Not a v0.7.4 fact and not a bug: a save is re-played through the current rules, so any
narrowing of what is legal can stop one. The rule is written down now (`README.md` § Design
notes); what is still owed is a line **wherever a build is announced**. See **Reference · #40**.
---
@@ -195,6 +195,12 @@ 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
@@ -455,12 +461,26 @@ below); reachable is not the same as correct. Early item for the next play sessi
#### #40 — A save from before v0.7.4 may not replay, and nobody has been told.
**A save from before v0.7.4 may not replay, and nobody has been told.** The Red Flags intent
changed shape, a make-up that was legal may now be refused, and a Yard Office arrival asks a
question no older history has an answer for. It fails safe — the server declines the save, names
the move and leaves the file untouched — and `WHISTLE-4086` did survive on `phoenix.local`, so it
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.
**An older save may not replay, and players are not told so anywhere they will see it.** A save is
a list of moves and reopens by being re-played through the CURRENT rules, so any change that makes a
once-legal move illegal stops it there. **A deck change is the likeliest breaker** — a history
naming a card the deck no longer deals has no legal answer at all — but any narrowing does it. This
is the design working, not a fault: it fails safe every time, declining the load, naming the move
and leaving the file untouched.
**So this item is no longer "saves before v0.7.4"** and should never be restated per version
(Jesse, 2026-09-07). The specific 0.7.4 breakages — the Red Flags intent changing shape, a make-up
that was legal being refused, a Yard Office arrival asking a question no older history answers —
are recorded here as the ORIGIN of the rule rather than as the rule, and `WHISTLE-4086` did survive
on `phoenix.local`, which is why it is "may not" rather than "will not".
**What is actually owed:** the general rule is written down in `README.md` § Design notes, and a
line belongs wherever a build is announced. Nothing in code.
**Versioned, migratable replays are a post-1.0 question, deliberately deferred** (Jesse,
2026-09-07): "once we get to a solid 1.0 release we will consider a system to version the replays so
they are not as fragile — but not worth any effort right now." The reason it would be wasted effort
now is that every migration would be written against rules that change again next release.
### The common board, and watching play happen — Gitea#20
@@ -782,6 +802,39 @@ 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
@@ -995,6 +1048,17 @@ bitten once**: both published replays were dead — one got 42 intents into 360,
carry a ruleset stamp and the page should say "this replay was recorded under an older
ruleset and stops at Stage N" rather than presenting a truncated game as a whole one.
**THE FULL FIX IS DEFERRED PAST 1.0, ON JESSE'S CALL 2026-09-07:** "once we get to a solid 1.0
release we will consider a system to version the replays so they are not as fragile — but not worth
any effort right now." The reason is sound and worth keeping: every migration would be written
against rules that change again the next release, so the work would be redone rather than reused.
**What that ruling does NOT defer is the honesty.** A truncated replay presented as a whole game is
a wrong answer, not a missing feature, and saying "this stops at Stage N" needs no version stamp at
all — `fromSave` already knows how many intents it replayed of how many it was given
(`session.ts:454` reports exactly that for multiplayer). That half is small and stands alone. The
general rule now lives in `README.md` § Design notes; #40 is the "tell the players" half.
### Rules
#### #12 — PARTLY REPRODUCED: "when I back up to collect standing cars and, furth…
@@ -1685,9 +1749,20 @@ counts move with play balance, so a document that prints them is stale on the ne
same rule was applied to `content.ts`'s own comments on 2026-08-22 — see the pass recorded in
CHANGELOG for what came out and what was kept.
Not started. Worth deciding first whether this is a build step writing Markdown into `docs/`,
or a page on the site beside the replay viewer — the site can render it from the same view-model
the game uses, which argues for the second.
**BUILT 2026-09-07 in v0.7.9.2, completed in v0.7.9.3, as the first of the two options** — a build step writing Markdown,
`scripts/build-card-reference.ts` → `docs/rules/as-built.md`, with `test/card-reference.test.ts`
re-running the generator and failing when the checked-in file disagrees. All six sections are
there, and so is the honesty column: every Enhancement carries its `live` / `dormantSolo` /
`unbuilt` status, and the opponent-directed cards say plainly that none of them is dealt.
**The counts are omitted as ruled** — where a count matters it is rendered as a yes/no "is this
dealt at all", which is a fact about the design rather than about the current tuning.
**WHAT REMAINS, and it is the second option rather than a gap in the first:** a page on the site,
beside the replay viewer, rendered from the same view-model the game uses. The argument for it is
unchanged — a player cannot read a file in the repo — and it is now cheap, because the projection
work is done and only the presentation is missing. **Do it with Gitea#20 step 3**, which builds a
renderer for exactly this kind of read-only public page; building a second one first would be the
waste.
#### #86 — Real audio, as committed assets.
@@ -1719,37 +1794,29 @@ placeholder, not the finished sound. Sound therefore defaults to OFF.
- Keep the synthesised versions as the fallback for anything not sourced, so a missing file is
a quieter game rather than a broken one.
#### #88 — card-reference.md's industry table may still be stale beyond Grocer's…
#### #88 — CLOSED — card-reference.md's industry table is no longer anybody's source.
**`card-reference.md`'s industry table may still be stale beyond Grocer's Warehouse, the Oil
**CLOSED 2026-09-07 in v0.7.9.3, by removing the question rather than answering it.** This asked
Refinery and Freight House (corrected v0.5.0) — Mine Tipple, Produce Shed and Power Plant were
NOT re-verified.** The v0.5.0 pass corrected three rows (and the "Freight House is not a card"
claim across `card-reference.md`, `glossary.md`, `rules-v0.2.md` and `open-questions.md`) on
Jesse's explicit call. Checking `content.ts` while making that change turned up that
`mineTipple` and `powerPlant` are ALSO base 1 out/in + 1 Laborer in the engine — the same
uniform model as the three that were corrected — while `card-reference.md` still prints Mine
Tipple 3/3/4 and Power Plant 3/3/4, and the "Throughput — why these Laborer counts" section
right below the table is built entirely on those higher numbers. Flagged inline in
`card-reference.md` rather than silently rewritten — this needs the same kind of decision Jesse
made for the other three, not an assumption that the same correction applies, since raising or
lowering Laborer counts is also a balance question, not only a docs one.
whether `card-reference.md`'s Mine Tipple, Produce Shed and Power Plant rows were stale — it prints
3/3/4 for two of them while `content.ts` has every industry at base 1 out / 1 in / 1 Laborer — and
deliberately did NOT rewrite them, on the grounds that changing a Laborer count is a balance
decision rather than a documentation one. **That reasoning still stands and nothing was changed in
the engine.**
**v0.4.9e narrows it.** The Direction column for the Grocer's Warehouse and the Oil Refinery was
the wrong half of that v0.5.0 pass and has been put back to one-way each, from gameplay testing
and Jesse's confirmation. The *numbers* in those two rows are still the card reference's own
(1 Laborer, 3–4 track) and still unverified against the engine, so this entry stands as written
for all five industries — what changed is only that the two rows the v0.5.0 pass claimed to have
re-verified turn out to have been re-verified against a premise rather than against a card.
---
What changed is that `card-reference.md` is no longer where anyone looks. `docs/rules/as-built.md`
is generated from `content.ts` and carries the industry table the game actually runs; the old file
keeps its SUPERSEDED banner, now pointing forward, and its numbers are read as what the v0.4.5
placeholder said. **The balance question the entry was really guarding is #70** (the rolling stock
supply and the uniform 1/1/1 industry model, both marked provisional in `content.ts`) — that is
where it belongs, and it is still open.
## Done
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.2, from the queue
### Shipped through v0.7.9.3, 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.
@@ -1757,12 +1824,11 @@ 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.
longer needed." The jump is made, so the note is retired rather than committed. **No per-version
save-compatibility fact is tracked from here**, on Jesse's call 2026-09-07: a save replays
through the current rules, so an older one breaking is the design working rather than an event
to log each time. The general rule lives in `README.md` § Design notes; #40 is the same shape and
has been generalised to match.
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
+127 -44
View File
@@ -14,6 +14,11 @@ 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.
**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with
play balance, so a document that prints them is answering a question that will have a different
answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at
all — which is a fact about the design rather than about the current tuning.
---
## Trains
@@ -97,12 +102,12 @@ Upgrades are strictly sequential — no skipping a tier — so a Terminal needs
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 |
| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |
| --- | :---: | :---: | ---: | ---: | ---: | ---: |
| Whistle Post | — | — | 1 | 0 | 0 | 0 |
| Depot | yes | yes | 2 | 1 | 1 | 1 |
| Station | yes | yes | 3 | 2 | 2 | 2 |
| Terminal | yes | yes | 4 | 3 | 3 | 3 |
Whistle Posts are a fixed supply of 4 outside the deck, and Limits signs a
supply of 8.
@@ -117,14 +122,14 @@ build one end of a chain or the other, never both, which is what forces traffic
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 |
| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |
| --- | --- | --- | ---: | ---: | ---: | --- |
| Freight House | boxcar | both | 1 | 1 | 1 | Grocer's Warehouse |
| Mine Tipple | hopper | outbound | 1 | 0 | 1 | Power Plant |
| Refinery | tank | outbound | 1 | 0 | 1 | Power Plant |
| Power Plant | hopper, tank | inbound | 0 | 1 | 1 | Mine Tipple, Refinery |
| Packing Sheds | reefer | outbound | 1 | 0 | 1 | Grocer's Warehouse |
| Grocer's Warehouse | boxcar, reefer | inbound | 0 | 1 | 1 | Packing Sheds, Freight House |
---
@@ -134,25 +139,25 @@ Each sits beside a host and raises one of its capacities. `office` as a host mea
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 |
| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |
| --- | --- | ---: | ---: | ---: | ---: |
| Waiting area | any Passenger Facility | 1 | — | — | 1 |
| Restaurant | any Passenger Facility | 1 | — | — | 1 |
| Hotel | any Passenger Facility | 1 | — | — | 1 |
| Truck dock | Freight House, Packing Sheds, Grocer's Warehouse | — | 1 | — | — |
| Railroad Express Agency | Freight House | 1 | — | 1 | — |
| Forklifts | Freight House, Packing Sheds | 1 | — | 1 | — |
| Prep Plant | Mine Tipple | 1 | — | 1 | — |
| Coal Piles | Mine Tipple | 1 | — | 1 | — |
| Conveyor Belts | Mine Tipple | 1 | — | 1 | — |
| Pipelines | Refinery | 1 | — | 1 | — |
| Oil Depot | Refinery | 1 | — | 1 | — |
| Viscosity breakers | Refinery | 1 | — | 1 | — |
| Transmission lines | Power Plant | — | — | 1 | — |
| Rotary Dumps | Power Plant | — | — | 1 | — |
| Steam Turbines | Power Plant | — | — | 1 | — |
| Ice House | Packing Sheds, Grocer's Warehouse | 1 | — | 1 | — |
| Local small groceries | Grocer's Warehouse | — | — | 1 | — |
---
@@ -162,16 +167,94 @@ Track is **in the Home Office deck** and is drawn and played like any other card
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 |
| Track | Geometry | Hand | Operational rail | Move cost | Dealt |
| --- | --- | --- | :---: | ---: | :---: |
| Straight track | straight | none | yes | 1 | yes |
| Curved track (right) | curved | right | yes | 1 | yes |
| Curved track (left) | curved | left | yes | 1 | yes |
| Sharp Curved Track (right) | sharpCurved | right | yes | 2 | no |
| Sharp Curved Track (left) | sharpCurved | left | yes | 2 | no |
| Turnout (right) | turnout | right | — | 1 | yes |
| Turnout (left) | turnout | left | — | 1 | yes |
48 track cards are dealt in total. Rows showing no copies are shapes the engine
understands but the deck does not currently print.
A row marked "no" is a shape the engine understands but the deck does not currently print.
---
## Enhancements
The column that only the implementation can fill in: **whether the printed effect actually
resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack
but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a
solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription
cannot carry this column, which is the argument for generating the page rather than writing it.
| Enhancement | Placement | Requires | Effect resolves |
| --- | --- | --- | :---: |
| Interlocking | runningTrackStraight | — | **live** |
| Facing Point Locks | onCard | interlocking in the district | **dormantSolo** |
| Yard office | secondaryTrackStraight | — | **live** |
| Small yard | secondaryTrackStraight | — | **live** |
| Water column | runningTrackStraight | — | **dormantSolo** |
| Overpass | onCard | — | **unbuilt** |
| Telegraph | runningTrackStraight | — | **live** |
| Telephone | onCard | telegraph on the same card | **live** |
| Radio | onCard | telephone on the same card | **live** |
| ABS Signals | mainlineCard | — | **live** |
---
## Opponent-directed cards, and what answers them
**None of these is dealt in any deck today.** A card that can only be played at another player
has no legal target in a solitaire game, and a defence with nothing to defend against is as dead
a draw as the attack — so both halves are held out until the attacks are implemented. They are
listed because they are the design, and because what a defence answers is the only record of why
it exists.
### Action cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Derail | a moving train in the Local Phase | That train must stop for the remainder of the turn. | — |
| Broken coupler | a moving train in the Mainline Phase | That train must stop and not move. | — |
| Railroad crossing | any Secondary Track Straight | May not be used as a stop point for switching. May not become an Industry. | — |
| Per Diem inventory | another player | Lose one point per 2 empty cars on Secondary Tracks. | — |
| Demurrage charge | another player | Lose one point per 2 loaded freight cars on Secondary Tracks. | — |
| Customer complaints | another player | Lose one point per 2 coaches in loading boxes. | — |
| Vandalism | another player | A train passing a Hobo Jungle has a boxcar looted (converted to empty). | — |
| Hotbox | another player | A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs. | — |
| Outlawed | another player | A train just arrived may not depart for one turn — the crew’s hours have expired. | — |
### Space-use cards — opponent-directed
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Bean house | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Flop house | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Watertower | adjacent to any straight, turnout on Running Track | Burns tablespace. | — |
| Hobo Jungle | adjacent to any straight, turnout, Limit on Running Track | Burns tablespace. Vandalism can loot a boxcar passing it. | — |
| Section House | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| City blocks | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engine Shops | adjacent to any straight, curve, turnout | Burns tablespace. | — |
| Tenderloin District | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
| Engineer cemetery | adjacent to any straight, curve, turnout, Limit | Burns tablespace. | — |
### Maneuver cards
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Red Flags | any time | A stopped train is prevented from being hit; the approaching train is prevented from moving. | — |
| Flying Switch | any time | Break a cut of cars away from behind the engine and roll them into an industry. | — |
| Poling | any time | TBD in the source. | — |
Mainline modifier cards, for completeness — these ARE dealt:
| Card | Played on | Effect | Answers |
| --- | --- | --- | --- |
| Brakeman | a GRADE Mainline card | Faster passage downhill. | — |
| Airbrakes | a GRADE Mainline card | Faster passage downhill. Brakeman must be in effect. | — |
| Helpers | a GRADE Mainline card | Faster passage uphill. | — |
| Realignment | a Mainline card | Convert one Mainline type to another. Not while a train is on it. | — |
| Facing Point Locks | adjacent to Interlocking | Prevents Derail being played on you. | Derail |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.7.9.2",
"version": "0.7.9.3",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+74 -18
View File
@@ -30,11 +30,12 @@ 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,
ACTION_CARDS, ALL_TRAINS, ENHANCEMENT_CARDS, ENHANCEMENT_RULES, EXTRA_TRAINS, INDUSTRY_PROFILES,
LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS,
MODIFIER_PROFILES, OFFICE_PROFILES, SPACE_USE_CARDS, TIMETABLED_TRAINS, TRACK_CARDS,
WHISTLE_POST_SUPPLY, consistSize, isOpponentOnly, mainlineDescription,
} from '../src/engine/content.ts';
import type { ConsistSpec, TrainProfile, TrainRules } from '../src/engine/content.ts';
import type { ConsistSpec, SimpleCard, TrainProfile, TrainRules } from '../src/engine/content.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -88,6 +89,11 @@ 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('**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with');
w('play balance, so a document that prints them is answering a question that will have a different');
w('answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at');
w('all — which is a fact about the design rather than about the current tuning.');
w();
w('---');
w();
@@ -141,11 +147,11 @@ w('Upgrades are strictly sequential — no skipping a tier — so a Terminal nee
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('| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |');
w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |');
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 || '—'} |`);
`${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} |`);
}
w();
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
@@ -162,13 +168,13 @@ w('build one end of a chain or the other, never both, which is what forces traff
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('| --- | --- | --- | ---: | ---: | ---: | --- | ---: |');
w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |');
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(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
}
w();
w('---');
@@ -180,13 +186,13 @@ w('Each sits beside a host and raises one of its capacities. `office` as a host
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('| --- | --- | ---: | ---: | ---: | ---: | ---: |');
w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |');
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(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
}
w();
w('---');
@@ -198,14 +204,64 @@ w('Track is **in the Home Office deck** and is drawn and played like any other c
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('| --- | --- | --- | :---: | ---: | ---: |');
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
w('| --- | --- | --- | :---: | ---: | :---: |');
for (const t of TRACK_CARDS) {
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck || '—'} |`);
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck ? 'yes' : 'no'} |`);
}
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('A row marked "no" is a shape the engine understands but the deck does not currently print.');
w();
w('---');
w();
w('## Enhancements');
w();
w('The column that only the implementation can fill in: **whether the printed effect actually');
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
w('solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription');
w('cannot carry this column, which is the argument for generating the page rather than writing it.');
w();
w('| Enhancement | Placement | Requires | Effect resolves |');
w('| --- | --- | --- | :---: |');
for (const r of ENHANCEMENT_RULES) {
const card = ENHANCEMENT_CARDS.find((c) => c.key === r.key);
const needs = r.requiresOnSameCard
? `${r.requiresOnSameCard} on the same card`
: r.requiresInDistrict
? `${r.requiresInDistrict} in the district`
: '—';
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
}
w();
w('---');
w();
w('## Opponent-directed cards, and what answers them');
w();
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
w('listed because they are the design, and because what a defence answers is the only record of why');
w('it exists.');
w();
const pvp = (title: string, cards: readonly SimpleCard[], category: string): void => {
w(`### ${title}${isOpponentOnly(category) ? ' — opponent-directed' : ''}`);
w();
w('| Card | Played on | Effect | Answers |');
w('| --- | --- | --- | --- |');
for (const c of cards) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w();
};
pvp('Action cards', ACTION_CARDS, 'action');
pvp('Space-use cards', SPACE_USE_CARDS, 'spaceUse');
pvp('Maneuver cards', MANEUVER_CARDS, 'maneuver');
w('Mainline modifier cards, for completeness — these ARE dealt:');
w();
w('| Card | Played on | Effect | Answers |');
w('| --- | --- | --- | --- |');
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w();
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);