more fixes and tweaks
This commit is contained in:
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user