adding classification and division yard updates, train collisions, engine and car updates, turnout improvements.
This commit is contained in:
+52
-22
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user