From f00255ce313a1b66e5d39114558965c002366ba8 Mon Sep 17 00:00:00 2001 From: Jesse Date: Sat, 1 Aug 2026 12:00:27 -0400 Subject: [PATCH] more fixes and tweaks --- docs/rules/implications.md | 35 +++++++++++++++++++++++++++++++++++ src/engine/advance.ts | 22 +++++++++++++++++++--- src/engine/events.ts | 7 +++++++ src/sim/narrate.ts | 21 +++++++++++++++++++++ src/sim/view.ts | 8 ++++++-- src/web/game.ts | 3 ++- test/web.test.ts | 6 ++++-- 7 files changed, 94 insertions(+), 8 deletions(-) diff --git a/docs/rules/implications.md b/docs/rules/implications.md index d47952a..2e9464d 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -726,3 +726,38 @@ The temptation is to rebuild everything at once. I would not. **Do not retune anything against current measurements.** Gap 12's numbers describe a placeholder ruleset. Re-measure after step 5 and treat everything before that as void. + +--- + +## §10 Q13 — rear-end collisions on a Mainline card are not implemented + +**Found by playing.** The Superintendent's §8.1 ruling asks whether one train may follow another onto +an occupied Mainline card. Granting it is currently **free**: both trains cross, nothing collides, no +penalty. Verified directly — two trains on one card, clearance granted, both alive and revenue +unchanged. + +Two places in the rules say it should not be free: + +- **§10** — "A collision on a Mainline card is the fault of the Superintendent who cleared the trains + out; he loses 5 Revenue points" and "**Train-on-train.** Both trains are immediately removed." +- **ABS Signals** — "Trains on this card will not rear-end each other. They will stop short of a + collision." A card whose entire purpose is preventing rear-enders presupposes that rear-enders + happen. + +But **§8.3's trigger list does not include it.** The three automatic triggers are all about arriving +at an Office or highballing past fouled track; none covers a follower catching a leader mid-card. So +the rule is asserted in §10 and priced by ABS Signals, yet never given a trigger. + +The missing piece is **timing**, and it is a design decision rather than a transcription gap. +Crossing is counted in Stages (Q1), so both trains simply run their counters down. Candidates: + +1. **On entry** — following onto an occupied card wrecks both at once. Makes clearance a pure gamble + and ABS Signals very strong. +2. **On catching up** — collide only when the follower's remaining Stages would take it past the + leader. Rewards judging the gap, which is what a Superintendent actually does. +3. **Never** — clearance is genuinely safe, §10's Mainline clause is vestigial, and ABS Signals is + worth less than it reads. + +Until this is settled the clearance buttons describe only what the engine actually does — they say +the train "closes up behind" rather than promising a −5 risk that cannot occur. Wording that invents +a consequence is worse than wording that under-sells one. diff --git a/src/engine/advance.ts b/src/engine/advance.ts index e009cee..f6417f7 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -35,7 +35,7 @@ import type { Direction } from './content.ts'; import type { GameEvent } from './events.ts'; import { acceptsCar, areaOf } from './apply.ts'; import { legalActions } from './legal.ts'; -import type { CrewTray, DivisionNode, GameState, PlayerIndex, TrayId } from './state.ts'; +import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts'; import { coordKey, freshTurn, totalRevenue } from './state.ts'; export type AdvanceResult = { @@ -630,7 +630,7 @@ function arriveAtOffice( }); return 'moved'; } - collide(s, owner, [id], events, 'no free A/D track'); + collide(s, owner, [id], events, 'no free A/D track', 'the Office'); return 'moved'; } @@ -647,7 +647,7 @@ function arriveAtOffice( // is not expecting them (§A.4), so this is a collision too, not a coupling. const officeCard = area.grid.get(coordKey(area.officeCoord)); if (officeCard && officeCard.standing.length > 0) { - collide(s, owner, [id], events, 'cars fouling the Running Track'); + collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track'); return 'moved'; } @@ -672,10 +672,22 @@ function collide( trains: TrayId[], events: GameEvent[], reason: string, + where = 'the Office', ): void { + // Record WHAT was lost before removing it. The log said only "COLLISION: NO FREE A/D TRACK", so a + // player could see the 5 points go without learning which train had just been written off, or + // what it was carrying. + const lost: { label: string; consist: RollingStock[] }[] = []; for (const id of trains) { const tray = s.trays.get(id); if (!tray) continue; + lost.push({ + label: + tray.trainNumber === null + ? 'the local crew' + : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`, + consist: [...tray.consist], + }); // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification. for (const car of tray.consist) { if (car.type === 'caboose') s.yards.divisionYard.push(car); @@ -685,6 +697,10 @@ function collide( s.freeTrays.push(id); } + if (lost.length > 0) { + events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where }); + } + s.collisionsToday += 1; const player = s.players[faultPlayer]; if (player) { diff --git a/src/engine/events.ts b/src/engine/events.ts index a3b3014..ecb73ff 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -31,6 +31,13 @@ export type GameEvent = | { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number } | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number } + | { + type: 'trainsDestroyed'; + player: PlayerIndex; + trains: { label: string; consist: RollingStock[] }[]; + reason: string; + where: string; + } | { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] } | { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier } | { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index c948fbd..a2619d6 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -294,6 +294,27 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { tone: 'good', text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`, }; + case 'trainsDestroyed': { + // Say what hit what, where, and what was written off. "COLLISION: NO FREE A/D TRACK" told a + // player the score had changed and nothing else. + const why = + e.reason === 'no free A/D track' + ? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it (§8.3)` + : e.reason === 'cars fouling the Running Track' + ? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)` + : e.reason; + const wrecked = e.trains + .map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`) + .join(' and '); + return { + tone: 'bad', + text: + `COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` + + `Yard, all other cars to the Classification Yard (§10). A Timetabled train card returns ` + + `to its slot and runs again next Day; an Extra is gone for good.`, + }; + } + case 'clearanceRequested': return { tone: 'bad', diff --git a/src/sim/view.ts b/src/sim/view.ts index 81dfbfc..e410411 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -302,9 +302,13 @@ export function describeIntent(s: GameState, i: Intent): string { const pending = s.clock.pendingDecision; const who = pending ? trainName(s, pending.train) : 'the train'; const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead'; + // NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what + // ABS Signals exists to prevent, but no such collision is implemented — granting clearance is + // currently free. Saying otherwise invents a consequence the engine will never deliver. + // See implications.md §10 Q13. return i.allow - ? `ALLOW — ${who} follows ${ahead} into the same Subdivision (risks a collision, −5 Revenue)` - : `HOLD — ${who} waits where it is: safe, but it loses the Stage`; + ? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it` + : `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`; } case 'draw.fromDepartment': { // Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and diff --git a/src/web/game.ts b/src/web/game.ts index 3aca91e..a637e36 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -81,7 +81,8 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [ { prefix: 'card.', title: 'Cards' }, { prefix: 'track.lay', title: 'Lay track from your supply' }, { prefix: 'mainline.modify', title: 'Mainline modifiers' }, - { prefix: 'maneuver.', title: 'Manoeuvres' }, + // American spelling throughout, to match MANEUVER_CARDS and the source deck. + { prefix: 'maneuver.', title: 'Maneuvers' }, { prefix: 'freightAgent.', title: 'Freight Agent' }, { prefix: 'newTrain.', title: 'Making up the train' }, { prefix: 'porter.', title: 'Porters' }, diff --git a/test/web.test.ts b/test/web.test.ts index 164360d..995d133 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -435,8 +435,10 @@ describe('the page explains itself', () => { assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`); assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`); } - assert.match(allow, /collision/, 'granting clearance does not mention the risk'); - assert.match(hold, /safe|loses/, 'holding does not mention the cost'); + // Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is + // implemented, and promising one would describe a consequence the engine never delivers. + assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens'); + assert.match(hold, /losing the Stage/, 'holding does not mention the cost'); }); it('states the objective and whether you are keeping up', () => {