TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.
Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.
Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.
Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
Housekeeping before v0.8.0: the answer to "anything else that should be
looked at first". One real hole, one stale document, and my own leavings.
#102 — `npm test` passed green on a type error. `pretest` ran
`scripts/build-web.ts`, which invokes `tsc --ignoreConfig` against three
web entry points, so it saw only what those three transitively import and
under a WEAKER configuration than tsconfig.json — no
`noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`,
`--types ''`. It never saw `src/server/` or a single file under `test/`.
Demonstrated rather than argued: a planted
`const DELIBERATE_TYPE_ERROR: number = 'not a number';` in
src/server/session.ts gives `npm run typecheck` a TS2322 and `npm test` a
clean `# fail 0`. `pretest` is `tsc --noEmit && node
scripts/build-web.ts` now, and the same error exits 1 with the tests
never running.
This mattered THIS week rather than generally: v0.8.0 is steps 2-7 of the
common board — display stream, credentials, persistence, Chromium
supervisor — which is almost entirely src/server/, exactly the half the
test command could not see.
#103 — the plan had drifted from the code it is the source for.
docs/plans/jitsi-common-board.md was written 2026-08-27, still said "No
implementation has been performed", and is what steps 2-7 get built from.
Step 1 shipped across four releases since, so every "current code
finding" under it described a fault that is now fixed — a document
reading as present tense and nine days stale sends the next reader to fix
things twice.
Measured: its PublicFrame sketch lists four properties never built
(protocolVersion, config, scoring, deckCounts) and omits 28 that exist,
and the shape is the real difference — the implementation is FLAT where
the plan grouped things into objects, so a renderer written from the
sketch would not compile. The plan now says so at the top and at step 1,
names src/sim/view.ts and the redaction allow-list as the authority,
keeps the original sketch for its reasoning, and calls out
`protocolVersion` as unbuilt rather than dropping it quietly — step 2 is
the reconnecting display stream and is the first thing that would want
one.
One step-1 item is STRUCK OFF rather than built: "add the Red Flag holder
to the public player projection". The premise does not hold here.
`decks.redFlags` is written once, in setup.ts, from
`optionalRules.emergencyToolbox`, and never again — `redFlag.play` emits
`phaseEnded` and does not spend it — so every player holds one or none
does, decided before the deal. A per-player `redFlagHeld` would be one
already-public option copied N times, while telling every reader of the
common board that it varies by player and might change mid-game. Worse
than the absence. Pinned by test so it is not re-raised from the plan.
Four dead imports removed, all mine: `HAND_LIMIT` left unused in
apply.ts, view.ts and web/game.ts when 0.7.9.6 consolidated the three
copies of the §6.2 test, and `actingPlayer` in web/game.ts, dead since
0.7.9.5 made `currentActor` delegate. Finding them re-measured #46:
`tsc --noUnusedLocals` now reports 40, up from 29 on 2026-08-30. That
entry's "without the flag this list simply regrows" is a measurement
rather than a forecast now. The other 36 and the flag stay open.
946 tests pass, up from 943. No behaviour changes: three new tests pin an
invariant, and the rest is a build command, dead imports and a document.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
Found by looking rather than by being told. Gitea#21, #22, #94 and #96
were four instances of one fault in a row — the engine gains something
that changes what a train may do, and nothing draws it — and every one
was found by a player hitting it. So instead of waiting for the fifth,
every field of GameState and its nested types was enumerated, checked for
a reader in sim/view.ts, src/web/ and sim/narrate.ts, and the survivors
verified BY RUNNING THE ENGINE rather than by trusting the grep.
Four fields had no reader. `movedThisPhase` lives and dies inside one
`advance` call and is nobody's business. The other three are below. What
was ruled out matters as much: `freightWorked`, `drawnThisTurn`,
`freightAgentUsed`, `switchedSince` and `movesUsed` are invisible on
purpose, their effect already showing as legality or as a complement
already on the Frame. A field is not a display gap merely because nothing
renders it.
#98 — the Crew Tray pool. §7 scarcity is called an explicit mechanic and
was explicit only in the engine. The blocked panel had one tray rule,
keyed off the train due out this Stage, so a player who spent a card on
an Extra or ordered a second section got an EMPTY panel while their train
sat behind an exhausted pool — both having been announced once in the log
in a line promising a future event that nothing then confirmed. The
shared table carries the pool and the queue now, so the common board gets
it too, and the panel reports all three with the count beside them.
#99 — a train held at the Limits vanished off the board, and this one had
shipped. The Interlocking stops an inbound train on the Limit Track
rather than colliding with a full Office. `arriveAtOffice` removes the
tray from the Mainline node's `transits` and the Interlocking branch
pushes it onto `heldAtLimits` without assigning `tray.position` — and the
map draws mainline nodes from `transits` and squares from
`position.at === 'grid'`, so between the two it was drawn in NEITHER. It
disappeared on arrival and reappeared in the Office some Stages later.
Fixed in the view: the engine is right, and `position` is left alone
deliberately so nothing treats the train as standing somewhere it could
be switched from.
#100 — the Campaign Train's speeches change its rules, and the card said
the same thing before and after. Worse, the "EXPEDITED ... costs 1
Revenue" warning prints only under `rules.expedite`, so X17 became
subject to a fault whose warning the game shows to every other expedited
train and never to it. `trainRules` reads `speechMade` now and borrows
`isExpedited` from advance.ts rather than restating the test.
#45 — the 0.7.9 dead-field audit, finished, and the answer was different
for each. `overHandLimit` is WIRED: its consumer existed all along and
was inferring the hand limit from the ABSENCE of `draw.end` in the menu,
which is sound only while `check` keeps refusing for exactly three
reasons. `viewerSeat` is DOCUMENTED, with a condition — Gitea#20's board
keys districts by seat, and the note says to delete it if step 2 ships
without using it.
The audit had missed a third limb. `game.mustPlayCard` was assigned on
every submit and read by nothing: deleted. Chasing it turned up the thing
worth fixing — the §6.2 hand-limit test existed in THREE places, all
agreeing, which is the state #96's disagreement started from. One
`overHandLimit(state, player)` in state.ts now, and the other two ask it.
`Session.overHandLimit()` is deleted rather than kept: the Frame already
carries the fact, so the method was a second path to it.
934 tests pass, up from 917. The 17 new ones were written red, and each
fix checked by mutation: reverting `speechMade` fails 2, dropping the
held-train projection fails 4, forgetting the tray queues fails 2. The
empty blocked panel is reported beside its positive control, since an
empty result from a broken function proves nothing.
NOT VERIFIED AT A TABLE. Engine and view work, checked by tests and by
running the engine. #39 and #35 still stand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.
#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.
The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.
The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.
#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.
The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.
Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.
One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.
Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.
917 tests pass, up from 909.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
Both leaks were found while planning the common board (Gitea#20 step 1),
and both are live multiplayer bugs with or without that display, so they
are fixed now rather than with 0.8.0.
`game.log` is one shared list and `linesSince(seat)` slices it with no
per-seat filter, so every line reaches every player. It carried the SEED
in the opening line of each multiplayer game — the whole future of the
deal — and the NAME OF A CARD DRAWN BLIND from the face-down Home Office
deck. Solitaire deliberately keeps both: a one-seat table has nobody to
leak to, the seed is what a bug report quotes, and a player's own history
naming their own draw is the record. A Department slot is face up and
stays named. The drawer still learns their card through `justDrawn`,
which already goes to that seat alone.
Neither was found by a test. Every test in `redaction.test.ts` passes an
empty log, so the whole of narration has sat outside the redaction net
since the net was built. Both now have tests there; 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`
named `card-reference.md` as the file that carries what the cards say —
a file whose own banner says not to use its numbers, describing the
v0.4.5 deck where 3/4 is a Mail-Express with three coaches. Every file in
that directory is a deliberate historical record, so none of them is
rewritten. `as-built.md` is new and GENERATED from the same catalogues
the engine instantiates from, with a test that re-runs the generator and
fails when the checked-in file disagrees. A hand-written replacement
would have drifted the same way, for the same reason.
TODO.md: #32 closed — the playtest migration note did its job and the
jump is made; the durable fact it carried is kept. #78 retired in favour
of #91. The "play it at a table" section now records that 0.7.4-0.7.9
were test-run without change requests, and that more testing comes at the
end of the 0.7.9 series.
897 tests pass, up from 891.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
Stays in the unshipped v0.7.9. Prompted by Jesse asking the general
question after two v0.7.9 fixes turned out to be the same shape:
actingPlayer existed and the Frame threw it away, and collisionsToday /
collisionsTotal rode the Frame for three releases with nothing drawing
them. So what else is computed, serialised and sent to nobody?
THE AUDIT, done rather than guessed. Every one of Frame's 59 top-level
fields grepped for a read across the seven renderers, then the same for
Tally's 26 members. 55 of 59 are read. Four are not.
tally.unloadsBegun was visible rather than merely unused. §9.1 makes
loading and unloading the same shape — begun, then carried through —
and the results screen printed "Loads still in the pipeline" for one
side and nothing for the other, reporting half of a symmetric
mechanism. tally.cardsDiscarded was counted by the engine and listed
beside "Cards drawn" and "Cards played" without it, though Gitea#9 made
throwing a Timetabled train away a deliberate move — a player CHOICE
the game counted and never reported. Both are reported now.
viewerSeat and overHandLimit are deferred by Jesse. The second is the
fullest version of the shape: engine computes it, view.ts puts it on
the Frame, session.ts declares it on the Session interface AND
implements it twice, and the only caller in the repo is its own test.
Four layers of plumbing, no consumer. The decision when it comes is
delete-or-document, not a patch.
Fixing the two turned up a third thing: resultsHtml draws
tallyHtml(report?.tally ?? f.tally), and report is f.official, so a
finished game reports the tally frozen at the official ending rather
than the live one. The first attempt at a test overrode f.tally alone,
changed nothing on screen, and failed for a reason unrelated to the
fix.
A SHOUTED KEYWORD IS NOT A SENTENCE. `EXTRA X18 started…` attributed to
a player rendered as `Player Solitaire eXTRA X18 started…`, and the
same happened to TRAIN 1 MADE UP and COLLISION. `record` folds a
narration's opening word into the middle of a sentence and did it with
a flat charAt(0).toLowerCase(). It now folds only a sentence-cased word
— ^[A-Z][a-z], a capital followed by a lower-case letter — which also
leaves X22 Pee-Dee alone, where a naive uppercase test gets it wrong
because '2'.toUpperCase() is '2'. It had been filed under Play Balance,
where it has no business being, which is how it survived a session that
had ruled balance work out of scope.
A REPLAYED SAVE NOW NARRATES WHAT THE LIVE GAME NARRATED. fromSave's
loop called record(game, result.events) with no actor, so every
restored save, every undo (which rebuilds through fromSave) and the
replay viewer stripped the "Player X" prefix off every attributed line.
submit attributes and fromMultiplayerSave attributes; this was the one
path of three that did not. One argument, with actor already computed
on the line above.
Why it survived: nothing ever compared a fromSave-built log against a
LIVE-played one. The single log-comparing test compares undo's rebuilt
log against another fromSave-built log — and undo itself rebuilds
through fromSave — so the gap cancelled out on both sides. The suite
was green with the bug in and green with it out. The new test plays a
game, saves it, restores it and asserts the two logs are identical:
the missing direction, not a new requirement.
All three fixes were confirmed to go RED with the fix reverted before
being called done.
884 tests pass, seventeen new.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
minCombinedRevenue fell back to SOLO_CONFIG's constant — the floor for a
FIVE-Day game — whatever days said. configWith({ days: 1 }) asked a
one-Day game to clear 15, which a full five-Day game averages barely
half of; configWith({ days: 10 }) asked for that same 15. It derives
from the days it was given now.
Not a live fault: createLocalSession is the only caller and the page
always writes the floor itself, so no dealt game was ever wrong. Found
by a throwaway probe that passed only days — which is how the next
caller would reach for it. Unchanged at the default day count, since
SOLO_CONFIG's floor is this same formula at DEFAULT_DAYS.
Stays in the unshipped v0.7.9 per Jesse — no version bump for the next
several fixes. 873 tests pass, three new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
"Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a
player's area who has the yard office card get an extra ability… the game will
offer that player the option… They can of course still choose to have the train go
to the standard office."
It was implemented, in a stripped form missing all three conditions: a qualifying
train was TELEPORTED onto the Yard Office card. Nobody was asked, no route was
computed — so the card's own printed "that can reach the yard office in one move"
was unenforced — and because nothing was walked, nothing was ever met on the way.
All three now hold:
- OFFERED to whoever sits in the district, interrupting the Mainline Phase on the
turn the train arrives. Declining is an ordinary arrival onto an A/D track.
- REACHABILITY is the engine's own move walk. `exploreMoves` already means what
the card means — any distance without changing direction, finishing on
Operational Rail (§2.4, §A.1) — so using it is what makes code and card agree.
Reversing is a separate Move, so a yard that can only be reached by backing up
is correctly out of reach.
- CARS ON THE LEAD COLLIDE. The walk does not treat standing cars as obstacles;
it COUPLES them, because that is what a switching move does. An arriving train
is not switching, so what it would have coupled is what it is about to hit —
the same reading §8.3 already applies to the Running Track. `destination.couples`
is therefore the fouling signal, and it needed no new machinery.
Per Jesse's ruling (2026-08-29) the two failures his issue names are kept apart: no
route means no offer, with the history saying why ("make sure this is logged in
history — why can't move so user knows why they can't get to yard"); a route that
exists but is fouled IS offered, and taking it crashes. A silent absence is
indistinguishable from a broken feature, which is how the missing check survived.
THE SHARED REFACTOR THIS NEEDED. `pendingDecision` was one question asked of one
player — §8.1's clearance, always the Superintendent — and `currentActor` hardcoded
that. It is a discriminated union now, with `decisionActor` as the single place that
maps a question to whoever must answer it, and `clearanceRuling` generalised to
`decisionAnswer`. Six copies of `pendingDecision !== null ? superintendent :
currentActor` across the engine, the sim, the web client and the tests collapse into
`actingPlayer`; they had already stopped being right the moment a second kind of
question existed. Gitea#19 needs the same machinery and now only has to add a case.
A BUG THE FIRST CUT WALKED INTO, worth recording because it is a trap the next
interruption will meet too: the offer must be put BEFORE the train is taken off its
Mainline card. `needsClearance` unwinds the whole phase and the driver re-enters
from the top, so asking after the `transits` filter cost the train its place on the
card and the answer had nowhere to land. §8.1 gets this right by asking before it
commits, and the Yard Office now does the same.
The developer bot declines: the Yard Office frees an A/D track, but the lead may be
fouled and the bot cannot read its own yard well enough to tell (`TODO.md`, Bot
Performance). Declining is always safe and keeps the harness comparable with every
measurement taken before this rule existed.
851 tests pass.
Closes#5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
Two issues off the tracker, and they are halves of one thing: the end of a game.
Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and
these are not fixes people mid-playtest need.
EXTENDED PLAY (#11). The official result is settled at the original game length
and never changes: in a five-Day game extended to eight, the winner is whoever
led at the end of Day 5. Extending grants exactly one Day and the question is put
again at the end of it — solitaire the player decides alone, multiplayer it is
unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4
collision breach is final, during an extended Day exactly as during the scheduled
game.
It could not be a client-side change. `check` refused every intent once `status`
left `active`; the server never loads a `finished` game back into memory; and a
save is `{ seed, config, history }` replayed through the engine, so a "continue"
the history does not record did not happen. Hence a fourth status,
`awaitingExtension`, and a `game.extend` intent. `config.days` never moves —
`extraDays` counts the borrowed Days and `official` freezes the outcome, the
standings and the statistics at the first ending.
THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an
internal enum interpolated into the page at the one moment the game has the
player's whole attention. Every reason now has a sentence with the game's own
numbers in it. Around it: the result and winner, standings, the rules the game
was dealt under, a per-player breakdown, and the railroad — trains through the
Division and how many worked en route, loads made up and broken, passengers, cars
switched, trains destroyed. It shares the Day-end dialog's blocks rather than
reimplementing them, and stays reopenable so continuing does not cost you the
results.
Statistics are folded, not recorded: `state.tally` counts what the event stream
says happened, hooked at `applyIntent` and `advance` because `reduce` never sees
the phase driver's events — and those are the interesting ones. Nothing in the
rules reads it, and it rides the Frame, so multiplayer gets the same numbers as
solitaire from one implementation.
THREE BUGS FOUND IN TESTING, all of which would have shipped:
- a saved game containing a vote could not be resumed (NO_ACTOR). A history is
a flat Intent[] with no seat recorded; the replay derives who acted from the
turn order, which cannot work for an intent every seat may send in any order.
`game.extend` carries its voter, checked against the authenticated seat.
- an all-bot game hung on the question for ever. `driveBots` loops on
`currentActor`, null the moment the game stops, so it cannot cast a vote, and
the bot-vote driver returned early with no humans to follow.
- the balance harness became unbounded — `test/sim.test.ts` went from under a
second to never finishing. `randomBot` took another Day about half the time,
so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver,
not in a policy, so it holds for bots not yet written.
All three have regression tests. 832 tests pass, against 793 before this change.
NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted
per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not:
reading advance.ts, it fires once per game and only for a train whose profile
sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a
second. The streak was built, rendered "1 Stage at (0,0)", and was taken out
again. There is no per-Stage "this train did not move" signal in the engine, so
"longest an engine sat on a siding" needs one first; TODO.md #36 records what it
would take, and the Circus set-up is reported instead. Badges remain the second
pass #16 asks for (TODO.md #33), and because the statistics are derived rather
than recorded, that pass can add any of them retroactively to games already
played and saved.
Extended play has not yet been played at a real table (TODO.md #35): the
multiplayer vote has only been driven through `session.intent`, never through two
browsers.
Closes#11Closes#16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never
been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md.
- Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block —
they had drifted, and each was missing a question the other asked.
- A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and
keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is
in, not just the last one.
- The start of a game is drawn: a handoff beat, an announcement, the code and type in the header.
- Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client;
justDrawn goes to the seat that drew it and nobody else.
- Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board
never named the Superintendent, bot seats were reported as absent players, and rule section
numbers are out of every string a player reads.
Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
asks what game you want
Three queued items. The last matters most.
A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS.
Four consecutive releases killed every game on the box, one of them a
release that changed only how the board is drawn. The reasoning behind the
refusal was always right — a move legal under old rules may not be legal
under new ones, and half-replaying a save is worse than refusing it. The
TEST was wrong: it compared engineVersion for exact equality, and that
stamp is the package version, which moves for a CSS fix.
Whether a save still replays has an exact answer, so it is now asked
directly. loadGame reads the file and judges nothing; tryResumeSession
replays the intents and reports the first one the engine refuses. A save
stamped with a version this server has never run resumes fine provided its
moves replay — verified against a file hand-stamped 0.4.9-ancient. One that
genuinely does not replay is still refused, but the log names the move
rather than two version strings: "move 3 of 8 (localOps.choose) is rejected
by the current rules with OPTION_ALREADY_CHOSEN".
fromMultiplayerSave had to stop lying first. It has always stopped at the
first unacceptable intent and done so in silence, which was survivable only
because the version gate meant a doomed replay was never attempted. Now
that the replay IS the check, it returns where it stopped and why.
Deliberately not done: resuming a partly-replayable game at its last good
move. That silently rewinds a game to a position nobody played to while
every browser holding a later Frame carries on unaware. Refusing leaves the
file intact, so putting the previous version back still recovers it.
EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED.
Two of the four optional-rule flags were read by nothing at all. Employee
Rotation is four lines in advance.ts, because the seat/player split (D9)
exists for precisely this rule: seating is the only thing that moves, so
Revenue, hands, the Superintendent and whose turn it is travel with the
player, and the Office, district, grid and any trains standing in it stay
with the chair. Inheriting the district you move into is the point of the
rule, not a side effect. "Left" is seat + 1, matching playerLeftOf.
Sister Trains is deleted rather than built: Q9 records that the Second
Section card supersedes it, and that card exists, so the flag was a toggle
for a rule the game no longer has.
THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY.
Creating a game asked for a name, a mode and a table size; every other dial
was hardcoded. A Game settings block now carries the same set the solitaire
dialog does — seed, starting hand, the three revenue rates, Days, the
combined-Revenue floor, both collision caps, the opponent-card toggle —
plus the three surviving optional rules. Mode and table size set the
defaults and everything stays editable. The seed is honoured, so a game can
be reproduced or compared.
Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked
both ways — disabling the rotation and turning the table the wrong way each
fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected
illegal move was refused by name, and a create with every dial set to a
non-default value came back out of game.json with all of them intact,
including seed 777.
Two of my own assertions were wrong on the way and the tests caught them:
the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared
against its own earlier value, and dispatchUsedToday is cleared at every
Day boundary so it cannot mark a district.
A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.
The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.
Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.
Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.
Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.
Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.
Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.
tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary).
This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per
docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed
cards, StartOS packaging) are still ahead.
Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing
Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling
submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add
POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/.
src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found
and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server
computing every connected seat's Menu would have handed the acting player's legal moves to a
waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a
rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and
test/redaction.test.ts. Not verified: an actual browser (none available in this environment).
Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then-
rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing
it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment
there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified
live: server killed and restarted mid-game, both seats reconnected exactly where they left off.
Two rules bugs found while building this: the New Train phase never implemented its car-placement
round (every car of every train was placed by the Superintendent alone, in every mode, all along —
now reads the round position off tray.consist.length); and victory conditions are now one shared,
configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and
a dead firstToTarget condition.
Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching
train's crew badge failing to draw once it left the Office square, an unload that always took the
westmost car regardless of which was picked, and a legal decision that could render with zero
buttons.
docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json)
included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated
side-project work, not part of this release. 635 tests, 0 failures.
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the
first real audio beyond a placeholder.
- Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order.
Display-only.
- "No switching" now means may not add or drop cars, not "never touch it" — these trains can
still be moved onto Secondary Track to clear the mainline.
- Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The
forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left
off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never
use" as a side effect.
- evaluateClearance now checks every occupant on a Mainline card before offering a judgment
call, instead of returning on whichever it found first — found while explaining a playtest
report, fixed with a regression test.
- Three new synthesised sounds: arrive, depart, crash.
- The splash page shows the box art.
Full detail, measurements and reasoning in CHANGELOG.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
Builds docs/plans/switching-paths.md. A passing loop can offer two legal
routes between the same two squares, coupling different cars — the engine
only ever found one, an artifact of search order (Reported by Jesse, undo
379). exploreMoves now enumerates every simple route (per-path visited set,
capped at 4000 frontier nodes) and dedupes on outcome — destination, entry
side, and origin-tagged cars — rather than on reaching the square at all.
switch.move gains an optional `via: GridCoord` naming one intermediate
square on the chosen route; absent, it resolves exactly as before, so
every existing save and bot decision replays identically (575/575, then
579/579 with the new tests). Threaded through the label, the action-list
dedupe, the hover highlight (data-route), and the history (trayMoved.via).
Ruling recorded as Gap 14 in docs/rules/open-questions.md: the player may
choose the path; §A.4's "may not go around" a car does not reach a
different track the player declined to enter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.
Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.
Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4