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
+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',
+6 -2
View File
@@ -302,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
+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' },