v0.4.9 — X,Y coordinates, no-switching can still clear the mainline, Q3 corrected, a clearance bug, three sounds, and the box art

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
This commit is contained in:
Jesse
2026-08-19 23:51:35 -04:00
co-authored by Claude Sonnet 5
parent 37b1e5b969
commit 859817d173
25 changed files with 1048 additions and 739 deletions
+76 -89
View File
@@ -18,6 +18,7 @@
import {
COLLISION_PENALTY,
EXPEDITE_FAULT_PENALTY,
MAINLINE_PROFILES,
enhancementRule,
crossingStages,
@@ -307,6 +308,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
});
/**
* Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins
* was not kept ready to highball, wherever in the district it has been left — switched onto
* Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase
* moves anything, and charged every Phase it is still caught away: the fault is in leaving it
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
* unaffected — this only bites when the train is not even in the queue to leave.
*/
for (const [, tray] of order) {
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
const area = areaAtSeat(s, tray.position.seat);
const { coord } = tray.position;
if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue;
const owner = playerAtSeat(s, tray.position.seat);
const p = s.players[owner];
if (!p) continue;
p.revenue -= EXPEDITE_FAULT_PENALTY;
events.push({
type: 'expediteFault',
player: owner,
trainNumber: tray.trainNumber ?? 0,
where: `(${coord.col},${coord.row})`,
});
events.push({
type: 'revenueChanged',
player: owner,
delta: -EXPEDITE_FAULT_PENALTY,
total: p.revenue,
reason: 'expedited train left off the station',
});
}
for (const [id, tray] of order) {
if (s.movedThisPhase.has(id)) continue;
const where = tray.position;
@@ -329,13 +362,13 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
(tray.position.at !== 'grid' ||
where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (!tray.departsThisStage && stillThere) {
if (stillThere) {
tray.stopPointClaimed = true;
// The point goes to whoever is SITTING in the district it stopped in.
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
const label =
tray.position.at === 'grid'
? `(${tray.position.coord.row},${tray.position.coord.col})`
? `(${tray.position.coord.col},${tray.position.coord.row})`
: tray.position.at === 'mainline'
? `Mainline card ${tray.position.index}`
: `the ${tray.position.side} Division Point`;
@@ -475,14 +508,18 @@ function spendDispatchBonus(
return 0;
}
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
/**
* Does this train leave in the same Stage it arrives (§7)?
* Q3 — is this train subject to the "kept ready" fault (§7)?
*
* Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any
* other train, and may be switched normally while it stands. What it means is that it must not be
* held anywhere but the station: a train the player parks on Secondary Track to clear a switching
* move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`).
*
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
* stop, so the train is slow once and fast thereafter.
* stop, so the train is exempt once and subject to the fault thereafter.
*/
function isExpedited(tray: CrewTray): boolean {
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
@@ -491,18 +528,7 @@ function isExpedited(tray: CrewTray): boolean {
return rules.stopThenExpedite === true && tray.speechMade === true;
}
function moveTrain(
s: GameState,
id: TrayId,
tray: CrewTray,
events: GameEvent[],
/**
* Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the
* Expedited arrivals to go again. The train cannot tell the difference, but the player must — see
* `trainHighballed.why`.
*/
pass: 'mainline' | 'expedite' = 'mainline',
): MoveOutcome {
function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome {
const dir = step(tray.direction);
if (tray.position.at === 'divisionPoint') {
@@ -588,10 +614,7 @@ function moveTrain(
trainNumber: tray.trainNumber ?? 0,
from: 'the Office',
to: 'the Mainline',
why:
pass === 'expedite'
? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it'
: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
});
return 'moved';
}
@@ -751,28 +774,44 @@ function evaluateClearance(
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
}
/**
* TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked
* against EVERY occupant before any same-direction judgment call is offered.
*
* A single pass returned on whichever occupant it examined first, in `node.transits` insertion
* order — which is fine while a Subdivision holds at most one train, but the dispatch exception
* below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same
* card as whatever it was cleared past. Found from a playtest report where the Superintendent was
* asked to rule on a same-direction train instead of being auto-held against an uncleared
* opposite-direction one also on the card — the loop had reached the same-direction occupant
* first simply because it entered the transit list first, and returned before ever looking at
* the other.
*/
for (const { tray: other } of occupants) {
if (other === id) continue;
const otherTray = s.trays.get(other);
if (!otherTray || otherTray.direction === tray.direction) continue;
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
//
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) continue;
return 'blocked';
}
for (const { tray: other, onCard } of occupants) {
if (other === id) continue;
const otherTray = s.trays.get(other);
if (!otherTray) continue;
if (!otherTray || otherTray.direction !== tray.direction) continue;
const onNode = s.division.nodes[onCard];
if (otherTray.direction !== tray.direction) {
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
//
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
return 'blocked';
}
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
// where ABS Signals protects everything on the card.
//
// Both of these now read the card the OTHER train is standing on rather than the card being
// Both of these read the card the OTHER train is standing on rather than the card being
// entered. They were the same card while this only looked one card ahead; across a Subdivision
// they are not, and the protection belongs where the train it protects actually is.
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
@@ -871,27 +910,9 @@ function arriveAtOffice(
});
/**
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE.
*
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
*
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
* §8.1 condition when it goes, so it can be held.
*/
if (isExpedited(tray)) {
tray.departsThisStage = true;
return 'moved';
}
/**
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
* runs expedited.
* X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after
* that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every
* later arrival, so it needs no guard against re-firing.
*/
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
tray.speechMade = true;
@@ -1018,40 +1039,6 @@ function retireTrain(
// ---------------------------------------------------------------------------
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
/**
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
*
* Run here rather than as a phase of its own because there is no decision in it: the trains that
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
* Porters their one Stage with the train.
*/
const expedited = [...s.trays.entries()]
.filter(([, t]) => t.departsThisStage)
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
for (const [id, tray] of expedited) {
tray.departsThisStage = false;
/**
* An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on.
*
* Said in the log, because it is the difference between "this train leaves at the end of the
* Stage" and "this train is still here next turn", and a player watching one Expedited train
* vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary
* traffic running, Train 6 was held this way and collected four Local Operations turns.
*/
if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') {
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason:
'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' +
'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' +
'runs like an ordinary train from now on — so you DO get a Local Operations turn with it',
});
continue;
}
}
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
+14 -5
View File
@@ -486,11 +486,14 @@ function freightBudgetLeft(
}
/**
* Whether a train may do switching work at all (§7).
* Whether a train may set out or sort cars (§7).
*
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
* sorting alike, because all three are switching.
* Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
* train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
* move would make, not on the move itself.
*/
function switchingRefusal(tray: CrewTray): RejectionCode | null {
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
@@ -626,8 +629,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
if (noSwitch) return noSwitch;
const from = trayCoord(s, i.trayId);
if (!from) return 'ILLEGAL_MOVE';
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
@@ -652,6 +653,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
*/
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
if (fresh.length > 0) {
/**
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
*/
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
const freight = fresh.filter(isFreight).length;
+6
View File
@@ -1004,6 +1004,12 @@ export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
export const LABORER_ACTIONS_PER_LOAD = 4;
export const COLLISION_PENALTY = 5;
export const COLLISION_FLOOR_PER_DAY = 3;
/**
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
*/
export const EXPEDITE_FAULT_PENALTY = 1;
export function collectiveRevenueFloor(players: number, days: number): number {
return 3 * players * days;
+10 -3
View File
@@ -124,6 +124,13 @@ export type GameEvent =
| { type: 'trainHeld'; trainNumber: number; reason: string }
/** A train whose card pays for standing still (X18 Circus) collected on it. */
| { type: 'trainStoodStill'; trainNumber: number; where: string }
/**
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
*/
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
/**
* `why` is the RULE that let it go now, not a restatement of the move.
*
@@ -134,9 +141,9 @@ export type GameEvent =
*/
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
/**
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
* every other train stands until the next Mainline Phase and can be switched in between.
* `expedited` flags a train that must not be left standing off the station (Q3) — it may be
* switched normally like any other arrival, but it has to be back on the Office square before the
* next Mainline Phase begins, or `expediteFault` fires.
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
+4 -9
View File
@@ -384,17 +384,12 @@ export type CrewTray = {
* X17 Campaign Train — "one turn at station (speeches) then expedite".
*
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
* once, so there is no later visit to hang it on.
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
* visit to hang it on.
*/
speechMade?: boolean;
/**
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
* Load/Unload, which is what gives the Porters their one Stage with the train.
*/
departsThisStage?: boolean;
};
// ---------------------------------------------------------------------------