Compare commits

...
2 Commits
Author SHA1 Message Date
Jesse f00255ce31 more fixes and tweaks 2026-08-01 12:00:27 -04:00
Jesse d1f689d4fc Name the caboose, the train and the Running Track hazard 2026-08-01 07:03:21 -04:00
7 changed files with 171 additions and 14 deletions
+35
View File
@@ -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.
+19 -3
View File
@@ -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) {
+7
View File
@@ -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 }
+21
View File
@@ -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',
+20 -8
View File
@@ -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': {
@@ -300,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
@@ -325,7 +331,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 +391,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 +585,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 +618,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) {
+2 -1
View File
@@ -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' },
+67 -2
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', () => {
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
// sharpest decision in the game, phrased in internal identifiers.
@@ -372,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', () => {