From d1f689d4fca68bbf9352e8654d924882c130bbea Mon Sep 17 00:00:00 2001 From: Jesse Date: Sat, 1 Aug 2026 07:03:21 -0400 Subject: [PATCH] Name the caboose, the train and the Running Track hazard --- src/sim/view.ts | 20 ++++++++++----- test/web.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/sim/view.ts b/src/sim/view.ts index fefee88..81dfbfc 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -287,11 +287,13 @@ export function describeIntent(s: GameState, i: Intent): string { case 'porter.detrain': return `detrain passengers at ${at(i.at)}`; case 'newTrain.placeCar': - return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`; + // carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put + // "add loaded caboose" on a button. + return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`; case 'mainline.modify': return `${cardName(s, i.cardId)} on Mainline card ${i.node}`; case 'maneuver.redFlags': - return `Red Flags on ${i.trayId}`; + return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; case 'maneuver.flyingSwitch': return `Flying Switch ${i.count} car(s) into ${at(i.to)}`; case 'mainline.clearance': { @@ -325,7 +327,7 @@ export function describeIntent(s: GameState, i: Intent): string { case 'redFlag.play': return 'play your red flag'; case 'maneuver.redFlags': - return `Red Flags on ${i.trayId}`; + return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; default: { // Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes // that a COMPILE error the day someone adds an intent without describing it — the playable UI @@ -385,7 +387,7 @@ export function snapshot( kind, label, running: row === area.runningRow, - what: cellDescription(card, officeProfile(area.tier).name), + what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow), enhancements: card.enhancements.map(prettyKey), tray: trayAt.get(key) ?? null, cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel), @@ -579,7 +581,7 @@ export function trainName(s: GameState, trayId: string): string { } /** What a card on the board does, in one short line. */ -function cellDescription(card: TrackCard, officeName: string): string { +function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string { const g = card.geometry; switch (g.kind) { case 'limits': @@ -612,7 +614,13 @@ function cellDescription(card: TrackCard, officeName: string): string { if (f.kind === 'passenger') return 'passengers board and detrain here'; const p = industryProfile(g.facility as never); const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives'; - return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load`; + // §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not + // block anything. It does inherit the Running Track's hazard: §10 makes cars left standing + // between the Limits and the Office a collision when a train arrives. + const where = onRunning + ? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival' + : ''; + return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`; } case 'track': switch (g.geometry) { diff --git a/test/web.test.ts b/test/web.test.ts index f0dc0ce..164360d 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -338,6 +338,69 @@ describe('the page explains itself', () => { } }); + it('never puts an internal tray id on a BUTTON either', () => { + // The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3", + // so the id leaked through the action list rather than the log. Every button, every turn. + const game = newGame(2345); + for (let i = 0; i < 400; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + for (const o of options) { + const label = describeIntent(game.state, o); + assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`); + } + if (!submit(game, options[0]!)) break; + } + }); + + it('never offers to load a caboose', () => { + // A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was + // formatted by hand instead of using the labeller that already knew. + const game = newGame(2345); + for (let i = 0; i < 400; i++) { + if (currentActor(game) === null) break; + const { options } = actionGroups(game); + if (options.length === 0) break; + for (const o of options) { + assert.doesNotMatch( + describeIntent(game.state, o), + /(loaded|empty) caboose/, + 'a caboose was described as loaded or empty', + ); + } + if (!submit(game, options[0]!)) break; + } + }); + + it('warns that an industry on the Running Track is in the way of arrivals', () => { + // §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car + // left standing between the Limits and the Office a collision, and nothing said so. + const game = newGame(9); + const area = game.state.officeAreas.get(0)!; + const industry = { + geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const, axis: 'ew' as const }, + baseOperationalRail: true, standing: [], modifiers: [], enhancements: [], + facility: { + kind: 'freight' as const, subtype: 'grocersWarehouse', + allows: { outbound: false, inbound: true }, + outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 }, + menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] }, + laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, + }, + }; + area.grid.set(`${area.runningRow},2`, industry as never); + area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never); + + const cells = view(game).cells; + const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!; + const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!; + + assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry'); + assert.match(onRunning.what, /pass straight through/, 'does not say traffic still passes'); + assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn'); + }); + it('never puts an internal tray id in front of a player', () => { // The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the // sharpest decision in the game, phrased in internal identifiers.