Compare commits
2
Commits
dd300ac154
...
f00255ce31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f00255ce31 | ||
|
|
d1f689d4fc |
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user