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
This commit is contained in:
Jesse.Markowitz
2026-08-23 05:46:42 -04:00
co-authored by Claude Opus 5
parent 42adfda390
commit 06db36e5b5
41 changed files with 4387 additions and 588 deletions
+11 -3
View File
@@ -1050,7 +1050,11 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// -- New Train ------------------------------------------------------------
case 'newTrain.startExtra': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
if (!s.pendingExtras.includes(i.trainNumber)) return 'NO_EXTRA_PENDING';
const pending = s.pendingExtras.find((x) => x.trainNumber === i.trainNumber);
if (!pending) return 'NO_EXTRA_PENDING';
// §7 — "the player who played the card MAY place the Crew Tray": it is theirs to place, and
// nobody else's to place for them.
if (pending.player !== player) return 'NOT_YOUR_EXTRA';
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
const where = resolveExtraStart(s, player, i);
if (typeof where === 'string') return where;
@@ -2254,7 +2258,8 @@ export function reduce(s: GameState, e: GameEvent): void {
}
case 'extraQueued':
s.pendingExtras.push(e.trainNumber);
// §7 hands this train to the player who played the card, so the queue records them.
s.pendingExtras.push({ trainNumber: e.trainNumber, player: e.player });
break;
case 'secondSectionOrdered':
@@ -2284,11 +2289,14 @@ export function reduce(s: GameState, e: GameEvent): void {
*/
case 'extraStarted': {
const trayId = s.freeTrays.pop()!;
s.pendingExtras = s.pendingExtras.filter((n) => n !== e.trainNumber);
s.pendingExtras = s.pendingExtras.filter((x) => x.trainNumber !== e.trainNumber);
const { direction } = e;
const base = {
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0,
consist: [] as RollingStock[], direction, movesUsed: 0,
// The queue is emptied here, before a single car goes on, so the tray carries the owner
// from now on — §7 lets the player who played it load the consist as they choose.
builtBy: e.player,
};
// Which way the tray physically points, for the cards it will pick up. A Division Point start
// does not set it: those trays have never carried a facing and `enterMainline` does not read