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
+18 -3
View File
@@ -266,8 +266,14 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
* chooser is the acting player; §7 says the player who PLAYED the card, which is the same person
* in solitaire and needs `pendingExtras` to carry a player before it is not.
*/
if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) {
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
const extra = s.pendingExtras[0];
if (extra !== undefined && s.freeTrays.length > 0) {
// §7 — the player who PLAYED the card places it. `pendingExtras` carries them (2026-08-23);
// before that it was a bare train number and this asked whoever the acting order was on, which
// is the same person in solitaire and the wrong one at every table. Reported by Jesse from a
// two-player game.
if (s.clock.currentActor !== extra.player) events.push({ type: 'actorChanged', player: extra.player });
s.clock.currentActor = extra.player;
return { events, needsInput: true };
}
@@ -285,7 +291,16 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
const filling = trainNeedingCars(s);
if (filling) {
const tray = s.trays.get(filling)!;
const nextActor = actorAt(s, tray.consist.length % s.players.length);
/**
* TWO DIFFERENT RULES, and §7 states them a paragraph apart. A TIMETABLED train's consist is
* built by the table — "starting with the Superintendent and working left, each player may place
* ONE car" — while an EXTRA is loaded by the player who played it, "as he chooses". So an Extra
* does not enter the round at all; it belongs to `builtBy` until it is full.
*/
const nextActor =
tray.trainIsExtra && tray.builtBy !== undefined
? tray.builtBy
: actorAt(s, tray.consist.length % s.players.length);
if (s.clock.currentActor !== nextActor) events.push({ type: 'actorChanged', player: nextActor });
s.clock.currentActor = nextActor;
return { events, needsInput: true };
+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
+27 -6
View File
@@ -504,8 +504,9 @@ export type MainlineKind =
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
/**
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade
* prints "G" with the player setting orientation.
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade prints
* "G" — no number at all, plus "Player sets orientation", **which the game deliberately does not do**
* (see `gradeReduction` below, and implications.md §10 Q11).
*
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
* the answer can be applied without re-reading the cards.
@@ -531,6 +532,14 @@ export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
/**
* `entryPoints` is TRANSCRIBED, NOT READ — nothing anywhere reads this field on any profile, and
* the printed start positions are not modelled: `crossingStages` counts Stages instead. Recorded
* here because implications.md §6 describes the card as having FIVE distinct starts (plain,
* brakemen, airbrakes, plain, helpers) against the four listed, and that discrepancy should be
* settled against `Mainline Cards.pdf` if the starts are ever implemented — not quietly "fixed"
* now, when nothing depends on it either way.
*/
{ kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
{ kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
@@ -664,12 +673,24 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
}
/**
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
* travelling when it goes UPHILL; a train heading the other way is descending.
* WHICH WAY THE GRADE CLIMBS, AND WHY NO PLAYER CHOOSES IT.
*
* Q11, answered from the card: Heavy Grade prints "(Up)" and "Player sets orientation", so the climb
* is a property of the PLACED CARD rather than a compass constant. `gradeUp` is the direction a train
* is travelling when it goes UPHILL; a train heading the other way is descending.
*
* **The second half of that print is deliberately overridden.** No player sets it — `setup.ts` rolls
* it from the seed. Settled v0.5.0 and re-confirmed 2026-08-23 after the question was raised again:
* a Heavy Grade always sits BETWEEN two districts (or beyond an end Division Point next to one),
* never inside one player's own, so there is no player with a fair claim to the choice — and the
* choice is not cosmetic, because it decides which of the three modifiers below can ever pay and
* therefore which direction of traffic is favoured, permanently. Giving it to the Superintendent was
* considered and rejected in that re-examination: the office rotates every three Stages, the
* advantage does not. Full reasoning in implications.md §10 Q11.
*
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces
* (`MAINLINE_MODIFIER_RULES`, `requiresOnCard`).
*/
function gradeReduction(
profile: MainlineProfile,
+2
View File
@@ -258,6 +258,8 @@ export type RejectionCode =
| 'NO_EMPTY_COACH_IN_YARD'
| 'NOT_A_CONTROL_POINT'
| 'NO_EXTRA_PENDING'
/** §7 gives an Extra to the player who played the card; another seat may not place it for them. */
| 'NOT_YOUR_EXTRA'
| 'NO_FREE_TRAY'
| 'NO_FREE_AD_TRACK'
// -- §7, where an Extra may be started (`resolveExtraStart`)
+5 -3
View File
@@ -79,7 +79,7 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
out.push(...localOpsCandidates(s, player));
break;
case 'newTrain':
out.push(...newTrainCandidates(s));
out.push(...newTrainCandidates(s, player));
break;
case 'loadUnload':
out.push(...loadUnloadCandidates(s, player));
@@ -257,7 +257,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
return out;
}
function newTrainCandidates(s: GameState): Intent[] {
function newTrainCandidates(s: GameState, player: PlayerIndex): Intent[] {
const out: Intent[] = [];
for (const [trayId] of s.trays) {
for (const carType of CAR_TYPES) {
@@ -279,7 +279,9 @@ function newTrainCandidates(s: GameState): Intent[] {
* is placed. In the middle of the railroad — an Interchange, a Control Point — both ways are real
* runs, so those are offered twice, once per direction.
*/
for (const trainNumber of s.pendingExtras) {
// Only the player who played it is offered anywhere to put it (§7) — `check` refuses anyone else
// with NOT_YOUR_EXTRA, and offering options that are certain to be refused is how a menu lies.
for (const { trainNumber } of s.pendingExtras.filter((x) => x.player === player)) {
for (const side of ['west', 'east'] as const) {
out.push({ type: 'newTrain.startExtra', trainNumber, start: { kind: 'divisionPoint', side } });
}
+5 -1
View File
@@ -254,7 +254,11 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
* district — so whichever direction climbs advantages one neighbour over the other, and there
* is no single player who owns that call fairly. Orientation is rolled from the seed instead,
* identically for solitaire and multiplayer, and this is not expected to change when setup
* eventually gains an interactive phase for other decisions. See implications.md §10 Q11.
* eventually gains an interactive phase for other decisions.
*
* RE-CONFIRMED 2026-08-23, when the question was raised again and giving the choice to the
* Superintendent was considered and rejected — the office rotates every three Stages, the
* advantage it would hand out does not. See implications.md §10 Q11.
*/
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
+17 -1
View File
@@ -371,6 +371,15 @@ export type CrewTray = {
* bug `isBeingMadeUp` was tightened to kill, and an arriving train never carries this.
*/
beingMadeUp?: boolean;
/**
* WHOSE TRAIN THIS IS TO BUILD, for an Extra only.
*
* §7's Extra is loaded by the player who played the card, not by the Superintendent-first round
* that fills a Timetabled train — so the tray has to remember them: `pendingExtras` is emptied the
* moment the Extra starts, which is before a single car goes on. Undefined on every Timetabled
* train, where the round decides instead.
*/
builtBy?: PlayerIndex;
/**
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
*
@@ -831,8 +840,15 @@ export type GameState = {
/**
* §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled:
* it runs once, immediately, then its card goes to the Salvage Yard (§2.3).
*
* CARRIES WHO PLAYED IT (2026-08-23, Jesse's call). §7 gives an Extra to the player who played the
* card — "may place the Crew Tray at either Division Point ... and may load the consist as he
* chooses" — which is a different rule from the Timetabled make-up round, where the table goes
* round starting at the Superintendent. This was a bare `number[]`, so the engine could not tell
* whose Extra it was and asked whoever the acting order happened to be on: correct in solitaire,
* where there is only one player, and wrong at every table.
*/
pendingExtras: number[];
pendingExtras: { trainNumber: number; player: PlayerIndex }[];
/**
* Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an
* identical train behind the first, if a Crew Tray is free.