Step 1 of the common board done as its own release rather than as the
first hour of 0.8.0, since both halves of it are worth having whether or
not anything is ever published to a call.
#95 — the public projection helpers. `projectDistrict(state, seat)`,
`projectDivision(state)`, `projectSharedTable(state)`,
`publicSnapshot(state)` and `currentActorOfState(state)`, with
`snapshot()` REBUILT to compose from the same helpers rather than keeping
a second copy of the shared table, so a player's frame and a spectator's
cannot come to disagree about the clock, the phase, whose turn it is or
the score. Behaviour-neutral; the 897 existing tests passing unchanged is
the proof.
The public view is composed UPWARD, never by calling `snapshot()` once
per seat. That shortcut is the trap the plan names: `snapshot` assembles
one player's view, so a public view made of player views builds every
private field and then has to remember to strip it — and it defaults its
viewer to player zero, so a careless spectator call would have served
seat 0's hand. Districts are keyed by SEAT with the player resolved
through `playerAtSeat`, because Employee Rotation moves players between
districts and a board that treated seat and player index as
interchangeable would relabel every district the first time anybody
rotated.
One plan finding is struck off rather than fixed: it warns a display
reading `clock.currentActor` could highlight the wrong district during a
decision. Measured over six seeds and 3,600 decision points, that field
and `actingPlayer` never disagreed. `currentActorOfState` exists anyway,
as one place for the next reader to ask.
#91 — the redaction net, systematically. v0.7.9.2's two leaks were found
by reading a plan, not by a test, which is the whole argument for this: a
suite made of the leaks somebody happened to notice proves nothing about
the next one. Serialise a seat's Frame, the PublicFrame a spectator gets
and the narration they receive, then search all three for every opponent
card id, every card name unique to one opponent's hand, the seed and any
private decision or menu data — across a fresh game, a blind draw,
mid-game, a pending decision, Employee Rotation before and after the
seating moves, a reconnect push (a full Frame, and its own opportunity to
leak) and a played-out game. And the allow-list, which is the plan's
stated acceptance bar rather than the tests: every property of
`publicSnapshot` is written down with its reason and compared on every
run, so adding a field fails the suite until somebody has said out loud
that a spectator may see it. Both v0.7.9.2 leaks were fields nobody had
ever asked that question about.
Proved by mutation rather than by passing: restoring the seed line fails
6 tests, restoring the blind-draw card name fails 1, adding a private
field to the public projection fails 7, and making `players[]` carry hand
contents instead of a count fails 5.
Two false failures were worth the lesson. A card NAME is a type, not an
identity — "right-hand curve" names a dozen cards and one is legitimately
a cell label the moment anybody lays track, so searching for it fails on
correct code, which is worse than not searching; a name is evidence only
when every card bearing it is in the one hand. And a one-digit seed makes
the seed check meaningless: seed 7 matched "Train 7". One item on the
plan's list has no test because it has no referent — there is no secret
objective in this game, `objectiveOf` deriving from
`config.minCombinedRevenue` and the player's own Revenue, both public.
#94 — a Red Flag standing at an Office's Limits is on the map. It is a
token set out ON the board that holds the next train arriving from that
side, and it was announced once in the log and drawn nowhere, so a train
stops short three Stages later with its only explanation scrolled out of
the panel. `DivisionView`'s office node carries `redFlag` and the map
draws a staff and pennant AT THE END IT GUARDS — west on the left, east
on the right — because which approach it covers is the whole of the
information; a flag in the middle of the cell would say one is out and
leave the reader to hover for the half that decides whether to run a
train. The tooltip leads with it, ahead of everything that merely
describes the cell.
The third of these in a row after Gitea#21 and #22: when the engine gains
something that changes what a train may do, the question to ask is where
it is drawn, not whether it works.
909 tests pass, up from 897.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ss2y7FyhxkHjGj7xnUPCgY
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
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
Gitea#22: the Division map drew every westbound train in the wrong half of
its Mainline card. `regionOfTransit` counts from the end a train entered,
which is what the collision rules ask; the map wanted "which printed box,
left to right" and used the same number, so an eastbound train came out
right by luck and a westbound one came out mirrored. It cost a collision —
Train 3 was cleared to follow T5 and ran into TX17, which the picture had
drawn ahead of T5 rather than behind it. One mirror in `view.ts`, at the
boundary the map is drawn from; the collision rules are untouched.
Gitea#21: a second tank car would not come off at a refinery, and "Blocked
— why nothing is moving" answered by describing the refinery's green box.
The real answer was Train 3's printed rule — the Express works one freight
car per location — so the refusal was correct and the panel sent the player
to spend a Freight Agent action that could not have helped. No rule
changed. The panel now names the budget, asking the reducer's own
predicate so its words cannot drift from the rule.
Both were replayed from the saves attached to the issues and verified in
the exact position each report names. The map fix is proved by mutation:
reverting the mirror fails two tests, and making the renderer ignore the
region fails a third. Every existing region test ran eastbound, where the
mirror is the identity, which is why the bug survived them.
891 tests pass, up from 884.
Closes#21Closes#22
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
The file had reached 2,441 lines and had to be read end to end to find
out what was live. Restructured with Jesse.
WHAT WAS WRONG was not the ordering. Eight subject sections sat under a
43-entry chronological queue that had come to duplicate them: the queue
was where the priority lived, the sections were where the reasoning
lived, so every live item existed in both and drifted between them.
That is what let eight items be closed in one place and left open in
the other last week, which reads as live work and is worse than no
entry at all. One item per place now.
THE SHAPE. A short overview; then "Every time", the process this
project runs on — the version bump being Jesse's call, signed commits
and tags, deploying from the right line, the wrapper release sequence,
and the lesson that only a test failing before the fix proves a fix;
then an index; then ten work sections in priority order, one per area,
each holding every open item for that area. Play it at a table first,
because the file itself calls it the largest gap in the project.
Gitea#20 second, as v0.8.0. Then multiplayer operations, the screen,
replays, rules, balance, the bot, code health, documentation.
THE ARGUMENT MOVED TO THE BACK, NOT AWAY. An item in a work section is
a few lines: what it is, what it blocks, what it would cost. The
measurements, rulings and rejected approaches behind it are in a
Reference section keyed by the same number, moved verbatim. All 61 open
items have one. Then Done, which keeps every closed item because
several are the only record of a ruling or a lesson.
Checked mechanically rather than trusted: every lead and every body
line over 40 characters from all 120 items of the old file was
asserted present in the new one. Zero fragments lost. Two rendering
bugs the move introduced were caught the same way — bodies that were
list continuations indented 4 or 6 spaces render as CODE BLOCKS once
they are no longer inside a list, and a wrapped lead splits mid
sentence if a blank line is inserted before its continuation.
THE NUMBERS ARE PERMANENT IDS. Items keep the number they were raised
under for life, wherever they later move, because commit messages,
Gitea comments and other items refer to them by number. Nothing was
renumbered; previously unnumbered items took the next free numbers.
Items that existed only as queue entries — 32, 33, 35, 36, 39, 40, 42a
— are proper items with checkboxes for the first time, which is why the
open count reads 61 against last week's 54 without anything new being
raised.
Also carries the two items Jesse asked for while finishing 0.7.9. #44:
how much history the panel holds should be configurable, with his three
candidates left deliberately open — a StartOS action reaches only
multiplayer, since solitaire has no server; per game puts a display
preference into the ruleset and every save; per browser is where every
other display preference already lives. The cap has no recorded reason
anywhere, and the replay viewer already answers the same question in a
different unit. #46: 29 unused declarations and no flag to catch them,
with the argument that the flag matters more than the 29.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
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
Reviewed with Jesse out of TODO.md's Display section. Stays in the
unshipped v0.7.9.
#17 (hiding the Division map) was DECLINED, and the reason is that its
premise had already died. Gitea#18 replaced the wrapped layout with a
single row, and the reason to fold the map away was that it GREW — a
horseshoe of three or a square of four pushed the board off the screen.
One row is boardH = PAD * 2 + CH + 30: 150px, fixed, at every seat
count. That is not worth a control, three states and a persisted
preference. It was a sixth member of the drawing pass that got closed
with Gitea#18 and stayed open only because it reads as a control
question rather than a drawing one — recorded in TODO.md as an explicit
decision, with the design that had already been worked out kept, and
with the one thing that would justify reopening it: the map growing
again.
#16 THE OFFICE AREA'S AUTO-HIDE COULD NOT REACH EVERY STATE. One button
cycling auto -> pinned -> auto, where the pin was `open ? 'closed' :
'open'` and `open` is what auto is doing AT THAT MOMENT. So the pin a
press offered depended on the phase, and going from always-show to
always-hide meant clicking back to auto, waiting for the phase to turn
over, and clicking again. Three controls now, one per mode. The labels
still say what pressing DOES, which was an earlier deliberate fix; what
the cycle could not do was report the state it was in, and aria-pressed
carries that now.
They are addressed by id rather than queried off the container, and
that is testability rather than style: the stub DOM the web suite runs
against only models markup the page WROTE, so a child query finds
nothing and the control would have shipped green and unexercised. The
test presses always-show to always-hide directly — the transition the
cycle could not make.
#23 THE HISTORY READS NEWEST FIRST. Jesse: "the top line is the most
recent and the further down you go, the older the entry." The phase
headings now trail the lines they announce, ruled acceptable rather
than overlooked: "stage changes will be beneath (prior to / older than)
the following events. That is OK." Reading down is reading backwards.
Grouping by phase and reversing the groups was offered and declined as
more machinery than the complaint needs. replays.ts keeps its
oldest-first log deliberately — it is paired with a frame stepper,
where newest-first would fight the stepping. The slice(-60) cap is
untouched and stays open.
#28 THE SETTINGS MOVED INTO A CARD. The top line carried six things and
now carries four: Revenue, the objective, the collision counts and the
game code. The rest is a This Game card at the foot of the right-hand
column, folded by default. Nothing new travels for it — configFromFrame
already existed and main.ts already called it three times, so
rulesListHtml(configFromFrame(f), ...) needed no refactor, and the card
draws from the same renderer as the lobby's join preview so the two
cannot drift.
THE COLLISION COUNTS ARE NEW ON THE BOARD, NOT MOVED. The Frame has
carried collisionsToday and collisionsTotal since v0.7.0 and nothing
drew them, so the one victory condition that ends a game EARLY ran
invisibly — the second time this release that the Frame had the answer
and the view never asked (see #43's actingPlayer). They stay on the top
line while the limits go in the card: a limit is agreed to once, "2 of
3 today" changes how you play the next Stage.
One stub gap closed to get here: none of the five element factories in
test/web.test.ts had setAttribute, so the first render threw and any
control reporting state through ARIA was untestable.
WHAT IS NOT VERIFIED: the layout. There is no browser on this box, so
nothing has confirmed the segmented control, the card or the reversed
panel look right on screen. The logic is tested; the appearance is not,
and wants the next play session.
881 tests pass, fourteen new.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
Nothing was wrong with the ordering. What had gone wrong is that eight
items were done or superseded in one place and still open in another,
which reads as live work — worse than no entry at all. Found by
reviewing the file against the Gitea tracker and the git log, not by
reading it.
NEXT WAS 43 CHRONOLOGICAL ENTRIES, 26 OF THEM STRUCK THROUGH, and the
live work had stopped being visible in it. It is grouped by what each
item is WAITING ON now — blocked on a table, the screen, waiting on
something outside the code, unscheduled, standing practice — with the
closed entries moved to a Settled block at the end of the section.
EVERY NUMBER IS KEPT AS A STABLE ID. The rest of the file refers to
items by number ("decide with item 15", "same drawing pass as 19-21"),
so renumbering would have silently broken every cross-reference.
Numbers are ids, not positions, and the file now says so. The one
reference that pointed at a settled item (item 22, the dead centre of
the Division map) is rewritten to name the principle that outlived it
rather than the item.
EIGHT STALE ITEMS CLOSED. The Heavy Grade orientation was fixed in
cf018b4 and never checked off. The Fedora is TODO #29, done in this
same unshipped v0.7.9. The remaining six were the Division-map drawing
pass, all answered by Gitea#18 (closed 2026-08-26) replacing the layout
rather than fixing the drawing — 167 lines describing a wrapped map
that no longer exists, collapsed to 32 that keep the two ideas which
outlived their items: "SHARED in the middle, YOURS on the right", now
Gitea#20's territory, and why rotating the map to seat a viewer at the
bottom was refused (it moves the open gap between the buffer stops out
of the eye's path, and that gap is the only thing saying the Division
is a line and not a loop).
65 open items to 57; 2,400 lines to 2,179.
TWO PIECES OF DEAD CODE GITEA#18 LEFT BEHIND, found while confirming
the map really is one row before closing the items that say it is.
SIDE_GAP was declared and never read. And divisionSvg's header comment
still described "one row alone, two rows facing, a horseshoe of three,
a square of four" — contradicting the layout comment 200 lines below
it, which explains why that layout was dropped. A comment that
survives the code it describes is how the next reader gets it wrong.
No behaviour change. 878 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
Reported by Jesse, 2026-08-30: "when you are continuing the saved game
out of that screen, do not post a message that says 'The game has
begun.' … it needs to say 'The game has resumed.'"
A restored game draws exactly like a dealt one — mid-Day, mid-phase,
with a log already several turns deep — and solitaire said nothing at
all to tell the two apart. It flashes "The game has resumed — Day 3,
Stage 7" now, on both ways back in: the setup screen's Continue saved
game, and a bare reload that restores the save.
THE SAME LINE WAS WRONG ON THE MULTIPLAYER SIDE, IN THE OTHER
DIRECTION. noteFirstFrame guards on firstFrameSeen, which is per
page-load — so re-entering a game this browser already held a seat in,
by reloading mid-game or picking it out of the lobby's list, announced
that the game had BEGUN to somebody who had been playing it for an
hour. beginRemote carries whether this is a rejoin now, and the line
reads "resumed" when it is.
Both halves are pinned, including that a brand-new game does not claim
to be a resume — an announcement that fires either way says nothing.
Stays in the unshipped v0.7.9. 878 tests pass, eleven new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
- The save warning is a warning: 15px, weight 500, bright amber on a
deeper ground with a 5px rule down the side. (What Jesse was looking
at is v0.7.8, where this line is still the small grey .ng-note —
none of 0.7.9 has been deployed.)
- The buttons read the same on both screens: "Continue saved game" and
"Create new game". Solitaire said "Continue Existing Saved Game" and
"Deal New Game", the lobby said "Create game" — three phrasings for
two actions.
- "Off in every game type" is deleted from the Optional rules note
because it was not true. Checked against the presets rather than
taken on trust: discardTimetabled (§6.2, a Timetabled train may be
discarded) ships ON in all four types, not just Co-op. The note now
says only what holds for all of them.
Stays in the unshipped v0.7.9. 877 tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Three items folded into the unshipped v0.7.9.
WAITING ON (reported by Jesse from play). The status line said "nobody —
the Division is running itself" while the game was stopped on the
Superintendent. Frame.actor carried clock.currentActor, which is null
for the whole Mainline Phase, so all three interruptions — §8.1's
clearance ruling, Gitea#5's Yard Office offer, Gitea#19's Red Flag
prompt — reported that nobody was holding it up. actingPlayer had the
answer since the Gitea#5 refactor; the Frame threw it away. It carries
actingPlayer now, plus a new `awaiting` field naming the question and
the train: "waiting on Bob · a clearance ruling · Train 4". Naming the
person alone is not enough when three different things can be pending.
THE FEDORA (TODO #29) rides at the right-hand end of the phase row
instead of a line of its own, and wraps under rather than squeezing the
chips.
THE DEVELOPER REPLAY (TODO #34) printed "loss — revenueFloor", the same
defect Gitea#16 was filed about, still alive because nothing
player-facing pointed at it. panels.ts's reasonSentence is exported and
shared rather than reimplemented, fed the last recorded frame and
stripped of markup. The drift test maps win/loss to won/lost so it still
checks the two AGREE rather than that they are spelled alike.
Also carries the previous, unsigned commit's work: the two setup screens
worded the same section by section.
877 tests pass, ten new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Jesse: "it should not go to a separate screen. We should reuse the
Solitaire New Game Screen… in general we should reuse what we already
have."
#newgamedlg was a third copy of the same questions and the one that
drifted: shown only to a solitaire player, it asked "Everyone loses if
COMBINED Revenue…" and explained Employee Rotation in full multiplayer
terms beside a control it had disabled. Both were on the list to
re-word; deleting the screen removes the drift instead of restating it.
New game opens the setup screen IN PLACE rather than navigating, so the
live session stays in memory: the fields open on the rules actually
being played (what the dialog was good for), and Continue Existing Saved
Game puts the board back with no reload. render() calls save() every
frame, so nothing is at risk either way.
The two remaining screens now match below their headers — same three
parameters in the same order, same seed note, same chair note. Solitaire
shows Players at the table locked at 1 rather than omitting it: a fixed
control says "same form, table of one", a missing one made it a
different form sharing a rules block.
Drift guard drops to two prefixes and now fails if any ng- id returns.
Stays in the unshipped v0.7.9. 874 tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Jesse: "heavy grade mainline card tooltip states climbs east, but card
doesn't show it." The Frame has carried gradeUp all along and the tip
has said it; the card drew nothing, so the one Mainline card whose
orientation is set per game was the one you had to hover to read.
A brown wedge in the lower right rising toward the climb, with a bone
arrow lying along its slope, centred on the triangle's centroid. East is
right on this map (Gitea#18), which is what lets a wedge be read without
a compass.
Four passes. Up the hypotenuse the arrow began at the wedge's thin
corner, where there is no height, so its head read as clipped. Level, it
was contained but did not read as climbing. At 30° — steeper than the
wedge's own 22.5° — it had to be tucked into the fat half. Parallel to
the slope is the shape that fits: the gap to the hypotenuse is then
constant, so the arrow can sit centred. The wedge grew to 58x24 to pay
for it, since a centred arrow has less room than an off-centre one.
Sizes are a search result, clearing every edge by 2.88px.
The first containment test bounded the arrow against the CARD, which it
never left, while the wedge clipped it — a green check on a visibly
broken glyph. It checks the TRIANGLE now with a 2px floor, plus
parallelism derived from the wedge and centroid placement.
Orientation is always set, measured not assumed: 400 seeds x 4 player
counts, 535 grades placed, 0 without one, 276 east / 259 west.
Stays in the unshipped v0.7.9. 874 tests pass, one new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
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
Jesse, playing v0.7.8: "Solitaire game ended. I did not have an option
to extend the game by a day."
The engine and the Frame were right — checked before changing anything.
A solitaire game at the end of its timetable reaches awaitingExtension
with extensionVotes [null], and renderEnding writes "play one more Day"
into #actions. It then opens #resultsdlg, which is MODAL, so those
buttons were directly underneath a dialog whose only control was Close.
The results dialog now carries the question itself, hidden unless a vote
is pending: Play One More Day / End the Game Here, casting the same
game.extend intent. The #actions buttons stay as the fallback once it is
closed.
TODO.md #35 recorded extended play as verified on phoenix.local — over
the HTTP API, which renders no dialog. What was proven was that the
server supports it, not that a player can reach it. Noted there.
Rides along in the unshipped v0.7.9. 870 tests pass, one new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Four pieces of feedback from Jesse on the solitaire setup screen.
THE COLLISION LIMITS DID NOTHING IN SOLITAIRE. Asked to reword those
entries to "the game ends immediately and results in a loss", which was
unwriteable: advance.ts gated the §3.4 check on competitive/coop, and a
solitaire game's mode is 'solitaire'. Both limits were offered as live
settings, rode into the config, and never fired — the existing text was
already false. The exclusion was never a stated rule and nothing
recorded a reason for it. Jesse's ruling: the settings do what they say,
so the gate is gone rather than the controls.
Measured, not asserted — 200 standard developer-bot games:
loss/collisionFloor 1 in 200, Days played 5.00 -> 4.98 mean with a
minimum of 1, collisions per game unchanged at 0.14. Recorded in
TODO.md under Play Balance, since full-length figures predate it.
Extra start defaults to ownOffice: at one seat it is the same rule as
anyOffice (apply.ts only rejects another seat's start), so this is a
label fix with no gameplay effect.
Also: collision wording on all three screens, Employee Rotation reads
"not applicable for solitaire", and the save warning is legible at 14px
on an amber panel with buttons that say Continue Existing Saved Game and
Deal New Game.
869 tests pass, two new; one asserted the opposite of the ruling and
says so where it was reversed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Third report of the same symptom, this time with the build confirmed
current on screen, which ruled out v0.7.7's caching fault and left the
real cause exposed.
v0.7.5 skipped the setup screen whenever load() found a save, reasoned
as "a saved game is a game to resume". A browser that has ever played
solitaire always has one, so the door could never reach the screen
again — and the fresh private window that appeared to vindicate v0.7.7
simply had no save. Two real faults were stacked; the caching one is
fixed and had been masking this.
The door outranks a saved game now: ?solitaire is a request to set one
up, while a bare reload still resumes (pinned by its own test). Since
Deal clears the save, the screen carries #ss-resume and says what Deal
costs, so the door cannot destroy a game in progress.
Also, per Jesse, riding along rather than taking its own release: the
splash footer now names both ways to play.
868 tests pass, four new. The reproduction was a failing test written
before the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
buildStamp()'s no-git fallback was the literal "nogit", and the .s9pk
Dockerfile copies the tree in without .git — so git rev-parse fails on
every packaged build. That string is also the cache-bust key every
module URL carries, so v0.7.4, v0.7.5 and v0.7.6 all published
./web/main.js?v=nogit, byte-identical, and returning browsers refetched
nothing. v0.7.5's setup screen and v0.7.6's door fix were both correct
and neither arrived.
The fallback is now the package version plus the build timestamp, always
distinct. And serveStatic sent no Cache-Control at all, which is the
other half — a cached play.html pins a player to the whole build it
names. A request carrying ?v= is now immutable for a year; everything
else is no-cache. ?v= rather than "not HTML" because build-web.ts tags
the modules and nothing else.
Neither half is sufficient alone.
864 tests pass, two new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Found by Jesse playing v0.7.5 on phoenix.local: a browser that had ever
held a multiplayer seat could not reach the new solitaire setup screen
at all. start() checked a browser-remembered multiplayer session before
ever looking at solitaire's own state, and a bare ./play.html load could
not tell "clicked Play solitaire" apart from "reloaded mid multiplayer
game" — the same problem ?lobby already solved for the door on the
other side, never applied to this one.
The door now links to ./play.html?solitaire, and start() treats that,
an explicit ?seed=, or the setup screen's own ?hand= (written by every
Deal) as proof this navigation means solitaire — checked ahead of the
remembered-session lookup rather than only below it.
862 tests pass, three new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
A new #solitairesetup screen in play.html asks the full shared game-options
block — type, starting hand, Extra start, revenue, victory conditions,
optional rules — before a genuinely fresh visit deals a game. A saved game,
an explicit ?seed=, or a URL a Deal already wrote all skip past it, same as
?lobby already skips the front doors on an invite link.
The in-game dialog, the lobby and this screen now share one
wireGameTypeBlock()/commitNewGame() pair instead of the dialog carrying its
own copy of the questions.
859 tests pass. Not yet played in a browser.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
Records the close of the 2026-08-29 session.
#38, done: Gitea#13, #5 and #19 in v0.7.4, wrapper 085b88b as 0.7.4:0, installed on
phoenix.local. Each issue carries a comment naming its commit and what was ruled,
per the standing instruction in #30 — auto-closing alone leaves no record of which
release answered a report.
#37 extended: the wrapper went 0.7.3 then 0.7.4 the same day, and the sequence is
written down rather than rediscovered next time. Both tags signed and pushed.
Three things the session leaves behind, and the first is the one that matters:
#39 — NOTHING FROM v0.7.4 HAS BEEN PLAYED BY A HUMAN, and nor has extended play
from v0.7.3 (#35). Four features shipped without a table between them. Two of them
are interruptions that stop the Mainline Phase and put a question in front of
somebody mid-thought, which is exactly what only play reveals.
#40 — a save from before v0.7.4 may not replay, and nobody has been told. Same
shape as #32 for the playtest line. It fails safe and WHISTLE-4086 did survive on
phoenix, so "may not" rather than "will not".
#41 — the bot still cannot use the half of Red Flags a human would: planting a
flag on purpose to buy a Stage for switching. It takes the danger prompt
unconditionally and still plays zero in 200 games.
Also de-duplicates #35, where an earlier edit left the superseded paragraph appended
to the rewritten one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
"If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your
limits from that direction (i.e. Flag East holds westbound trains). You can do this
if you see a problem or wish to complete switching."
REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a
stopped train out on the Mainline and protected it from a rear-ender: offered 4,212
times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already
does that job better. The flag is now planted on one side of your own district and
holds the next train arriving from that side.
SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to
build, nothing to forget, and a flag cannot quietly strangle the Division. The held
train loses one Mainline Phase and comes in on the next — it buys a Stage to clear
the lead, which is what "wish to complete switching" asks for.
PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would
certainly collide and the district's owner holds the card, the phase breaks in and
asks. Offered ONLY to somebody holding one — a prompt with a single button is not a
choice, and it would leak that a collision is coming. The danger is read from §8.3's
own two triggers rather than restated, so the prompt cannot offer a flag against a
collision that will not happen.
Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and
nothing else structural.
THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the
out-of-phase prompt unconditionally — the engine has already established the danger,
so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires
ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one
game in seven) to coincide with holding the card from a three-card hand out of 121.
So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the
bot is unwilling: it is measuring deck luck. What is left to fix is the half of the
card a human would use, planting a flag on purpose to buy switching time, and TODO.md
now says that instead of the old finding.
A BUG WORTH RECORDING, because the next interruption will meet it too: the flag was
originally taken down in a `reduce` case, which never fires for an event advance.ts
emits — the phase driver mutates state and then describes it. The flag stayed up and
held every train that came. test/events.test.ts's unreduced-event registry is what
makes that class of mistake visible, and `redFlagSpent` is on it deliberately now,
with the reasoning.
858 tests pass.
Closes#19
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
"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
"I've redefined some of the extra trains that they have to run full boxcars —
military trains, circus trains, etc. If not loaded, then empty, and if none
available, run without."
MAKE-UP. X17 Campaign, X18 Circus and X19 Military carry `mustRunLoaded`. It is a
preference order rather than a flat requirement, so the rule is asked of the
DIVISION YARD: an empty is refused only while the yard can still supply a loaded
car this train would accept, and once it cannot, the empty is legal and the train
may still depart short. Per category, since that is the slot the car competes for
— a loaded coach is no reason to refuse an empty boxcar.
SCORING, per Jesse's ruling (2026-08-29): "once per stop in an office area. In a
multiplayer game, each player could score if the circus stops in their area." So
`stopPointClaimed` (a boolean, once per game) becomes `stopPointSeats` (the seats
already paid). A Circus touring three districts is paid three times; one parked in
the same district all game is paid once. The other half of the ruling — "if the
circus train gets recycled and played a second time as a second extra, then it
could again score points later too" — needs no code: a train is made up onto a
fresh tray every time, so a re-played Extra starts with an empty list.
The point now requires the train to be FULLY LOADED, meaning every non-caboose car
loaded. A coach counts as loaded when occupied, which is what makes this the right
test for the Campaign Train: X17 carries one coach and no freight, so "fully
loaded" is exactly "the candidate is aboard". X17 also GAINS the per-stop point —
it had `stopThenExpedite` and no scoring rule at all, and Jesse's "credit for a
circus or campaign train (one point per stop)" says it should score.
TWO BUGS FIXED ALONG THE WAY:
- `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, RENDERED to the player
as "(empties only)" by both web/game.ts and sim/view.ts, and enforced by
nothing — `acceptsCar` never read it, so the Appleseed could be made up loaded
while its own card said otherwise. It is the same rule as this issue pointing
the other way, and it would have been perverse to add one and leave the other.
- Setting up out on the Mainline paid a point to PLAYER 0 whoever was playing:
`playerAtSeat` needs a seat, off the grid there is none, and the fallback was
`0`. Scoping the rule to Office Areas is what the ruling says and removes the
misattribution rather than patching it.
Both loading rules exempt the caboose: every caboose in ROLLING_STOCK_SUPPLY is
minted loaded, so an unexempted rule would bar the one car a consist lists by name.
`trainNeedingCars` now asks the full question per car rather than the shape
question. It shares its predicate with `check` precisely to avoid the stall its own
comment describes, and the shape question stopped being the same question: an
empties-only train facing a yard of loaded cars would have been told a car was
available and then refused every one.
The two published replays that stopped replaying under the new rules were retired
and re-recorded with save-replay.ts, which verifies each candidate before writing
it. That is what test/harness.test.ts is for and what its comment prescribes.
846 tests pass.
Closes#13
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
v0.7.3:0 is installed. #35 keeps its heading — nobody has played this at a table
with humans — but the engine and server half is no longer merely compiled.
Dealt a two-seat competitive game over the HTTP API with days: 1, played it to the
end of its timetable, and watched it reach `awaitingExtension` with the official
result frozen at config.days. Voted yes; the bot followed; the game returned to
active with extraDays: 1 and the official outcome unchanged. Both test games were
deleted afterwards.
The carry-over claim was checked the same way rather than asserted. phoenix held
five saves before the update, one resumable; after it the resume log is identical
— same game, same 7 intents, same three refusals at the same move with the same
code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
Three updates from the 2026-08-29 session, none of them code.
#37, new and already done: the StartOS wrapper is bumped to 0.7.3 (`74aea24` in
station-master-startos). Recorded with what was verified — check, prettier, pack —
and what was not: it is not installed on a box and has not been played, which is
#35 and still open.
#13 is settled, and as the reading that costs more. Jesse: "I want to be able to
watch other players and bots make their moves. It's not fun to do my turn and have
magic happen in the background and then have to figure out what others did." The
entry had explicitly left open which of two things was meant — a log-legibility
problem or a new view — and it is the second. It is Gitea#20 step 4 pointed at a
player's screen rather than the common board, and `docs/plans/jitsi-common-board.md`
already has the mechanism; what it does not have is the seated-player half, which
that plan deliberately excludes. Jesse: "this relates to issue #20 and will require
a lot more thinking." Marked to be designed with #20, not started alone.
#7 is on hold: StartOS 0.4.0.2 is expected to improve action displays, and the
diagnosis behind that item is that the action-result view collapses newlines —
exactly the sort of thing a platform release fixes. Re-read the real output before
designing around a limitation that may have been lifted.
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
Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut
through a curve's south leg no longer couples it back to front. The same
assumption left a crew's own cut standing when it pulled out through a leg,
which is the "cars left behind" report we had failed to reproduce.
Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12
office doubling and Gap 12 industry tripling both come out with it: they were
measured against a deck with twice the track, and keeping them at the sheet's
track count wipes out the reefer chain entirely. 84 rows now match card for
card; the ten Safety, Event and Inspection cards it adds are not built and are
held out. Cards the sheet no longer lists are dealt zero copies rather than
deleted, so their rules stay implemented.
Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the
placement is legal. What must hold is that no train crosses the gap, which was
already true and is now pinned against the reported board.
Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed
region and where a train STARTS is what varies; Fast/Slow is read on Hilly
alone. Entering a one-region card behind another is a collision now, which is
what ABS exists to prevent, and ABS no longer holds trains silently.
Gitea#18 — the Division draws as one row, west to east, with no office-area
detail. East is finally always to the right.
Closes#3Closes#14Closes#15Closes#17Closes#18
Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for
every item, and what was verified how: CHANGELOG.md.
- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it
out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded,
white is empty" doubles as a piece count in the supply table; one read of the flag took that
literally. A caboose carries the crew, not freight, so it is never a load.
- Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click
and the next — and both transient signals fade before a player reading the board notices. A modal
stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the
first frame, on Undo stepping back across a rollover, and on the Day the game ends.
- Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up
to a Department slot, where a rival may pick it up — the second half of the ruling needed no code,
since that is where every discard already goes. An Extra still may not. A New Game setting on this
line (discardTimetabled, on by default), the plain rule on the 0.4.9 line.
- Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is
Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A
blocked platform now gives its reason, from the engine's own predicate, including how many coaches
are stranded in Classification and what brings them back.
The same four ship as v0.4.9g on the playtest line.
Closes#2Closes#8Closes#9Closes#10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
Checked while answering whether v0.7.0 needed porting to the playtest line: almost none of it does,
but §A.4's coach ruling from v0.5.0 is a code difference that line never received — `!atOffice` on
the coachStaysOnStationTrack check. Jesse's call is not to port it mid-playtest; written down so the
divergence is a decision rather than a surprise, and so that line's README is not "corrected" into
describing behaviour its build does not have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
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
The deploy script only. No rules change, no game change, and the built site is byte-for-byte what
0.6.2 produced — this repairs the path that publishes it.
Found trying to publish the 0.4.9f playtest build: every deploy died with "login failed: 404 404
page not found". The File Browser host has been upgraded to FileBrowser Quantum, a fork whose API
differs from the v2.63 one deploy-web.ts was written against. Three things moved at once, each
fatal on its own: auth is a session COOKIE rather than a JWT sent back as X-Auth, so a deploy that
ignored it would authenticate and then be rejected by every upload; the password is an X-Password
header, URL-encoded, rather than a JSON body field; and the path is a query parameter, with every
resource call also having to name a `source` — Quantum can serve several named stores and refuses
any call that does not say which, a concept the v2.63 API did not have.
Rewritten against the running instance's own bundle rather than guessed, the same discipline the
v2.63 version was written with. Two things worth knowing next time: the bundle is gzip-compressed,
so it needs gunzip before it can be grepped; and an endpoint that exists answers a bad password
with 401 while a missing one answers 404, which is how each path was confirmed against the live
host without holding a password.
The source is discovered from GET /api/settings/sources — one configured source is used silently,
several makes the script stop and list them rather than deploy into the wrong store. FB_SOURCE
overrides it, FB_OTP carries a two-factor code.
Verified by deploying with it rather than by reading: 0.4.9f went up this way. This is that same
file byte-identical, brought across to the main line — both branches carried the same broken
script, so deploying 0.6.x would have failed identically.
Also removes dist-test/, an untracked hand-made copy of a v0.6.2 dist/ build that no script or test
references. build-web.ts hardcodes dist and wipes it on every run, so nothing in the repo could
have produced that directory or would ever read it. .gitignore is deliberately unchanged: the
answer for a directory that should not exist is to delete it, not to hide it.
715 tests pass, tsc clean, site builds.
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found
underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in
TODO.md under Play Balance.
GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered,
chosen by number parity. The number no longer decides an Extra's direction — the start does, which
supersedes the recorded ruling that "the number decides, like everything else on the timetable".
The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division
on its first move having crossed nothing, and be paid for the run. Either end now runs the train
away from itself; at an Interchange or a Control Point the player picks the direction. The
Interchange start is a YARD, off the running line, which is what makes the Superintendent clause
work: placing it can never force a collision, a guaranteed one holds it there for another Stage,
and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and
`ask`, so nothing new decides collisions. Where an Extra may start is now a house rule
(divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The
legacy `atSeat` intent field still replays as it always meant.
FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested
position alone, so the Control Point start has been shipping since it was added with a train that
could never be given a consist. Found by playing it, not by the tests, which had only asserted
where the tray landed.
FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit
entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision
permanently poisoned that Mainline card for every later train.
THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a
Division could hold two Interchanges and Plains carried the weight of a card printed once. "An
Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds
at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This
re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are
retired too — two of those were already dead before this release and nobody had noticed.
GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no
mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the
turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot
needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The
player is told on the card and on the button.
GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards,
so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts
and the Home Deck reference carry what the game plays.
CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an
office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never
existed. Every Enhancement row cited its implementation by line number and every citation had
rotted; they name functions now. Card counts came out of the comments, since they move with play
balance; source-sheet figures and dated measurements stayed.
TODO.md gains an item for a card reference generated from content.ts, in six sections, so the
documentation cannot disagree with the game.
715 tests pass, tsc clean, site builds.
Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.
TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.
A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.
THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.
NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.
Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.
The same change ships as v0.4.9e on the 0.4.9 line, branched from the v0.4.9d commit — the engine
files these fixes touch are identical across the two lines, so the patch applied cleanly both ways.
Also carries the two "Queued 2026-08-22, from playing on StartOS" TODO items that were staged
before this work started (Games in Progress readability, and getting back into a game after
losing a browser). They are notes, and items 9-12 below them are numbered against them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
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.
Three things off the first proper look at a live table.
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right inside —
it indexes seating, the seats array and every route, and none of that
changes — but nobody sitting at a table calls their chair "seat 0". There
were four of these rather than one: the lobby list, the topline's Seat N
for a remote session, the presence banner's fallback name, and the admin
summary's. All go through a single seatLabel now, and a test fails the
build if any "Seat ${...}" interpolates a raw seat again, since the
conversion has to happen in exactly one place or the two conventions drift
apart. Verified by mutation — putting the raw seat back fails the suite.
seatLabel lives in sim/view.ts, not web/game.ts. Putting it in game.ts was
the first attempt and test/session.test.ts caught it: the page may not
import values from that module, because they are the local engine by
another name and importing one reopens the Phase 1 boundary. The test was
right and the placement was wrong.
.bs-name.bs-turn carried font-weight:700 over a base of 600. At 11px a
monospace face has to be synthesised the rest of the way and the extra ink
lands as blur, so the one name you most need to read was the one you could
not. The bump is gone; amber against #e6e9ee was always doing the work, and
blue "(you)" and amber "their move" stay clearly distinct without it.
The west-to-east chain under the Division map now shows only during Day 1
Stage 1. It answers who is where and why, which is a question you have once
— at the start, when the chain has just been rolled and the names are new.
By Stage 2 the map has been answering it for a while and the line is
something to read past.
675 tests pass (673 + 2).
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.
Three things lined up. start() enters a remembered session WITHOUT checking
it still exists — that is what makes reconnection seamless, and it is why
the lobby was skipped. The v0.5.4 update had refused to resume that game,
its save being recorded under v0.5.3 and the engine-version check being
exact (D7). And createRemoteSession had no onerror at all, so EventSource
retried the resulting 404 forever in silence while frame stayed null and
nothing rendered. The only escape was clearing site data, and nothing on
screen said so.
v0.5.3's Manage Game -> End had just widened the same dead end: it closes
every watcher's stream, so a player whose game an administrator ended would
sit frozen on a stale board indefinitely, for exactly the same reason.
GET /api/session?token= is new: a cheap yes/no on whether a token still
names a live game. EventSource fires error identically for a transient blip
— the expected shape of a game idle for minutes (§9) — and for a 404 it
will retry forever, and exposes no status code either way, so the client
asks rather than guessing. Only a definite 404 closes the stream and
reports the game gone; a flaky network still self-heals.
The page then forgets the stored session, says why (ended by an
administrator, or the service was updated, which does not carry games
across), and drops into the lobby. Forgetting the token is what stops the
next load repeating it. It also stops rendering nothing while it waits —
"… connecting to the game" sits in the presence banner until the first push
arrives, because a page showing nothing is indistinguishable from a broken
one, which is what this looked like.
Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every
game in progress, and v0.5.4's changes were rendering only. The refusal is
right, but the test is exact equality against the PACKAGE version, which
moves for reasons unrelated to the rules. Three options costed; the
recommendation is to replay the save and refuse only if an intent actually
rejects — the real question rather than a proxy for it, and a full replay
measures ~100 ms.
Verified live: /api/session answers 200 for a seated token, 404 once an
administrator ends the game, 404 for a garbage token, and /api/stream 404s
in the same state — which is the response EventSource had been retrying
silently. 673 tests pass.
Six things found playing the StartOS build, all of them the game telling
you what it already knew.
The lobby's Start button did not look disabled when it was. The reported
symptom was "it says it's waiting for a player but Start is enabled" — it
wasn't: the note and the disabled assignment are two lines apart in the
same block. The page had only `header button:disabled` and `#actions
button:disabled`, and #lb-start is in neither, so a disabled button kept
its normal face AND still lit up under the cursor from the generic
button:hover. It advertised a click it would refuse. The rule is generic
now.
The game code was rendered as "— code TRESTLE-5109" in dim text beside a
heading, reading like a reference number rather than the thing you have to
send somebody. It is a labelled block at 22px with a Copy button, and a
clipboard refusal says the code can be selected instead of failing
silently. The blurb under it was also WRONG — it claimed the chairs were
"in the order everyone joined", which stopped being true in v0.4.1 when the
§4.4 D12 started deciding. It now says what actually happens.
Every Office on the Division map was labelled with its tier, which every
other player's Office also has, so four districts read identically and
"where does Bob sit" had no answer on the one map showing where trains are.
The owner's name takes the headline and the tier moves beside the A/D
count. Amber marks whose move it is — the same "happening here" the action
panel uses — and "(you)" is spelled out on the reader's own district,
because colour alone cannot say which of four railroads is yours. Turn
colour wins over the you-colour when both apply: whose turn it is changes
every few seconds, which railroad is yours never does.
Under the map, the chain in words with the roll behind it: "West to East:
Alice (1) → Bot 2 (5) → Bot 1 (11)". state.openingRolls has been kept for
exactly this since v0.4.1 and nothing had displayed it. It also answers
"is the host always at the eastern end" outright — no. Alice there is the
host, rolled lowest, and sits at the western end.
Supporting: Frame gained viewer and viewerSeat. Every private field on it
was already scoped to one player, but nothing said which player, so a page
could draw a railroad without being able to say whose it was — harmless in
solitaire, the first question at four seats. Frame also gained
openingRolls. Bots are Bot 1 / Bot 2 rather than all Bot, since two of them
are two different railroads. The standalone replay gets all of it: players,
actor and viewer are not delta'd keys in compress, so they ride whole on
every frame and replay.ts passes the same roster.
Verified: 673 tests pass (668 + 5). The new ones were mutation-checked —
removing the (you) suffix, never applying the turn mark, and reinstating
the pre-v0.4.1 identity seating each fail the suite. The seating test
deliberately asserts across six seeds that the eastern end is NOT always
player 0, which is the claim it exists to defend.
Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.
The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.
That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.
And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.
/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.
Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.
SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.
index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.
Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.
Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
The "Play multiplayer" door on index.html had sat disabled, labelled
"Coming soon", since before the server existed — Phases 2 through 4 built
a working lobby and nothing ever linked to it. Loading the site landed on
the same solitaire splash whether a real multiplayer server was behind it
or not, with no visible way in. Found packaging Phase 6 for StartOS.
The door is now a live link to ./play.html?lobby, and main.ts's start()
routes ?lobby straight to the lobby screen — the same showScreen('lobby');
runLobby(beginRemote) the in-game Multiplayer button already used —
instead of dealing a solitaire game first.
GET /api/health is new, and exists to be failed. The same dist/ ships both
served by src/server/ and uploaded as flat files by deploy-web.ts, and the
bundle is identical either way (D4), so the page cannot know from its own
build which it is; every other route 404s an unknown path exactly as a
static host does, so nothing distinguished them. The splash probes it on
load and closes the door when nothing names itself in reply.
The door starts open and only ever closes, deliberately: a wrong "no
server" is the bug above again — invisible, and it strands a player who
does have one — while a wrong "there is one" costs a click and a lobby
that says it cannot connect. The reply must name itself rather than merely
return 200, or a host answering every path with its index page would pass.
Verified: tsc clean; 659 tests pass (656 + 3); /api/health exercised live
against a running server — 200 with the right body, unauthenticated, while
an unknown path and a wrong method both still 404, which is what makes the
probe discriminate at all.
The probe's own test was vacuous on the first attempt — both its "closes"
cases reached close() through the .catch arm, so deleting the body-naming
check outright still passed. Caught by mutating splash.ts and re-running;
the test now covers all three closing routes and fails without the check.
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.
Patched directly onto 0.4.9a rather than the in-progress 0.5.0 line.
A switching train vanished from the board the moment it left the Office. `selectedTrain` in
`officeSvg` (board-svg.ts), which gates whether a card draws a train's crew badge, was only ever
assigned inside the `cell.adTracks !== null` branch — true for the Office card alone. A train
standing anywhere else, which is everywhere it stands while actually being switched, drew no badge
at all. Game state was never affected; confirmed against the reported save with the engine directly.
Fix hoists the assignment out of the guard so it runs for any card with a train on it.
Unloading always took the westmost car, whichever one was picked. `laborer.beginUnload` carried the
player's chosen `carIndex`, and `check()` validated that specific car, but the `unloadBegan` event
it produced carried only the car's type — the reducer that performs the swap re-derived the target
with `industryTrack.cars.findIndex(c => c.loaded)`, which always answers the first loaded car in
track order regardless of what was requested. Fix adds `carIndex` to the event and uses it directly.
A legal decision could render with zero buttons, which looked exactly like a hang. The "where does
this Extra start" decision is titled by `trainCardTitle`, beginning "Making up Extra X22…" — the
same prefix `renderActions()` stripped from the action list on the assumption it only ever belonged
to the separate yard-chip car-placement panel. With no tray yet being filled, that panel is null, so
nothing rendered the Extra's decision either: a legal, correctly-computed option with no button
anywhere on the page. Replaying the reported save found no engine deadlock at any step — the engine
always had a move. Fix matches the exact title of the one group the yard-chip panel covers instead
of a title-prefix regex.
New tests for all three: a train parked on an ordinary facility card must draw its crew badge; three
differently-typed loaded cars, unloading index 2, must leave indices 0 and 1 alone; the existing
softlock regression test now mirrors the real render filter instead of only the raw menu, plus a
deterministic test for the exact reported scenario. 590 tests, 0 failures.
Splash page only: the box art thumbnail opens a full-size lightbox on click, a
new "Play multiplayer" (coming soon) card balances the bottom row to three
cards, and the footer's "runs entirely in your browser" claim is now scoped
to solitaire.
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 "The Roster Pass" / "Two Trains, One Card" (station display for
multiple trains at one Office). The Office is the one square where
more than one train may legally stand at once (one per A/D track), and
CellView.train had room for exactly one — a second train at a busy
Station was counted in the old A/D pips and never drawn.
CellView.train -> CellView.trains: TrainView[], seat-filtered and
collecting every match rather than the first (fixes a latent
cross-district leak in the process: trainOnCard never checked seat).
The A/D pips are replaced with one roster chip per A/D track, always,
free or occupied; clicking a chip sets selectedCrew, wiring the board
and the action panel to the same value.
standingWest moves from CrewTray to TrackCard: two trays sharing one
Office card need one shared split, not one each, and there is no such
thing as "west of one particular A/D track". No save migration — Save
replays through the engine — and a stale value on an emptied card is
inert because nothing reads a split with no train standing there.
The Division map's Office cell is now sized by A/D capacity rather
than occupancy, so it holds still as trains arrive and leave; chips
lay into fixed slots instead of centre-spreading onto the Limits cards
either side.
584 tests, 0 failures. Verified end-to-end against the built app and a
direct render of a 4-train Terminal (screenshotted).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
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
Planning only; no source files touched. The BFS visited set in exploreMoves
keys on (coord, entry), so two legal routes that rejoin through the same port
collapse to whichever is shorter — silently discarding the option that couples
cars on the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
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
Adds SVG board rendering shared by the game and the replay, a splash page with a
shareable replay directory, hover tooltips for reference detail, and makes curves
two-port arcs so sidings and run-arounds can finally be built.