16 Commits
Author SHA1 Message Date
Jesse.MarkowitzandClaude Opus 5 dc31555625 v0.8.0.16 — the guide a tester can reach, and a flag that named the card instead of the gate
v0.8.0.15 wrote a Quickstart for a tester who has never played and then left it
in docs/, where a tester does not look — reachable only by somebody who already
has the repository. Nobody handed the box had it. build-web.ts now copies it to
dist/quickstart.md and the splash page offers it under the three doors, as a
line rather than a fourth door: reading the guide is not a way to play, and
giving it equal weight in that grid would say it is.

COPIED, NEVER RE-WRITTEN. The Markdown document stays the one copy. A
hand-written HTML twin drifts from it on the first edit, which is the failure
#15a was raised about and precisely what the v0.8.0.15 pass spent itself
undoing. It is served as PLAIN TEXT, which is honest rather than good — tables
render as pipes and the links do not click. Rendering it into a styled page
wants a small Markdown converter and is filed as TODO #109; build-web.ts's
comment names that number rather than gesturing at "the next step", so the file
and the worklist cannot drift the way the references just did.

Two things had to be true and tsc checks neither, so both are tests. The href on
the splash page and the filename the build writes are two strings with nothing
connecting them: rename the document and the build quietly publishes nothing
while the page keeps offering a link that 404s. And a .md file must not arrive
as a download — the server's MIME fallback is application/octet-stream, which a
browser saves instead of displaying, so the link would have handed a tester a
file to save rather than a page to read. '.md' is in http.ts's table now, and
the test reads that table out of the source rather than asserting on a copy of
it, which would pass while the real one was wrong.

VERIFIED AGAINST A RUNNING SERVER, not only compiled: 200,
text/plain; charset=utf-8, the guide's own first lines, and the splash link
resolving.

THE sortsCars COMMENT. Asked after v0.8.0.15 whether everything now agreed, and
the audit turned up one place that did not — the field's own doc comment named
the card's printed text as though it were the flag's meaning. Nothing reads it
to permit a sort; its two readers, resolveExtraStart in apply.ts and the
enumeration in legal.ts, both ask whether this is the one Mainline card with a
Yard Limit and therefore the one an Extra may be made up and started on. Comment
only, and worth the bump because of where it is: it is what a developer reads
before using the flag, and it is the likeliest source of the sentence v0.8.0.15
had to correct off the board. The name is kept for its link to the card face and
the comment now says outright that the name is not the meaning.

The five references and docs/design.md read v0.8.0.16. They describe this build
because the audit re-checked them against it, not because the number was swept
forward — a stamp bumped without a reading is worth less than none.

1001 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 01:28:51 -04:00
Jesse.MarkowitzandClaude Opus 5 7c9ef8797d v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in
exactly one place the player controls: their browser's localStorage, scoped to
the origin they joined at. Lose it — a cleared profile, a private window, a
different browser — and the seat is unreachable while the game runs on and the
session sits intact on disk. Reported from the table: of two humans in one game
the host reloaded straight back in, the joiner met an empty lobby.

Diagnosed before it was fixed, and two server-side theories of mine were
retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the
app deletes the secret or name, create and join both call persistSession, that
game's sessions.json held both seats, and it resumed with 80 intents replayed.
Both players used the same URL, so it was not a second origin either.

The fix is a recovery link. An administrator mints a code for a named seat
(admin-gated: deciding somebody lost a seat is a judgement no route can make);
the player opens the link and the page trades the code for the token over a
POST, then strips it from the address bar. The link never carries the token —
lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is
exactly what gets pasted into a chat. Single use, 30-minute expiry, held in
memory because a restart dropping them is the right failure.

server/claims.ts is a pure store, so single use, lazy expiry and one identical
answer for unknown/spent/expired codes are tested rather than asserted. The
admin game listing gained seatedPlayers — the seats a human holds a token for,
read from the session map rather than guessed from player names — so the
StartOS action can offer real players instead of bot chairs.

No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so
games in progress resume.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 20:16:24 -04:00
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00
Jesse.MarkowitzandClaude Opus 5 02289e94b8 v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.

Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.

Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.

Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 15:31:46 -04:00
Jesse.MarkowitzandClaude Opus 5 d5445badcc v0.7.9.5 — two answers to one question, and the copy nobody read
Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.

#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.

The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.

The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.

#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.

The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.

Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.

One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.

Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.

917 tests pass, up from 909.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-07 20:35:19 -04:00
Jesse.MarkowitzandClaude Sonnet 5 af68aac78d v0.7.7 — two releases shipped to a browser that never received them
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
2026-08-29 22:23:09 -04:00
Jesse.MarkowitzandClaude Opus 5 45580d8b61 v0.7.3 — a game that asks before it ends, and a results screen worth reading
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 #11
Closes #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 04:23:26 -04:00
Jesse.MarkowitzandClaude Opus 5 06db36e5b5 v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound
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
2026-08-23 05:46:42 -04:00
Jesse.Markowitz 40f07b0710 v0.6.0 — saves survive a release, Employee Rotation is real, and the lobby
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.
2026-08-21 21:45:34 -04:00
Jesse 51710498f5 v0.5.6 — seats counted from 1, a name you can read, and a line that stops repeating
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).
2026-08-21 20:53:17 -04:00
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
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.
2026-08-21 17:55:59 -04:00
Jesse 689de2ff0f v0.5.4 — the map says whose railroad is whose
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.
2026-08-21 17:12:25 -04:00
Jesse 2fbfe11977 v0.5.3 — a table you size yourself, and games an administrator can see and end
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.
2026-08-21 14:53:53 -04:00
Jesse 62b6ed7e1b v0.5.2 — the splash's multiplayer door opens, and knows whether it should
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.
2026-08-21 11:00:06 -04:00
Jesse e76bd77099 v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection
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.
2026-08-21 05:25:47 -04:00
Jesse c3c5cbfeec v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
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.
2026-08-20 23:50:38 -04:00