adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.

This commit is contained in:
Jesse
2026-08-04 21:03:17 -04:00
parent 4bba991f41
commit 821d3cdffa
26 changed files with 6876 additions and 2638 deletions
+52 -22
View File
@@ -30,6 +30,8 @@ import {
collectiveRevenueFloor,
lengthProfile,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
mainlineProfile,
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
@@ -186,7 +188,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: due,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction,
position: { at: 'divisionPoint', side },
@@ -219,7 +221,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: number,
trainIsExtra: false,
engineFront: true,
engineAt: 0,
consist: [],
direction,
position: { at: 'divisionPoint', side },
@@ -250,7 +252,7 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
id: trayId,
trainNumber: number,
trainIsExtra: true,
engineFront: true,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
@@ -488,6 +490,53 @@ function moveTrain(
// Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
// Fast/Slow class. Count it down rather than stepping through printed cells.
if (transit.stagesRemaining > 1) {
/**
* Q13 — REAR-END COLLISIONS, answered: a train collides when it CATCHES UP.
*
* §10 makes a Mainline collision the Superintendent's fault and removes both trains, and ABS
* Signals exists to stop trains rear-ending each other — but §8.3's trigger list never named
* one and none was implemented, so granting clearance was free: both trains survived, no
* penalty, and ABS Signals protected against nothing.
*
* Colliding on CATCHING UP is the version that rewards judging the gap. A following train
* that closes on the one ahead runs into it; a following train that never closes is fine, so
* clearance becomes a bet on relative speed rather than a formality. §2.1 divides the card
* into two regions, and sharing one is what "caught up" means.
*
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
*/
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
const elapsed = t.stagesTotal - t.stagesRemaining;
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
};
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
// trains because they HAVE two roads, so a train catching another there goes past it — that
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
// never once granted clearance, which is the tell: those were all passing cards.
const mayPass = mainlineProfile(node.card).trainsMayPass;
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
const ahead = mayPass
? undefined
: node.transits.find(
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
);
if (ahead) {
if (node.absSignals) {
// "Trains on this card will not rear-end each other; they stop short of a collision."
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason: 'ABS Signals — held short of the train ahead',
});
return 'held';
}
// §10 — the Superintendent let it in behind the other, so it is the Superintendent's fault.
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
return 'moved';
}
transit.stagesRemaining -= 1;
return 'moved';
}
@@ -756,25 +805,6 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
// Telegraph/Telephone/Radio are each usable once a Day.
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
/**
* THE CLASSIFICATION YARD GOES BACK INTO SERVICE OVERNIGHT.
*
* ASSUMPTION, flagged rather than derived: Gap 2c says engines and cabooses return to the
* Division Yard and everything else to Classification, and the recovered rules never say how
* Classification empties. It was write-only — seven places pushed into it and nothing ever read
* it — so rolling stock drained one way and left the game. Measured at 30 cars a game dead by
* the end, 37% of the 80 dealt at setup.
*
* Sorting cars for redistribution is what a classification yard is FOR, and a Day is its
* natural cycle, so they return at the Day boundary. This does not bind a 5-Day game — the
* Division Yard still held 16.6 loaded freight cars at the end and ran dry in 2 games of 100 —
* but a campaign-length game would have starved.
*/
if (s.yards.classificationYard.length > 0) {
s.yards.divisionYard.push(...s.yards.classificationYard);
s.yards.classificationYard = [];
}
s.clock.day += 1;
s.clock.stage = 1;
s.collisionsToday = 0;
+26
View File
@@ -1174,6 +1174,7 @@ export function reduce(s: GameState, e: GameEvent): void {
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
f.outboundBox.push(e.stock);
s.turn.freightAgentUsed = true;
break;
@@ -1236,6 +1237,7 @@ export function reduce(s: GameState, e: GameEvent): void {
(c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
);
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
tray.consist.push(e.stock);
break;
}
@@ -1674,6 +1676,30 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
return true;
}
/**
* §2 — WHEN THE DIVISION YARD RUNS OUT, THE CLASSIFICATION YARD GOES BACK INTO SERVICE.
*
* Used Rolling Stock is set out in the Classification Yard; used engines and cabooses go straight
* back to the Division Yard. The Classification Yard empties only when the Division Yard is bare —
* every car of every kind gone — and then all of it returns at once.
*
* Confirmed from the source after an earlier guess. The first implementation returned cars at the
* DAY boundary, which is a different rule and a much more generous one: it kept the yard topped up
* continuously, where this lets it run down to nothing and refill in one go. That difference is the
* whole of the supply pressure the game is meant to have.
*
* Called wherever a car leaves the Division Yard, so the refill happens the moment it empties
* rather than at the next convenient tick.
*/
export function refillDivisionYardIfEmpty(s: GameState): { type: 'yardRefilled'; count: number } | null {
if (s.yards.divisionYard.length > 0) return null;
if (s.yards.classificationYard.length === 0) return null;
const count = s.yards.classificationYard.length;
s.yards.divisionYard.push(...s.yards.classificationYard);
s.yards.classificationYard = [];
return { type: 'yardRefilled', count };
}
/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
function limitsCard(): TrackCard {
return {
+6 -1
View File
@@ -584,7 +584,12 @@ export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', co
export const MANEUVER_CARDS: readonly SimpleCard[] = [
{ key: 'redFlags', name: 'Red Flags', copies: 5, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 1, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
{ key: 'poling', name: 'Poling', copies: 1, placement: 'any time', effect: 'TBD in the source.' },
// POLING IS OUT OF THE DECK, at 0 copies rather than deleted.
//
// It is the one card whose effect the source records as "TBD", so there is nothing to implement
// and inventing something would be worse than leaving it out. Kept in the table with its text so
// the gap stays visible and the card can be dealt again the moment its rule is known.
{ key: 'poling', name: 'Poling', copies: 0, placement: 'any time', effect: 'TBD in the source.' },
];
/** Played AT other players. We have no player-interaction mechanic yet. */
+7
View File
@@ -196,6 +196,13 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
// least deterministic and varies between games; it should become a real player choice when
// setup gains an interactive phase. See implications.md §10 Q11.
if (mainlineProfile(card).speed.kind === 'grade') {
/**
* TEMPORARY, and flagged in TODO. The card prints "(Up)" and "Player sets orientation", so
* which way a Heavy Grade climbs is the player's decision — but it is dealt during setup, and
* setup has no decision point at all. Rolled from the seed until it gains one, because the
* orientation MATTERS: it decides which direction climbs, and therefore what a Brakeman or
* Helpers card is worth.
*/
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
return node;
+13 -2
View File
@@ -202,8 +202,19 @@ export type CrewTray = {
/** null while a local crew is switching without a train card. */
trainNumber: number | null;
trainIsExtra: boolean;
/** Which end the engine occupies (§A.3). */
engineFront: boolean;
/**
* WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`.
*
* A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead
* of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle
* doing both at once. That last case is why this is an index and not the boolean it replaced —
* `engineFront` was written in three places and read in none, so the engine had a position the
* game recorded and never used.
*
* The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
*/
engineAt: number;
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
consist: RollingStock[];
direction: Direction;