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
+60 -43
View File
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
@@ -690,9 +690,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
* them — it may be the rules are wrong, or it may be my perception."
*
* It was perception, but the log was actively feeding it. Every departure read alike, and the
* arrival line for an Expedited train said there was "no turn in which to work it" — which is
* false and cost the player the Cargo turn they did have. These pin the two claims the log now
* makes, so a phase-order change cannot leave the narration lying about it.
* arrival line for an Expedited train said there was "no turn in which to work it" — which was
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
* the Cargo turn they did have.
*
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
* change cannot silently bring back the old forced departure.
*/
/**
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
@@ -704,11 +711,10 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/**
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train,
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages
* and collected four Local Operations turns, which is correct behaviour and the opposite of what
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
* this test is trying to pin, so clearing the timetable isolates the rule from it.
*/
s.timetable = s.timetable.map(() => null);
const id = 'watched';
@@ -779,40 +785,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
});
it('gives an Expedited train the Cargo phase but never Local Operations', () => {
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair:
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of
// the Stage rather than in a Mainline Phase.
it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
// Local Operations turn the same as Train 12 above.
const r = phasesWith(6);
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift');
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
});
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => {
/**
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
*
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
* meet depends on the deck, and the deck moves.
*
* So the log and the card must not promise that an Expedited train can never be switched.
*/
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
// switching, say — it must not still be there when the next Mainline Phase begins.
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
const area = areaOf(s, 0);
const id = 'expedited';
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
});
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
s.clock.phase = 'mainline';
const before = s.players[0]!.revenue;
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
'no fault was raised for an Expedited train left off the station',
);
assert.equal(
s.players[0]!.revenue,
before - EXPEDITE_FAULT_PENALTY,
'the fault did not cost Revenue',
);
});
it('does not fault an Expedited train legitimately held at the station itself', () => {
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
const s = game(7, { length: 'standard' });
const area = areaOf(s, 0);
const id = 'expedited';
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
departsThisStage: true,
});
area.adOccupancy.push(id);
@@ -829,26 +850,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
s.clock.phase = 'shiftChange';
s.clock.phase = 'mainline';
const before = s.players[0]!.revenue;
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
'the Expedited departure was not put to the Superintendent',
);
assert.equal(
s.trays.get(id)?.position.at,
'grid',
'the Expedited train left despite needing a ruling nobody could give',
'the departure was not put to the Superintendent',
);
assert.ok(
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
'the log does not say why the Expedited train stayed',
!r.events.some((e) => e.type === 'expediteFault'),
'a train properly parked at the station was faulted anyway',
);
assert.equal(
s.trays.get(id)?.departsThisStage,
false,
'it should run as an ordinary train from now on, not retry the expedited departure',
s.players[0]!.revenue,
before,
'Revenue changed for a train legitimately held at the station',
);
});
});
+56 -19
View File
@@ -468,9 +468,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
* would break in silence. These pin it.
*
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at
* all. It stands through Load/Unload first, which is what makes its coaches workable.
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
*/
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
const id = 'arriving';
@@ -503,12 +504,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
);
});
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => {
it('an Expedite train follows the same rule now — no exception any more', () => {
/**
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable —
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them.
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
* exactly like Train 9 above.
*/
const s = game();
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
@@ -518,19 +521,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
assert.equal(
s.trays.get(id)!.position.at, 'grid',
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it',
'an Expedite train departed again in the same Stage it arrived from a mainline card',
);
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
// is the departure pass that follows it — not that phase's own input handling.
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(
s.trays.get(id)?.position.at, 'grid',
'an Expedite train should be gone by the end of the Stage it arrived',
);
});
});
@@ -666,6 +659,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
});
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
// instead of being automatically held against an opposite-direction one also occupying the
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
// what creates: a facing train dispatched past another sits on the same card as it.
const s = game();
const ml = s.division.nodes[1];
assert.equal(ml?.kind, 'mainline');
if (ml?.kind === 'mainline') {
ml.card = 'plains';
// SAME direction, entered the transit list FIRST — what the old code asked about.
s.trays.set('sameDir', {
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
s.trays.set('facing', {
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('mine', {
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
const r = advance(s);
assert.equal(
s.trays.get('mine')!.position.at, 'divisionPoint',
'the train entered despite an uncleared opposite-direction occupant on the card',
);
assert.ok(
!r.events.some((e) => e.type === 'clearanceRequested'),
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
);
});
it('lets a junior train win the meet with a Telegraph', () => {
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
const s = game();
+1
View File
@@ -46,6 +46,7 @@ const KNOWN_UNREDUCED = [
'carPassed',
'clearanceRequested',
'dispatchBonusUsed',
'expediteFault',
'phaseBegan',
'stageBegan',
'trainArrived',
+7
View File
@@ -446,6 +446,13 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
// stride and seed count. If this starts failing after either changes, widen the pool rather than
// deleting the assertion — §10 is the one event a player most needs to hear.
assert.ok(kinds.has('crash'), 'a collision never made a sound');
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow
+2 -2
View File
@@ -345,8 +345,8 @@ describe('end-of-game statistics', () => {
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
* facilities a game, down from 3.8 when industries could sit on the main.
*
* That is the bot's district-building weakness, which is the top item in TODO.md, not a rule
* that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
* That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
* and so removing this line is what proves the bot has been fixed.
*/
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
+13 -3
View File
@@ -76,13 +76,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
// ---------------------------------------------------------------------------
describe('§7 — no switching', () => {
it('refuses every switching act to a train whose card forbids it', () => {
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
// Moving, setting out and sorting are all switching, so all three are refused.
// A train held at the Office may still need to clear onto Secondary Track ahead of other
// traffic, so a move that couples nothing is legal.
const s = game();
switching(s, 1, false, [boxcar()]);
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
});
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING');
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
const s = game();
switching(s, 1, false, [boxcar()], [boxcar()]);
assert.equal(
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
'PICKUP_NOT_ALLOWED',
);
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
});
+8 -7
View File
@@ -841,7 +841,7 @@ describe('board highlighting', () => {
}
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree');
}
}
});
@@ -3113,18 +3113,19 @@ describe('two crews switching are told apart', () => {
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
});
it('leaves out a train the rules will not let switch at all', () => {
// `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it.
// Offering the Circus Train in a "which train are you switching?" row offers a train whose
// every move is refused.
it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
// v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
// "may never be touched": a train held at the Office may still need to clear onto Secondary
// Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
// is not filtered any differently for a no-switching train than for any other.
const game = twoCrews();
const s = game.state;
const circus = [...s.trays.values()][0]!;
circus.trainNumber = 18;
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
const f = view(game);
assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch');
assert.equal(f.moves[0]!.label, 'Train 12');
assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
});
});