v0.4.1 - bug fixes. initial d12 rolls determine what player in which seat.

This commit is contained in:
Jesse
2026-08-13 15:28:53 -04:00
parent 49f8504b05
commit a7221dcf20
22 changed files with 736 additions and 237 deletions
+118
View File
@@ -21,6 +21,124 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.1 — 2026-08-13
### §4.4's opening D12 now decides who sits where
It had been rolled and thrown away — `void divisionRolls`, with seating fixed by array order — so the
rule decided nothing, and `seating` was the identity mapping in every game ever played. `seating[seat]
= player` now runs ascending by roll from seat 0 (west, beside the Western Division Point) to the last
seat: "highest is the Eastern Division Point".
The rule names only those two ends, because at a table the players are already sitting in a chain and
the roll only says which way round it is. There is no physical table here, so the roll orders
everybody — it uses a number every player is already told to roll, and it makes the roll matter to
more than the winner. Ties break toward the lower player index sitting further east, the same
first-max-wins convention `argmax` already uses for the Superintendent roll.
**Turning it on immediately found three more of last commit's bug class.** Acting order, the opening
deal and the Fedora all did `(player + n) % players`. Every one of them is a statement about the
physical chain — "starting from the Superintendent and proceeding left" (Gap 1, §4.7, §5) — so every
one of them is seat arithmetic, and all three were right only while seating was the identity mapping.
They now go through a new `playerLeftOf(state, player, n)`. This is the payoff of the split being
exercised by real games rather than only by tests that rotate `seating` by hand: eight of these were
found by inspection last commit, and three more fell out of simply making the rule work.
`state.openingRolls` keeps both D12s, indexed by player, so a lobby can show the chain forming rather
than only its result (`lobby-and-sessions.md` §4). `Frame.players` gained `seat` for the same reason —
the list is in player order because it is about people, and a client that wants to draw the table
west-to-east now can.
**Solitaire is untouched, and the proof is that it had better be:** one player is one seat, so the
permutation is trivially `[0]`. All four published replays finish on their recorded Revenue (27, 31,
29, 28) and the bot is unmoved at 7.0 mean over 200 games.
Nine multiplayer tests failed on the change and every one of them was the test being wrong — each had
encoded the identity mapping, which is exactly what made them pass before. `readyToLeave` was putting
a *player* index into a tray's `seat` field; the Subdivision tests upgraded "player 1's Office" when a
Subdivision is a stretch of the physical chain; the Fedora test recorded player indices where §5 is
about seats. Two new tests replace the one that asserted the identity mapping outright: seating is a
permutation (and is still `[0]` in solitaire), and over 40 seeds the highest roller is easternmost with
the chain ordered throughout.
### The intents are canonical; the event log narrates
The README, four architecture documents and six source comments all claimed `state = fold(events)`.
It was never true, and it was load-bearing — the stated justification for reconnection, restart
recovery and persistence, none of which were built yet, so nothing had ever tested the claim.
Measured before deciding: **`advance.ts` never calls `reduce`.** Fourteen of the forty-six event types
are emitted after the phase driver has already mutated state — the clock, and the whole Mainline
phase. Folding the log rebuilds a district and not a railroad; every train movement in the game is
missing.
**Settled the cheap way, because the plan never needed fold.** Persistence is
`{ engineVersion, seed, config, history: Intent[] }` (`multiplayer.md` §10), the wire carries `Frame`s
rather than events (D2/D3), and reconnection is a fresh `Frame` rather than an event tail — so making
the phase driver reduce would have been a rewrite of the most rule-dense code in the project to buy
something nothing uses. `lobby-and-sessions.md` §6 previously said "persist the event log"; it now
persists the intents, which are smaller still and which `fromSave` already replays.
`test/events.test.ts` pins the unreduced set as a deliberate change-detector: shrink it and the test
tells you which documents now understate the engine; grow it and you have added another event on the
mutate-then-describe path. It also asserts the property that *does* hold — that replaying the intents
reproduces the board, the score, the clock and the crews exactly — and that `Save` still carries
nothing but a seed and a history.
### `lobby-and-sessions.md` reviewed, and four decisions taken
The persistence rewrite above left the document internally consistent but unreviewed — it was written
before `multiplayer.md` and contradicted it in four places, and the code in two more.
*Contradictions with the newer plan:* it had **no access control at all** ("a game code is the whole
discovery mechanism") where D14 added a server-wide join secret; its timeout table used the fictional
`Switch.End`-style names and **omitted `freightAgent.end`**, so a player who chose Freight Agent never
timed out; it opened "one actor at a time", which D19 stopped being true; and "do not substitute an AI
player" contradicted D8's bots-at-lobby-time.
*Contradictions with the code:* §4 described the opening D12 for the Eastern Division Point as
happening, when `setup.ts:266` rolls it and does `void divisionRolls` — seating is array order, so the
rule decides nothing. `PlayerDisconnected` was written as an event; there is no such type, and it
should stay out of `GameEvent`, which must remain replayable from a seed.
**Four decisions:**
- **The session token names the PLAYER, not the seat.** `multiplayer.md` §10 said seat; that became
wrong in v0.4.0, and Employee Rotation is exactly the case where it bites — a token naming a chair
seats a returning player in someone else's Office. Both documents now agree, and say the seat is
`seatOf(state, player)`.
- **2 to 4 players**, enforced in the lobby because the engine enforces nothing. Set to what is
actually exercised rather than to the previous guess of 6. Noted as a lobby judgment, not a rules
limit — the rules describe 5+ and the engine implements it.
- **No forcing turn timer.** Cut, on the same objection that keeps bots out of running games: the
clearance decision changes somebody else's score, so anything answering it automatically changes
the game. Moved to `TODO.md` to explore only if halted games prove to be a real problem, keeping
the one piece of reasoning worth saving — that **deny** is the safe default, since a held train
costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **A turn clock that records rather than enforces.** Wall-clock per player per phase, so "how long
does a 4-player game take, and which phase is the wait?" becomes measured instead of guessed — the
one measurement bot simulation cannot produce, because the bot does not think. Explicitly outside
the rules engine (which has no clock and must not acquire one) and outside the canonical record
(a replay must reproduce a game from decisions alone). Phase 3, item 16.
- Plus: **host rights pass to the earliest-joined remaining player** if the host leaves before start,
so no lobby is stuck behind a closed tab.
### Documentation reconciled with the code
`docs/design.md`'s status section was four milestones stale: a 115-card deck (it is 213 in play, from
a 235-card catalogue), 134 tests (497), a 0% win rate and 0.9 Revenue/Day (7.0 mean, 1.4/Day, 5 wins
in 200), and "Next: step 7 begins the server" when Phase 2 is deliberately held. Rewritten as the
shape of the project rather than a running tally, pointing at the CHANGELOG and `TODO.md` for
anything that moves — a hand-maintained tally is exactly what drifted.
The README also said the rules were **fully specified**, which overstates it: three questions are
genuinely open — where the Local's coach stands while its engine works (a §A.4 question rather than a
train-card one), Poling, the one card in the deck with no defined behaviour, and whether a Heavy
Grade's orientation is rolled or chosen at setup. Thirteen prototype gaps were closed; these three
came after, and the bullet now says so and names them.
## 0.4.0 — 2026-08-13
### Phases 0 and 1 of the multiplayer plan — the seams, not the server