more fixes and tweaks

This commit is contained in:
Jesse
2026-08-01 12:00:27 -04:00
parent d1f689d4fc
commit f00255ce31
7 changed files with 94 additions and 8 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 **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. 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 type { GameEvent } from './events.ts';
import { acceptsCar, areaOf } from './apply.ts'; import { acceptsCar, areaOf } from './apply.ts';
import { legalActions } from './legal.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'; import { coordKey, freshTurn, totalRevenue } from './state.ts';
export type AdvanceResult = { export type AdvanceResult = {
@@ -630,7 +630,7 @@ function arriveAtOffice(
}); });
return 'moved'; 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'; return 'moved';
} }
@@ -647,7 +647,7 @@ function arriveAtOffice(
// is not expecting them (§A.4), so this is a collision too, not a coupling. // is not expecting them (§A.4), so this is a collision too, not a coupling.
const officeCard = area.grid.get(coordKey(area.officeCoord)); const officeCard = area.grid.get(coordKey(area.officeCoord));
if (officeCard && officeCard.standing.length > 0) { 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'; return 'moved';
} }
@@ -672,10 +672,22 @@ function collide(
trains: TrayId[], trains: TrayId[],
events: GameEvent[], events: GameEvent[],
reason: string, reason: string,
where = 'the Office',
): void { ): 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) { for (const id of trains) {
const tray = s.trays.get(id); const tray = s.trays.get(id);
if (!tray) continue; 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. // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
for (const car of tray.consist) { for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car); if (car.type === 'caboose') s.yards.divisionYard.push(car);
@@ -685,6 +697,10 @@ function collide(
s.freeTrays.push(id); s.freeTrays.push(id);
} }
if (lost.length > 0) {
events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where });
}
s.collisionsToday += 1; s.collisionsToday += 1;
const player = s.players[faultPlayer]; const player = s.players[faultPlayer];
if (player) { 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: '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: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number } | { 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: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier } | { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number } | { 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', 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.`, 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': case 'clearanceRequested':
return { return {
tone: 'bad', tone: 'bad',
+6 -2
View File
@@ -302,9 +302,13 @@ export function describeIntent(s: GameState, i: Intent): string {
const pending = s.clock.pendingDecision; const pending = s.clock.pendingDecision;
const who = pending ? trainName(s, pending.train) : 'the train'; const who = pending ? trainName(s, pending.train) : 'the train';
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead'; 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 return i.allow
? `ALLOW — ${who} follows ${ahead} into the same Subdivision (risks a collision, 5 Revenue)` ? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
: `HOLD — ${who} waits where it is: safe, but it loses the Stage`; : `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
} }
case 'draw.fromDepartment': { case 'draw.fromDepartment': {
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and // Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
+2 -1
View File
@@ -81,7 +81,8 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'card.', title: 'Cards' }, { prefix: 'card.', title: 'Cards' },
{ prefix: 'track.lay', title: 'Lay track from your supply' }, { prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' }, { 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: 'freightAgent.', title: 'Freight Agent' },
{ prefix: 'newTrain.', title: 'Making up the train' }, { prefix: 'newTrain.', title: 'Making up the train' },
{ prefix: 'porter.', title: 'Porters' }, { prefix: 'porter.', title: 'Porters' },
+4 -2
View File
@@ -435,8 +435,10 @@ describe('the page explains itself', () => {
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`); 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.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
} }
assert.match(allow, /collision/, 'granting clearance does not mention the risk'); // Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
assert.match(hold, /safe|loses/, 'holding does not mention the cost'); // 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', () => { it('states the objective and whether you are keeping up', () => {