Name the caboose, the train and the Running Track hazard

This commit is contained in:
Jesse
2026-08-01 07:03:21 -04:00
parent dd300ac154
commit d1f689d4fc
2 changed files with 77 additions and 6 deletions
+14 -6
View File
@@ -287,11 +287,13 @@ export function describeIntent(s: GameState, i: Intent): string {
case 'porter.detrain': case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`; return `detrain passengers at ${at(i.at)}`;
case 'newTrain.placeCar': 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': case 'mainline.modify':
return `${cardName(s, i.cardId)} on Mainline card ${i.node}`; return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
case 'maneuver.redFlags': 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': case 'maneuver.flyingSwitch':
return `Flying Switch ${i.count} car(s) into ${at(i.to)}`; return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
case 'mainline.clearance': { case 'mainline.clearance': {
@@ -325,7 +327,7 @@ export function describeIntent(s: GameState, i: Intent): string {
case 'redFlag.play': case 'redFlag.play':
return 'play your red flag'; return 'play your red flag';
case 'maneuver.redFlags': 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: { default: {
// Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes // 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 // that a COMPILE error the day someone adds an intent without describing it — the playable UI
@@ -385,7 +387,7 @@ export function snapshot(
kind, kind,
label, label,
running: row === area.runningRow, 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), enhancements: card.enhancements.map(prettyKey),
tray: trayAt.get(key) ?? null, tray: trayAt.get(key) ?? null,
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel), 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. */ /** 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; const g = card.geometry;
switch (g.kind) { switch (g.kind) {
case 'limits': case 'limits':
@@ -612,7 +614,13 @@ function cellDescription(card: TrackCard, officeName: string): string {
if (f.kind === 'passenger') return 'passengers board and detrain here'; if (f.kind === 'passenger') return 'passengers board and detrain here';
const p = industryProfile(g.facility as never); const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives'; 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': case 'track':
switch (g.geometry) { switch (g.geometry) {
+63
View File
@@ -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', () => { 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 // The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
// sharpest decision in the game, phrased in internal identifiers. // sharpest decision in the game, phrased in internal identifiers.