v0.7.2 — a leg that is part of the row, the deck the sheet prints, regions not miles per hour, and a Division you read left to right

Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut
through a curve's south leg no longer couples it back to front. The same
assumption left a crew's own cut standing when it pulled out through a leg,
which is the "cars left behind" report we had failed to reproduce.

Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12
office doubling and Gap 12 industry tripling both come out with it: they were
measured against a deck with twice the track, and keeping them at the sheet's
track count wipes out the reefer chain entirely. 84 rows now match card for
card; the ten Safety, Event and Inspection cards it adds are not built and are
held out. Cards the sheet no longer lists are dealt zero copies rather than
deleted, so their rules stay implemented.

Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the
placement is legal. What must hold is that no train crosses the gap, which was
already true and is now pinned against the reported board.

Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed
region and where a train STARTS is what varies; Fast/Slow is read on Hilly
alone. Entering a one-region card behind another is a collision now, which is
what ABS exists to prevent, and ABS no longer holds trains silently.

Gitea#18 — the Division draws as one row, west to east, with no office-area
detail. East is finally always to the right.

Closes #3
Closes #14
Closes #15
Closes #17
Closes #18
This commit is contained in:
Jesse.Markowitz
2026-08-26 15:20:56 -04:00
parent 441447648d
commit 2ab25e320c
23 changed files with 4619 additions and 3264 deletions
+168 -28
View File
@@ -23,16 +23,16 @@ import {
enhancementRule,
crossingStages,
trainProfile,
startRegion,
MOVES_PER_LOCAL_OPS,
MOVES_PER_LOCAL_OPS_NIGHT,
STAGES_PER_DAY,
STAGES_PER_SHIFT,
houseRules,
officeProfile,
REGIONS_PER_MAINLINE_CARD,
mainlineProfile,
} from './content.ts';
import type { Direction } from './content.ts';
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
import type { GameEvent } from './events.ts';
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
// the legality test cannot disagree about which train is being assembled.
@@ -451,6 +451,104 @@ function badlyMadeUp(tray: CrewTray): string | null {
return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train';
}
/**
* WHICH REGION OF A MAINLINE CARD A TRAIN IS STANDING IN (Gitea#3).
*
* A card is `regions` boxes wide and a train advances one per Stage, so what it has LEFT to run says
* where it is: enter with `regions` still to go and you are at the beginning; enter with one to go
* and you are in the last box.
*
* This used to be derived from a single global `REGIONS_PER_MAINLINE_CARD = 2`, with an entry term
* that put a one-Stage train in region 1 of a two-region card — a fast train did not traverse a fast
* card, it appeared at the far half of it. Cards carry their own region count now, so the position
* is simply the count minus what is left.
*/
export function regionOfTransit(card: MainlineKind, stagesRemaining: number): number {
const regions = mainlineProfile(card).regions;
return Math.min(regions - 1, Math.max(0, regions - stagesRemaining));
}
/** The entry a train would make onto this card, before occupancy is taken into account. */
function entryFor(
node: Extract<DivisionNode, { kind: 'mainline' }>,
tray: CrewTray,
startsAtBack = false,
): MainlineEntry {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
return {
trainSpeed: profile?.speed ?? 'slow',
direction: tray.direction,
gradeUp: node.gradeUp ?? 'east',
modifiers: node.modifiers ?? [],
...(startsAtBack ? { startsAtBack: true } : {}),
};
}
/**
* THE UNCONTROLLED SIDING RULE (Gitea#3): "if a train already exists when you arrive, you go in the
* second stage back — you are in the siding and are one behind the other train. This prevents a
* collision, since you are not in same exact location."
*
* So arriving at an occupied siding is not a collision and not a hold; it is a different, slower
* entry. Anywhere else this returns false and the ordinary start applies.
*/
function takesTheSiding(node: Extract<DivisionNode, { kind: 'mainline' }>): boolean {
return node.card === 'uncontrolledSiding' && node.transits.length > 0;
}
/**
* IS MOVING ONTO THIS CARD A COLLISION? (Gitea#3)
*
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
* granted clearance arrives in the same region as the train ahead the moment it enters. There was no
* test for that at all: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage
* crossing never reaches, so entering behind another train on a Plains was silently free.
*
* ABS is the card that answers it, in RAR's words: "This is played on a mainline card to prevent
* collisions. If a collision would normally occur, the train moving onto the card is instead held
* back." Held, not waved through — it tries again next Stage.
*
* The Uncontrolled Siding never conflicts on entry, because `takesTheSiding` has already moved this
* train a region back; that is the whole point of the card.
*/
function entryConflict(
s: GameState,
node: Extract<DivisionNode, { kind: 'mainline' }>,
id: TrayId,
tray: CrewTray,
events: GameEvent[],
startsAtBack = false,
): 'collided' | 'held' | null {
if (mainlineProfile(node.card).trainsMayPass) return null;
const start = startRegion(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
const ahead = node.transits.find(
(t) =>
t.tray !== id &&
t.direction === tray.direction &&
regionOfTransit(node.card, t.stagesRemaining) === start,
);
if (!ahead) return null;
/**
* A BACKSTOP, not the main path. `evaluateClearance` already refuses to clear a train onto a card
* carrying ABS, so in the ordinary run of things nothing reaches here with signals up. It stays
* because the two rules answer to different questions — clearance looks at the whole Subdivision,
* this looks at one region — and a card that promises no rear-enders should not depend on the
* wider check happening to fire first.
*/
if (node.absSignals) {
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason: 'ABS Signals — held short of the train ahead',
});
return 'held';
}
// §10 — the Superintendent cleared it into an occupied region, so it is the Superintendent's fault.
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
return 'collided';
}
/** Puts a train onto a Mainline card with its crossing time already computed. */
function enterMainline(
s: GameState,
@@ -458,17 +556,9 @@ function enterMainline(
id: TrayId,
tray: CrewTray,
index: number,
startsAtBack = false,
): void {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
const stages = crossingStages(
node.card,
profile?.speed ?? 'slow',
carriesPassengers,
node.modifiers ?? [],
tray.direction,
node.gradeUp ?? 'east',
);
const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
@@ -577,6 +667,10 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (clearance === 'blocked') return 'held';
if (clearance === 'ask') return 'needsClearance';
const conflict = entryConflict(s, node, id, tray, events);
if (conflict === 'held') return 'held';
if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target);
const dp = s.division.nodes[dpIndex];
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
@@ -636,6 +730,11 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
if (clearance === 'blocked') return 'held';
if (clearance === 'ask') return 'needsClearance';
const conflict = entryConflict(s, node, id, tray, events);
if (conflict === 'held') return 'held';
// The wreck's A/D track is released by `collide` itself, which is why it has to be.
if (conflict === 'collided') return 'moved';
enterMainline(s, node, id, tray, target);
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
events.push({
@@ -696,8 +795,19 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
}
if (clearance === 'ask') return 'needsClearance';
/**
* AN EXTRA PULLING OUT OF THE INTERCHANGE STARTS IN THE BACK REGION (Gitea#3) — "interchange
* has new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is
* 1 stage for ALL trains. So are interlockings, with a second stage for incoming extras to hold
* at." A train running THROUGH the Interchange starts past that region and crosses in one
* Stage; one that began its run here has the holding region to clear first.
*/
const conflict = entryConflict(s, node, id, tray, events, true);
if (conflict === 'held') return 'held';
if (conflict === 'collided') return 'moved';
node.holding = node.holding.filter((t) => t !== id);
enterMainline(s, node, id, tray, index);
enterMainline(s, node, id, tray, index, true);
events.push({
type: 'trainHighballed',
trainNumber: tray.trainNumber ?? 0,
@@ -729,21 +839,22 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
*
* 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.
// NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS
// two roads, so a train catching another there goes past it. That is what the card is for.
//
// The Uncontrolled Siding used to be in that set and no longer is: it keeps two trains apart
// by putting the second one in the siding a region back (`takesTheSiding`), not by letting
// them share a place. Marking it "may pass" skipped this test entirely and made the siding do
// nothing at all.
const mayPass = mainlineProfile(node.card).trainsMayPass;
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
const next = regionOfTransit(node.card, transit.stagesRemaining - 1);
const ahead = mayPass
? undefined
: node.transits.find(
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
(t) =>
t.tray !== id &&
t.direction === transit.direction &&
regionOfTransit(node.card, t.stagesRemaining) === next,
);
if (ahead) {
@@ -882,10 +993,27 @@ function evaluateClearance(
// they are not, and the protection belongs where the train it protects actually is.
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
// collision". With signals in place a following train simply holds, and the Superintendent has
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
if (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked';
/**
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
* normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3).
*
* With signals in place a following train simply holds and the Superintendent has no judgment
* call to make, which is the amendment to Gap 2's unconditional collisions. It is caught HERE
* rather than at the entry itself, so the train never gets as far as the card.
*
* IT USED TO HOLD SILENTLY. A blocked clearance emits nothing on the Office and Division Point
* paths, so the one card whose entire purpose is to stop a wreck did its job invisibly: the
* train simply did not move, Stage after Stage, with nothing on screen saying why. The card is
* unplayable to reason about without this line.
*/
if (onNode?.kind === 'mainline' && onNode.absSignals) {
events.push({
type: 'trainHeld',
trainNumber: tray.trainNumber ?? 0,
reason: 'ABS Signals — held short of the train ahead',
});
return 'blocked';
}
// Same direction — the Superintendent must rule (§8.1, fourth condition).
s.clock.pendingDecision = { train: id, occupiedBy: other };
@@ -1041,6 +1169,18 @@ function collide(
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
}
/**
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
*
* It never mattered while every collision happened to a train already out on the road. Gitea#3
* adds one that can happen as a train LEAVES — a following train entering an occupied region —
* and that train is still standing at the Office when it dies. Without this its A/D track stays
* marked forever: the Office reads as permanently full, and every later arrival collides against
* a train that no longer exists.
*/
for (const [, area] of s.officeAreas) {
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
}
}
if (lost.length > 0) {
+16 -4
View File
@@ -77,6 +77,7 @@ import {
facilityVariants,
opposite,
reachableDestinations,
rowEndAt,
variantsFor,
withinLimits,
} from './track.ts';
@@ -331,6 +332,15 @@ function checkTurnoutUpgrade(existing: TrackCard, proto: TrackCard): RejectionCo
if (existing.standing.length > 0) return 'UPGRADE_OCCUPIED';
if (existing.enhancements.length > 0) return 'UPGRADE_ENHANCED';
/**
* NOTHING IS ASKED ABOUT THE NEIGHBOURS, deliberately (Gitea#15).
*
* A turnout adds a 45° leg the card underneath did not have, and that leg may well point into an
* occupied square with nothing to meet it. That is legal: RAR ruled (2026-08-26) that a rail may
* stop dead against its neighbour, and an upgrade is no different from laying the piece there in
* the first place. What must hold either way is that no train can cross the gap, which is
* `exploreMoves`' business and is tested in `track.test.ts`.
*/
return null;
}
@@ -962,7 +972,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
const on = node.modifiers ?? [];
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
if (rule.gradeOnly && node.card !== 'heavyGrade') return 'NOT_A_GRADE';
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
// restriction exists to prevent. A train standing in the Interchange's yard counts: it is on
@@ -1470,7 +1480,7 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
return cutTowards(card, carsOn(card), exit);
return cutTowards(card, carsOn(card), rowEndAt(card, exit));
}
/**
@@ -1588,14 +1598,16 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
*/
const grid = areaOf(s, player).grid;
const startCard = grid.get(coordKey(from)) ?? emptyCard();
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
const startCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, exitPort));
const lifted = [
...(startCut.length > 0 ? [from] : []),
...dest.path.map((step) => step.coord),
i.to,
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
const sides = standingSides(startCard, carsOn(startCard));
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
// The OTHER end's cut, which stays behind — so it is the other end of the row, not the
// other port. A 45° leg is an end of the row too (`rowEndAt`, Gitea#17).
const stayed = rowEndAt(startCard, exitPort) === 'e' ? sides.west : sides.east;
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
// up, they couple behind. Which end they land on is the whole point of a run-around: it
+258 -164
View File
@@ -72,6 +72,26 @@ export type TrackProfile = {
* from `docs/Deck cards2.xlsx`, a fixed document, and stay here as the audit trail for the
* transcription — they are not claims about what the game deals today.
*
* THE COUNTS BELOW NOW COME FROM `docs/Deck cards5.xlsx` (Gitea#14), which HALVES every track row
* against sheet 2: straight 32 → 16, each curve 16 → 8, each turnout 16 → 8. Track is the only
* section of that sheet whose numbers moved — every station, industry, modifier and train row is
* character-for-character what sheet 2 said — so this is the whole of the deck change it asks for.
*
* Sheet 5 also deals the sharp curves ZERO, which is where they already were: Jesse took them out
* for the reason below, and RAR arrived at the same number independently. Nothing to do, but worth
* recording that the two agree rather than leaving it looking like a coincidence.
*
* IT LANDS ON RAR'S OWN TARGETS, which is the check that matters — the top right of sheet 5 states
* the draw rates he is designing to. Against his denominators (start cards counted for track, only
* the non-track deck counted for trains): he wants track 48/167 = **28.7%** and trains 22/107 =
* **20.6%**; this deck gives 48/170 = **28.2%** and 22/110 = **20.0%**.
*
* THAT MATCH IS PARTLY A CANCELLATION, and whoever retunes next should know it. The engine holds
* ~25 more office and industry cards than the sheet (doubled and tripled, below) and is missing the
* ~33 Safety, Event, Inspection and Space-use cards sheet 5 lists, which Gitea#14 defers. The two
* errors are opposite and nearly equal today. Build the deferred categories and they stop
* cancelling, so the ratios have to be re-measured then rather than assumed to have held.
*
* It matters well beyond bookkeeping. Track competes for the draw with industry, trains and
* enhancements, so building a district is paid for in cards you did not draw instead — and the
* hand-of-three is the real constraint on how fast a railroad grows.
@@ -86,9 +106,9 @@ export type TrackProfile = {
* published replay still plays. Reordering to look tidy would silently re-deal every saved game.
*/
export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 8, isOperationalRail: true, moveCost: 1 },
/**
* SHARP CURVES ARE DEALT ZERO COPIES — Jesse's call, and the same treatment as Poling.
*
@@ -103,8 +123,8 @@ export const TRACK_CARDS: readonly TrackProfile[] = [
*/
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 0, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 8, isOperationalRail: false, moveCost: 1 },
];
/** Summed from `copiesInDeck` above, never written down — it moves whenever the deck is retuned. */
@@ -143,29 +163,39 @@ export type OfficeProfile = {
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
*/
/**
* Office cards. Every tier's `copiesInDeck` was **doubled** against the recovered design — Q12.
* Office cards, at `docs/Deck cards5.xlsx`'s counts exactly: Depot 4, Station 2, Terminal 1.
*
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
* a Depot and never escaped: they averaged **−6.0** revenue against **−0.4** for games that
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post.
* THE Q12 DOUBLING IS GONE (Gitea#14). Every tier used to be dealt at twice the sheet, to remove a
* 25% chance of an unwinnable opening deal: players always start at a Whistle Post, which has ONE
* A/D track, so a second arrival is an automatic collision (§8.3, Gap 2a), and measured at the
* sheet's density 25 of 100 games never drew a Depot and never escaped — averaging **−6.0** revenue
* against **−0.4** for games that upgraded at least once, with 25 of 26 collisions at a Whistle
* Post.
*
* That measurement was taken against a deck with 96 track cards in it, and the failure it describes
* does not survive the halving of track. RE-MEASURED at the sheet's counts, 100 games: **43 of 100**
* never upgrade off a Whistle Post, up from 25 — but they average **−0.2** revenue against **+1.4**
* for games that do upgrade, where the gap used to be −6.0 against −0.4. Collisions fell from 26 per
* 100 games to **6**, and only 3 of those are in games that never upgraded, against 25 of 26 before.
*
* So staying at a Whistle Post is now common and survivable rather than rare and fatal, which is the
* opposite of the shape Q12 was answering: with fewer trains reaching an Office, a single A/D track
* is seldom contested. The doubling was the blunt instrument its own note called it, and at the
* sheet's deck size it costs more than it buys — see `TRACK_CARDS` for the whole comparison and
* `INDUSTRY_PROFILES` for the other half of the same decision.
*
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
* their raw counts imply — Terminal needs all three cards in order.
*
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
* dilutes every other category slightly. Revisit once the victory target is settled and freight is
* carrying its intended share; the right answer may instead be fewer Terminals, a cheaper first
* upgrade, or more A/D capacity at Whistle Post. The counts themselves are in the rows below, which
* is the only place they should be read from.
* IF THE OPENING BITES AGAIN, the fix is not to re-double this. The note it replaces already listed
* the better options: fewer Terminals, a cheaper first upgrade, or more A/D capacity at a Whistle
* Post. Any of those answers the collision without diluting every other category to do it.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
];
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
@@ -202,15 +232,24 @@ export type IndustryProfile = {
};
/**
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
* prototype ran 10 in 52. At the sheet's density a game saw 1.6 Freight Facilities, freight was 10%
* of gross revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the
* point of the game, effectively never ran.
* Industry density, at `docs/Deck cards5.xlsx`'s counts exactly (Gitea#14).
*
* Each industry's `copies` is TRIPLED against the sheet, which restores roughly the prototype's
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
* lockout structure are unchanged, because every kind scales by the same factor. The multiplier is
* the decision; the resulting totals are in the rows below and move with every retune.
* THE GAP-12 TRIPLING IS GONE. The recovered sheet listed 9 industries in a 115-card deck and the
* prototype ran 10 in 52; at that density a game saw 1.6 Freight Facilities, freight was 10% of
* gross revenue, and `carsCoupled` fired 4 times per 100 games, so the freight loop effectively
* never ran. Tripling every kind restored roughly the prototype's ratio.
*
* ALL OF THAT WAS MEASURED AGAINST A DECK WITH 96 TRACK CARDS. Sheet 5 halves the track, and the
* tripling then works backwards: the deck keeps dealing industries while the district stays too
* small to reach them. Measured over 300 bot games on identical seeds — 96 track with the multiplier
* / 48 track with it / 48 track without — reefer cars set out by a crew went **49 / 0 / 39** and
* mean revenue **−0.20 / +0.22 / +0.27**. The middle column is the tripling meeting the halved
* deck: it wipes out the reefer chain completely. The sheet's own density is the best of the three
* on both counts.
*
* The sheet's proportions were always preserved by the multiplier, since every kind scaled by the
* same factor — so removing it changes the density and nothing else. The outbound/inbound balance
* and the lockout structure below are the sheet's, as they always were.
*/
/**
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
@@ -233,8 +272,8 @@ export type IndustryProfile = {
* enforced for every kind in `isLockedOut`, not repeated in each row here.
*/
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
/**
* OUTBOUND ONLY. A Refinery ships oil out and receives nothing; reported from playtesting and
* confirmed by Jesse (v0.4.9e): "only ships out tanks, does not receive anything".
@@ -252,9 +291,9 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
* the game with no way to raise the direction it is supposed to use half its capacity on.
* `StationMaster-Home-Deck-v0.4.5.md` prints it "Outbound, 1 out / 0 in".
*/
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
/**
* INBOUND ONLY — the mirror of the Refinery above, and the same correction. Reported from
* playtesting and confirmed by Jesse (v0.4.9e): "Grocer's Warehouse should be receive only, does
@@ -267,7 +306,7 @@ export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
* other example. The Truck Dock (+1 inbound) and Local Small Groceries (+1 Laborer) are the two
* that do work here.
*/
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
@@ -504,23 +543,35 @@ export type MainlineKind =
| 'uncontrolledSiding' | 'tunnel' | 'trestle' | 'interchange';
/**
* Speed as printed. `60` and `30` appear on the cards; Hilly prints P60/F30, and Heavy Grade prints
* "G" — no number at all, plus "Player sets orientation", **which the game deliberately does not do**
* (see `gradeReduction` below, and implications.md §10 Q11).
* THE PRINTED SPEEDS ARE GRAPHICS. RAR, 2026-08-26 (Gitea#3): "please ignore the speed signs I put
* on the cards — those are nothing but scene-setting graphics that mimic the speed you are
* travelling. It's just ambiance, nothing more."
*
* WHAT THESE NUMBERS MEAN IS NOT YET SETTLED — see implications.md §10 Q2. Transcribed as data so
* the answer can be applied without re-reading the cards.
* They used to decide everything: a `MainlineSpeed` of 60 meant one Stage and a 30 meant two, plus
* one more for a Slow train. Both rules are gone. **What crosses a card is REGIONS** — the boxes
* printed on it — one per Stage, and where a train STARTS decides how many it has left to run.
*/
export type MainlineSpeed =
| { kind: 'uniform'; value: number }
| { kind: 'byTrainType'; passenger: number; freight: number }
| { kind: 'grade' };
export type MainlineProfile = {
kind: MainlineKind;
name: string;
speed: MainlineSpeed;
/** Double Track and Uncontrolled Siding: "Trains may pass". */
/** Regions printed on the card. A train advances one per Stage, so a full run costs `regions`. */
regions: number;
/**
* The region an ordinary train enters at. Zero on nearly everything — but the Uncontrolled Siding
* and the Interchange print a back region that is a siding or a holding spur rather than part of
* the road, so a train running straight through starts past it and crosses in one Stage.
*/
defaultStart: number;
/**
* Cards that print a FAST and a SLOW start, and the region each begins at. "Some cards say fast /
* slow. This is an indication that if on the train card, the train is listed as fast or slow,
* that's starting position / how many stages it takes to traverse the card. Fast / Slow does not
* apply to every card — just those that say fast / slow on them. Currently this is only hilly."
*
* So the train's rating is read HERE and nowhere else. It used to add a Stage to every card.
*/
speedStarts?: { fast: number; slow: number };
/** Double Track: "Trains may pass". */
trainsMayPass: boolean;
/** Interchange: "Sort cars in new order". */
sortsCars: boolean;
@@ -529,30 +580,56 @@ export type MainlineProfile = {
};
export const MAINLINE_PROFILES: readonly MainlineProfile[] = [
{ kind: 'plains', name: 'Plains', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'curves', name: 'Curves', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'hilly', name: 'Hilly', speed: { kind: 'byTrainType', passenger: 60, freight: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['passenger', 'freight'] },
{ kind: 'plains', name: 'Plains', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'curves', name: 'Curves', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
/**
* `entryPoints` is TRANSCRIBED, NOT READ — nothing anywhere reads this field on any profile, and
* the printed start positions are not modelled: `crossingStages` counts Stages instead. Recorded
* here because implications.md §6 describes the card as having FIVE distinct starts (plain,
* brakemen, airbrakes, plain, helpers) against the four listed, and that discrepancy should be
* settled against `Mainline Cards.pdf` if the starts are ever implemented — not quietly "fixed"
* now, when nothing depends on it either way.
* THE ONLY CARD THAT READS A TRAIN'S FAST/SLOW RATING. A fast train starts in the second region
* and is across in one Stage; a slow one starts at the beginning and takes two.
*
* It used to read the CONSIST instead — any coach aboard made the train "passenger" for this card
* — off the printed P60/F30. RAR corrected that directly: "I notice that you are basing stages in
* mainline cards off coach/non-coach. Actually, all trains are rated as FAST and SLOW."
*/
{ kind: 'heavyGrade', name: 'Heavy Grade', speed: { kind: 'grade' }, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
{ kind: 'doubleTrack', name: 'Double Track', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', speed: { kind: 'uniform', value: 60 }, trainsMayPass: true, sortsCars: false, entryPoints: ['noPass', 'passingTrains'] },
{ kind: 'tunnel', name: 'Tunnel', speed: { kind: 'uniform', value: 30 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'trestle', name: 'Trestle', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'hilly', name: 'Hilly', regions: 2, defaultStart: 0, speedStarts: { fast: 1, slow: 0 }, trainsMayPass: false, sortsCars: false, entryPoints: ['fast', 'slow'] },
/**
* RENAMED FROM "Yard" after play. The card is unchanged — same 60, same "sort cars in new
* order", same entry points, same art — but "Yard" collided with the Division Yard, the
* Classification Yard, the Salvage Yard, the Yard Office and the Small Yard, none of which are
* this. Its own key is renamed with it, so the two never drift apart. Those OTHER yards are
* deliberately left alone: they are different things that merely shared a word.
* THREE REGIONS, AND THE MODIFIERS MOVE THE START RATHER THAN CUTTING THE TIME — which comes to
* the same number of Stages and is how the card is actually printed and played. "If you play the
* home deck card 'helpers' against the mainline card heavy grade, it remains there the rest of the
* game and helps all trains going up hill by starting 1 region easier — so 2 to traverse, not 3.
* Other cards help the other direction, similar idea. Airbrakes is an upgrade from brakemen (which
* must be played first)."
*
* So: Helpers moves an UPHILL train up one region; Brakeman moves a DOWNHILL train up one, and
* Airbrakes another on top of it. A fully-equipped grade is one Stage downhill and two up.
*/
{ kind: 'interchange', name: 'Interchange', speed: { kind: 'uniform', value: 60 }, trainsMayPass: false, sortsCars: true, entryPoints: ['start', 'sortCars'] },
{ kind: 'heavyGrade', name: 'Heavy Grade', regions: 3, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start', 'brakemen', 'airbrakes', 'helpers'] },
{ kind: 'doubleTrack', name: 'Double Track', regions: 1, defaultStart: 0, trainsMayPass: true, sortsCars: false, entryPoints: ['start'] },
/**
* TWO REGIONS, AND THE BACK ONE IS THE SIDING. A train with the card to itself starts past it and
* crosses in one Stage. "Uncontrolled siding: if a train already exists when you arrive, you go in
* the second stage back (you are in the siding and are one behind the other train). This prevents
* a collision — since you are not in same exact location."
*
* `trainsMayPass` is FALSE here, and used to be true. Two trains fit, but not by passing: the
* second one takes the siding and sits a region behind, which is what keeps them apart. Leaving it
* true skipped the collision test altogether and made the siding do nothing at all.
*/
{ kind: 'uncontrolledSiding', name: 'Uncontrolled Siding', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: false, entryPoints: ['through', 'siding'] },
{ kind: 'tunnel', name: 'Tunnel', regions: 2, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
{ kind: 'trestle', name: 'Trestle', regions: 1, defaultStart: 0, trainsMayPass: false, sortsCars: false, entryPoints: ['start'] },
/**
* RENAMED FROM "Yard" after play. The card is unchanged — same "sort cars in new order", same art
* — but "Yard" collided with the Division Yard, the Classification Yard, the Salvage Yard, the
* Yard Office and the Small Yard, none of which are this. Its own key is renamed with it, so the
* two never drift apart. Those OTHER yards are deliberately left alone: they are different things
* that merely shared a word.
*
* TWO REGIONS, the back one a holding spur, exactly as the Uncontrolled Siding: "interchange has
* new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is 1 stage
* for ALL trains. So are interlockings, with a second stage for incoming extras to hold at." A
* train running through crosses in one Stage; an Extra beginning its run here starts at the back.
*/
{ kind: 'interchange', name: 'Interchange', regions: 2, defaultStart: 1, trainsMayPass: false, sortsCars: true, entryPoints: ['through', 'extraStart'] },
];
/**
@@ -579,44 +656,63 @@ export const MAINLINE_DECK: readonly MainlineKind[] = [
];
/**
* How many Stages a train needs to cross a Mainline card.
* WHERE A TRAIN ENTERS A MAINLINE CARD, and therefore how long it takes to cross (Gitea#3).
*
* Q1 — the printed 60/30 are miles per hour expressed as crossing time: a 60 card takes one Stage,
* a 30 card takes two. The cells drawn on the cards are decoration.
* Q2 — a Slow train adds one Stage to every card.
* "Regions shown on cards indicate how many stages it takes to cross. Plains is 1. Double track is
* 1, tunnel is 2, curves is 2, heavy grade is 3 unless you have help." A train advances one region
* per Stage, so the whole of crossing time is `regions - startRegion`.
*
* Hilly prints P60/F30, so it reads the consist rather than the speed class: a train carrying any
* coach is "passenger" for this purpose.
* FOUR THINGS MOVE THE START, and nothing else does:
*
* 1. the card's own `defaultStart` — the Uncontrolled Siding and the Interchange print a back
* region that is not part of the road, so a train running through begins past it;
* 2. `speedStarts`, on a card that prints a Fast and a Slow start. Only Hilly does;
* 3. the permanent Heavy Grade modifiers, which move a train one region up the hill each;
* 4. `takesSiding` / `startsAtBack`, the two occupancy cases below.
*
* WHAT NO LONGER MOVES IT: the printed mph, which is now scenery, and a train's Fast/Slow rating on
* any card but Hilly. That rating used to add a Stage to EVERY card, which is what made a Slow train
* cross Double Track in two Stages and produced the report this issue opened with.
*/
export function crossingStages(
kind: MainlineKind,
trainSpeed: TrainSpeed,
carriesPassengers: boolean,
modifiers: readonly string[] = [],
direction: Direction = 'east',
gradeUp: Direction = 'east',
): number {
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
export type MainlineEntry = {
trainSpeed: TrainSpeed;
direction: Direction;
gradeUp: Direction;
modifiers: readonly string[];
/**
* The Uncontrolled Siding with a train already on it: this one takes the siding and sits a region
* behind, which is what keeps them out of the same place. Also the Interchange, where an Extra
* beginning its run starts in the holding region rather than on the road.
*/
startsAtBack?: boolean;
};
let mph: number;
switch (profile.speed.kind) {
case 'uniform':
mph = profile.speed.value;
break;
case 'byTrainType':
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
break;
case 'grade':
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
// until one is placed.
mph = 30;
break;
export function startRegion(kind: MainlineKind, entry: MainlineEntry): number {
const profile = mainlineProfile(kind);
if (entry.startsAtBack) return 0;
const base = profile.speedStarts
? profile.speedStarts[entry.trainSpeed]
: profile.defaultStart;
const climbing = entry.direction === entry.gradeUp;
let help = 0;
if (kind === 'heavyGrade') {
if (climbing) {
if (entry.modifiers.includes('helpers')) help++;
} else {
// Airbrakes is an upgrade on Brakeman and cannot be played without it, so this counts both.
if (entry.modifiers.includes('brakeman')) help++;
if (entry.modifiers.includes('airbrakes')) help++;
}
}
// Never past the last region: a card always costs at least one Stage to cross.
return Math.min(profile.regions - 1, base + help);
}
const base = mph >= 60 ? 1 : 2;
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
/** How many Stages a train needs to cross a Mainline card — the regions it has left to run. */
export function crossingStages(kind: MainlineKind, entry: MainlineEntry): number {
return mainlineProfile(kind).regions - startRegion(kind, entry);
}
/**
@@ -634,35 +730,50 @@ export function crossingStages(
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
const p = mainlineProfile(kind);
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
const run = (entry: Partial<MainlineEntry>): number =>
crossingStages(kind, { trainSpeed: 'fast', direction: 'east', gradeUp, modifiers: [], ...entry });
const parts: string[] = [];
if (p.speed.kind === 'byTrainType') {
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
// takes the passenger figure.
parts.push(`${p.regions} region${p.regions === 1 ? '' : 's'} — one Stage each.`);
if (p.speedStarts) {
parts.push(
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
`A slow train adds one Stage either way.`,
`This card reads the train's FAST/SLOW rating: a fast train starts further along and crosses ` +
`in ${stages(run({ trainSpeed: 'fast' }))}, a slow one in ${stages(run({ trainSpeed: 'slow' }))}. ` +
`No other card cares which it is.`,
);
} else if (p.speed.kind === 'grade') {
} else if (kind === 'heavyGrade') {
parts.push(
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. ` +
`${stages(run({ direction: gradeUp }))} to climb it and ${stages(run({ direction: gradeUp === 'east' ? 'west' : 'east' }))} to run down, ` +
`before help. Helpers start an UPHILL train a region further on; Brakeman does the same ` +
`DOWNHILL and Airbrakes another again, and Airbrakes cannot be played without Brakeman. ` +
`Never less than one Stage.`,
);
} else if (p.defaultStart > 0) {
parts.push(
`A train with the card to itself starts past the back region and is across in ` +
`${stages(run({}))}.`,
);
} else {
parts.push(`${stages(run({}))} for every train — the printed speed is scenery.`);
}
if (kind === 'uncontrolledSiding') {
parts.push(
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
'UNCONTROLLED SIDING — arrive to find a train already here and you take the siding, a region ' +
'behind it. You are not in the same place, so you do not run into it; it costs you the ' +
'extra Stage instead.',
);
}
if (kind === 'interchange') {
parts.push('An Extra beginning its run here starts in the back region and takes the extra Stage.');
}
if (p.trainsMayPass) {
parts.push(
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
'behind a slower one. Only this and the Double Track allow it.',
'behind a slower one.',
);
} else {
parts.push('One train at a time — anything following has to wait for it to clear.');
@@ -672,43 +783,7 @@ export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'ea
return parts.join(' · ');
}
/**
* WHICH WAY THE GRADE CLIMBS, AND WHY NO PLAYER CHOOSES IT.
*
* Q11, answered from the card: Heavy Grade prints "(Up)" and "Player sets orientation", so the climb
* is a property of the PLACED CARD rather than a compass constant. `gradeUp` is the direction a train
* is travelling when it goes UPHILL; a train heading the other way is descending.
*
* **The second half of that print is deliberately overridden.** No player sets it — `setup.ts` rolls
* it from the seed. Settled v0.5.0 and re-confirmed 2026-08-23 after the question was raised again:
* a Heavy Grade always sits BETWEEN two districts (or beyond an end Division Point next to one),
* never inside one player's own, so there is no player with a fair claim to the choice — and the
* choice is not cosmetic, because it decides which of the three modifiers below can ever pay and
* therefore which direction of traffic is favoured, permanently. Giving it to the Superintendent was
* considered and rejected in that re-examination: the office rotates every three Stages, the
* advantage does not. Full reasoning in implications.md §10 Q11.
*
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces
* (`MAINLINE_MODIFIER_RULES`, `requiresOnCard`).
*/
function gradeReduction(
profile: MainlineProfile,
modifiers: readonly string[],
direction: Direction,
gradeUp: Direction,
): number {
if (profile.speed.kind !== 'grade') return 0;
const downhill = direction !== gradeUp;
let n = 0;
if (downhill) {
if (modifiers.includes('brakeman')) n++;
if (modifiers.includes('airbrakes')) n++;
} else if (modifiers.includes('helpers')) {
n++;
}
return n;
}
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
@@ -769,7 +844,8 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [
{ key: 'flopHouse', name: 'Flop house', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'watertower', name: 'Watertower', copies: 1, placement: 'adjacent to any straight, turnout on Running Track', effect: 'Burns tablespace.' },
{ key: 'hoboJungle', name: 'Hobo Jungle', copies: 1, placement: 'adjacent to any straight, turnout, Limit on Running Track', effect: 'Burns tablespace. Vandalism can loot a boxcar passing it.' },
{ key: 'sectionHouse', name: 'Section House', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26), the same treatment as the ladder.
{ key: 'sectionHouse', name: 'Section House', copies: 0, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'cityBlocks', name: 'City blocks', copies: 4, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
{ key: 'engineShops', name: 'Engine Shops', copies: 1, placement: 'adjacent to any straight, curve, turnout', effect: 'Burns tablespace.' },
{ key: 'tenderloin', name: 'Tenderloin District', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
@@ -876,16 +952,32 @@ export function enhancementRule(key: string): EnhancementRule | null {
}
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'interlocking', name: 'Interlocking', copies: 1, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). It answers Derail, which is itself an
// Event held out until built, so at zero it defends against nothing that can be dealt anyway.
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'waterColumn', name: 'Water column', copies: 1, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
{ key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
{ key: 'absSignals', name: 'ABS Signals', copies: 2, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
/**
* THE DISPATCHING LADDER IS OUT OF THE DECK, at 0 copies rather than deleted — the treatment
* Poling and the sharp curves already get, and for the same reason.
*
* `docs/Deck cards5.xlsx` does not list Telegraph, Telephone or Radio at any count, and **Jesse
* confirmed (2026-08-26) that the removal is deliberate, not a row that failed to carry across**
* from sheet 2. So no copy is dealt, which is what the sheet asks for.
*
* The rows and `ENHANCEMENT_RULES`' `dispatchBonus` chain stay exactly where they are. The rule
* is implemented and tested — `advance.ts` reads the ladder when the Superintendent dispatches
* facing trains, best device first — and deleting working machinery to express a count of zero
* would throw away the only record of how it worked. At zero copies the code is unreachable: no
* card is ever dealt, so nothing ever places one, so the bonus never applies.
*/
{ key: 'telegraph', name: 'Telegraph', copies: 0, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
{ key: 'telephone', name: 'Telephone', copies: 0, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
{ key: 'radio', name: 'Radio', copies: 0, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
{ key: 'absSignals', name: 'ABS Signals', copies: 1, placement: 'any Mainline card', effect: 'Trains on this card will not rear-end each other; they stop short of a collision.' },
];
export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
@@ -893,7 +985,8 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26); see the Enhancement of the same name.
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 0, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
];
/**
@@ -904,8 +997,9 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
export const SECOND_SECTION = { key: 'secondSection', name: 'Second Section', copies: 1 };
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: 'redFlags', name: 'Red Flags', copies: 3, placement: 'any time', effect: 'A stopped train is prevented from being hit; the approaching train is prevented from moving.' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26). The reducer stays; nothing can reach it.
{ key: 'flyingSwitch', name: 'Flying Switch', copies: 0, placement: 'any time', effect: 'Break a cut of cars away from behind the engine and roll them into an industry.' },
// 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
@@ -922,7 +1016,8 @@ export const ACTION_CARDS: readonly SimpleCard[] = [
{ key: 'perDiemInventory', name: 'Per Diem inventory', copies: 1, placement: 'another player', effect: 'Lose one point per 2 empty cars on Secondary Tracks.' },
{ key: 'demurrageCharge', name: 'Demurrage charge', copies: 1, placement: 'another player', effect: 'Lose one point per 2 loaded freight cars on Secondary Tracks.' },
{ key: 'customerComplaints', name: 'Customer complaints', copies: 1, placement: 'another player', effect: 'Lose one point per 2 coaches in loading boxes.' },
{ key: 'vandalism', name: 'Vandalism', copies: 1, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
// Not in sheet 5 — dealt 0 copies (Jesse, 2026-08-26).
{ key: 'vandalism', name: 'Vandalism', copies: 0, placement: 'another player', effect: 'A train passing a Hobo Jungle has a boxcar looted (converted to empty).' },
{ key: 'hotbox', name: 'Hotbox', copies: 1, placement: 'another player', effect: 'A train just arrived must set one car (chooser’s pick) onto Secondary Track until it departs.' },
{ key: 'outlawed', name: 'Outlawed', copies: 1, placement: 'another player', effect: 'A train just arrived may not depart for one turn — the crew’s hours have expired.' },
];
@@ -972,7 +1067,6 @@ export function crewTrayCount(players: number): number {
return players + 3;
}
export const REGIONS_PER_MAINLINE_CARD = 2; // provisional, pending §10 Q2
export const STAGES_PER_DAY = 12;
export const STAGES_PER_SHIFT = 3;
export const HAND_LIMIT = 3;
+1 -1
View File
@@ -246,7 +246,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
if (deck.length === 0) throw new Error('the Mainline deck ran out — too many players for it');
const card = deck.splice(rng.nextInt(deck.length), 1)[0]!;
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
if (mainlineProfile(card).speed.kind === 'grade') {
if (card === 'heavyGrade') {
/**
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
+12 -9
View File
@@ -771,22 +771,25 @@ export function standingSides(
}
/**
* The cut a train would run into if it left this card through `exit` — the cars between it and that
* end of the card.
* The cut a train would run into if it left this card by the `exit` END OF THE ROW — the cars
* between it and that end. Returned in the order the train MEETS them, nearest first, which is what
* `carsCoupled` wants.
*
* Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
* train MEETS them, nearest first, which is what `carsCoupled` wants.
* `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet
* nothing" for north and south on the reasoning that a leg leaving through an edge is not running
* along the west-to-east row. It is: a `sw` curve's south leg IS the east end of that row, so a
* crew standing on the curve pulled out through the leg and drove away leaving the cars beside it
* standing, against §A.4's mandatory coupling (Gitea#17). Callers resolve the leg with `rowEndAt`
* (`track.ts`), which lives there because only the card's arc can say which end a leg is — and the
* narrowed type is what makes every caller do it.
*/
export function cutTowards(
tray: { standingWest?: number | undefined },
cars: readonly RollingStock[],
exit: 'n' | 's' | 'e' | 'w',
exit: 'e' | 'w',
): RollingStock[] {
const { west, east } = standingSides(tray, cars);
if (exit === 'e') return east;
if (exit === 'w') return [...west].reverse();
return [];
return exit === 'e' ? east : [...west].reverse();
}
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
+63 -6
View File
@@ -188,6 +188,40 @@ export function joins(a: TrackCard, p: Port, b: TrackCard): boolean {
return slopeAt(a, p) === slopeAt(b, opposite(p));
}
/**
* WHICH END OF THE WEST-TO-EAST ROW A PORT SITS AT.
*
* `TrackCard.standing` is ordered west to east (§A.3), so whether a train meets the row front to
* back or back to front depends on which end it enters by — and a port is not always at one of
* those two extremes. Every 45° leg leaves through the MIDDLE of its north or south edge, so its
* end of the run is whichever end the arc does NOT reach: a `sw` curve's south leg is the EAST end
* of the row, and an `se` curve's south leg is the WEST end. Same port, opposite answers, which is
* why this has to ask the card rather than read the port.
*
* Gitea#17 is what both callers looked like without it. `exploreMoves` reversed the row for an 'e'
* entry and for nothing else, so backing into a cut through a `sw` curve's south leg coupled it up
* back to front — the caboose came out next to the engine, which §8.2 then calls badly made up.
* `cutTowards` answered "you meet nothing" for a north or south exit, so a crew standing on a curve
* pulled out through the leg and left the cars beside it standing, which §A.4 forbids.
*
* There is no north-south straight anywhere on the printed sheet (see the module comment), so a run
* touching a 45° leg always has an east or west port at its other end and the answer is never
* undefined. A TURNOUT is the one card whose row has three ends rather than two — and it is also
* the one card no cut can ever stand on, since a train may not stop there (§A.1) and so never sets
* anything out there. Its stem answers for it.
*/
export function rowEndAt(card: TrackCard, p: Port): 'e' | 'w' {
if (p === 'e' || p === 'w') return p;
for (const [a, b] of connectionsFor(card)) {
const other = a === p ? b : b === p ? a : null;
if (other === 'e') return 'w';
if (other === 'w') return 'e';
}
// Not a card the printed sheet can produce. Reading the leg as the west end leaves the row in the
// order it is stored rather than inventing a reversal on a card nothing knows the shape of.
return 'w';
}
// ---------------------------------------------------------------------------
// Orientation (Gap 11)
// ---------------------------------------------------------------------------
@@ -450,7 +484,7 @@ export function exploreMoves(
*
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
*/
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
const ownCut = cutTowards(startCard, carsOn(startCard), rowEndAt(startCard, initialExit));
const startKey = coordKey(start);
const queue: Frontier[] = [
{
@@ -502,12 +536,15 @@ export function exploreMoves(
* overfill the tray is illegal, not a move that picks up fewer cars.
*
* NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering
* through the card's EAST port meets them back to front and the row has to be reversed. Without
* this the same parked cut produced an identical consist whichever way it was approached, when
* the two must mirror — which is the difference between a run-around being worth a Move and
* being pointless.
* at the row's EAST end meets them back to front and the row has to be reversed. Without this
* the same parked cut produced an identical consist whichever way it was approached, when the
* two must mirror — which is the difference between a run-around being worth a Move and being
* pointless.
*
* `rowEndAt` rather than `node.entry === 'e'`: a 45° leg is an end of the row too, and which
* end it is depends on the card's arc (Gitea#17).
*/
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
const met = rowEndAt(card, node.entry) === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
const couples = [...node.couples, ...met];
const nodeKey = coordKey(node.coord);
const origins = [...node.origins, ...met.map(() => nodeKey)];
@@ -658,6 +695,26 @@ export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard):
// into a stub and cutting the Office off from the Limits.
if (coord.row === area.runningRow && !carriesThroughTrack(card)) return false;
/**
* ONE NEIGHBOUR MUST JOIN. THE OTHERS NEED NOT — AND THIS RULE HAS BEEN BOTH WAYS (Gitea#15).
*
* A card may be laid with an exit facing a card that has nothing to meet it. The rail stops dead
* at that edge, and that is legal.
*
* The issue was filed the other way round — "if a card is placed in that space, it MUST connect" —
* against a right-hand curve laid with its north leg against an Ice House and the turnout below it
* pointing at its portless south edge. **RAR reversed it on review (2026-08-26): placing it is
* fine, and a stub like that is useful — a siding to park cars on.**
*
* WHAT MATTERS INSTEAD IS THAT NOTHING CAN DRIVE ACROSS THE GAP, so the real requirement is on
* MOVEMENT rather than on placement: two cards touching are not connected, and `exploreMoves` must
* refuse the hop. It does — every step is gated on `joins`, never on a bare pair of `hasPort`
* calls — and `track.test.ts` pins the reported geometry against exactly that.
*
* SO DO NOT ADD A PER-EDGE CHECK HERE. One was written and taken out again when the ruling
* arrived. What survives is the weaker rule that was always here: the piece must touch the network
* SOMEWHERE, which is what stops orphaned track being laid in an empty corner of the board.
*/
const ports: Port[] = ['n', 's', 'e', 'w'];
for (const p of ports) {
const neighbourCard = cardAt(area, neighbour(coord, p));
+190 -157
View File
@@ -65,7 +65,30 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* anything outside its own body.
*/
const CW = { dp: 118, ml: 152, run: 78 };
const CH = 58;
/**
* TALL ENOUGH FOR TWO REGISTERS OF CHIPS, on every cell so the rail runs level across the row.
* Was 58, when a cell held one row of trains.
*/
const CH = 76;
/**
* EVERY DISTRICT THE SAME WIDTH, sized for four chips two-by-two and NOT for its A/D count.
*
* Measured over 60 games: one office area holds at most 4 distinct trains, and up to 3 of those
* can be crews switching below the Running Track — which do not occupy A/D tracks at all. So a
* Whistle Post, with its single A/D track, can still have four trains to show, and sizing the cell
* by capacity would overflow it. Sizing by OCCUPANCY is worse still: that is what "The Roster
* Pass" fixed, because the cell then resizes as trains come and go and shoves the rest of the map
* sideways. A fixed two-by-two block holds the map still all game, upgrades included.
*/
const OFFICE_W = 2 * 54 + 12;
/**
* THE VERTICAL ANATOMY OF A CELL, so the two chip registers and the rail cannot drift apart.
* The rail sits above centre; A/D chips straddle it, and the district register hangs below —
* which is where those trains are on the real board (Gitea#18).
*/
const RAIL_Y = 34;
const CHIP_Y = RAIL_Y - 10;
const BELOW_Y = RAIL_Y + 13;
const GAP = 6;
/**
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
@@ -115,6 +138,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
/**
* Office cells only: trains in the district that are NOT holding an A/D track — a crew switching
* below the Running Track, or a train standing on it away from the Office. Drawn in a second
* register under the rail (Gitea#18).
*/
below?: Cell['trains'];
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number;
w: number;
@@ -122,11 +151,8 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
y: number;
};
const cells: Cell[] = [];
const sides: number[][] = [];
let side: number[] = [];
const push = (c: Omit<Cell, 'x' | 'y'>): void => {
side.push(cells.length);
cells.push({ ...c, x: 0, y: 0 });
};
@@ -152,47 +178,54 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
}
: null;
for (const rc of n.running ?? []) {
const isOffice = rc.kind === 'office';
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
push({
kind: 'run',
label: isOffice && owner ? owner.name : rc.label,
owner: isOffice ? owner : null,
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
// count on the line below.
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
/**
* A train standing at the Office occupies an A/D track, which is where it is — but it is
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be
* drawn twice. Reported as two T10 chips on one Office.
*/
trains: isOffice
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
: rc.trains,
cap: isOffice ? cap : null,
tip: owner && isOffice
? `${owner.name}'s ${rc.label}` +
(owner.isYou ? ' — this is your railroad' : '') +
// "their move" is wrong when the reader is the one being waited on.
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
seat: n.seat ?? null,
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one.
regions: 0,
w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run,
});
/**
* ONE CELL PER DISTRICT — NO OFFICE-AREA DETAIL ON THIS MAP (Gitea#18).
*
* An Office used to expand into its whole Running Track, Limits to Limits, so this map carried
* every straight, turnout, facility and Limits sign of every district. Two things were wrong
* with that. It is the OFFICE map's job, and it draws all of it properly, with the rails; and
* it made the Division map grow sideways as districts were built, shoving everything east of a
* district along every time somebody laid a card.
*
* TRAINS STAY. "Trains within the office area should definitely be represented on the division
* map" — at a glance the number and which way it is pointing, and the consist on the tooltip.
* They are split into two registers, because a train holding an A/D track and a crew switching
* in the district are not the same thing: A/D occupancy is a hard capacity that causes
* collisions, switching is not. The split is drawn as POSITION rather than colour — A/D on the
* rail, the rest below it — which is where those trains actually are.
*/
const seen = new Set(ad.map((t) => t.label));
const below: typeof ad = [];
for (const t of [...(n.running ?? []).flatMap((rc) => rc.trains), ...(n.switching ?? [])]) {
if (seen.has(t.label)) continue;
seen.add(t.label);
below.push(t);
}
// A crew below the Running Track has no position ON it, so it is reported against the
// district rather than drawn somewhere it is not.
const below = n.switching ?? [];
if (below.length > 0) {
const last = cells[cells.length - 1];
if (last) last.sub = `${below.length} switching below`;
}
sides.push(side);
side = [];
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
push({
kind: 'run',
label: owner ? owner.name : n.label,
owner,
sub: [owner ? n.label : '', adLabel, below.length > 0 ? `${below.length} switching` : '']
.filter(Boolean)
.join(' \u00b7 '),
trains: ad,
below,
cap,
tip:
(owner ? `${owner.name}'s ${n.label}` : n.label) +
(owner?.isYou ? ' — this is your railroad' : '') +
// "their move" is wrong when the reader is the one being waited on.
(owner?.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '') +
`\n\nThe district itself is drawn on the Office map — this cell is the whole of it, with the ` +
`trains standing in it: those holding an A/D track on the rail, and any crew switching in ` +
`the district below it.`,
seat: n.seat ?? null,
// No regions in a district: a crew moves by Moves there, not by Stages, so it occupies a
// card outright rather than a part of one.
regions: 0,
w: OFFICE_W,
});
continue;
}
const dp = n.kind === 'dp';
@@ -229,58 +262,31 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
w: dp ? CW.dp : CW.ml,
});
}
if (side.length > 0) sides.push(side);
// Each player's side carries their district and the Mainline card leading into it; whatever is
// left over (the last Mainline and the East DP) joins the final side.
const seats = Math.max(1, Math.min(4, nodes.filter((n) => n.kind === 'office').length));
const lanes: number[][] = [];
for (let i = 0; i < seats; i++) lanes.push([]);
sides.forEach((grp, i) => {
const target = Math.min(i, seats - 1);
for (const idx of grp) lanes[target]!.push(idx);
});
// -- lay the sides out around the table -------------------------------------------------------
// top → right → bottom (reversed) → left (reversed), which gives a row, two facing rows, a
// horseshoe open to the west, and a square broken at the same place.
const dir: ('top' | 'right' | 'bottom' | 'left')[] =
seats === 1 ? ['top'] : seats === 2 ? ['top', 'bottom'] : seats === 3 ? ['top', 'right', 'bottom'] : ['top', 'right', 'bottom', 'left'];
const runLen = (idxs: number[]): number =>
idxs.reduce((n, i) => n + cells[i]!.w + GAP, -GAP);
const widest = Math.max(...lanes.map((l) => runLen(l)), 200);
const tall = lanes.length > 1 ? Math.max(...lanes.map((l) => l.length), 1) * (CH + GAP) : CH;
const vertCount = dir.filter((d) => d === 'right' || d === 'left').length;
const boardW = PAD * 2 + widest + (vertCount > 0 ? CW.run + SIDE_GAP : 0);
const boardH = PAD * 2 + (dir.includes('bottom') ? CH * 2 + SIDE_GAP + (vertCount ? tall : 0) : CH) + 30;
lanes.forEach((idxs, i) => {
const d = dir[i]!;
if (d === 'top' || d === 'bottom') {
const y = d === 'top' ? PAD : boardH - PAD - CH - 22;
const order = d === 'bottom' ? [...idxs].reverse() : idxs;
let x = PAD;
for (const idx of order) {
const c = cells[idx]!;
c.x = x;
c.y = y;
x += c.w + GAP;
}
} else {
const x = d === 'right' ? boardW - PAD - CW.run : PAD;
const order = d === 'left' ? [...idxs].reverse() : idxs;
let y = PAD + CH + SIDE_GAP;
for (const idx of order) {
const c = cells[idx]!;
c.x = x;
c.y = y;
c.w = CW.run;
y += CH + GAP;
}
}
});
/**
* ONE ROW, WEST TO EAST (Gitea#18). The West Division Point is at the far left, the East at the
* far right, and nothing wraps.
*
* IT USED TO BE LAID OUT AROUND A TABLE — one row for a single seat, two facing rows for two, a
* horseshoe for three, a square for four — on the reasoning that players sit around a table so the
* route should too. That cost more than it bought, and three separate reports came out of it: the
* buffer stops pointed the wrong way once the route turned a corner, and, the one that decided it,
* **east stopped being to the right**. A player's east could be drawn south, west or north
* depending on which lane their district landed in, on a map whose whole job is saying which way
* a train is going.
*
* A row is wider than a square — roughly 1,580px at four players against 842 — and that is
* accepted: the map scrolls and zooms, and being able to rely on east meaning right is worth the
* scroll.
*/
let x = PAD;
for (const c of cells) {
c.x = x;
c.y = PAD;
x += c.w + GAP;
}
const boardW = x - GAP + PAD;
const boardH = PAD * 2 + CH + 30;
// -- draw -------------------------------------------------------------------------------------
const rail = (x1: number, y: number, x2: number): string => {
@@ -295,20 +301,29 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
return o;
};
// The same rail turned through ninety degrees, for the sides of the table.
const railV = (x: number, y1: number, y2: number): string => {
let o =
`<line class="bs-rail" x1="${x - 2.5}" y1="${y1}" x2="${x - 2.5}" y2="${y2}"/>` +
`<line class="bs-rail" x1="${x + 2.5}" y1="${y1}" x2="${x + 2.5}" y2="${y2}"/>`;
const n = Math.max(2, Math.floor(Math.abs(y2 - y1) / 9));
for (let i = 0; i <= n; i++) {
const ty = y1 + ((y2 - y1) * i) / n;
o += `<line class="bs-tie" x1="${x - 4.5}" y1="${ty}" x2="${x + 4.5}" y2="${ty}"/>`;
}
return o;
};
let out = `<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" preserveAspectRatio="xMinYMin meet">`;
/**
* DRAWN AT ITS OWN SIZE, SO IT SCROLLS RATHER THAN SHRINKING (Gitea#18).
*
* An SVG has a viewBox and a drawn size, and the browser scales one to the other. `.bs` is
* `width:100%`, so the map is drawn at whatever the panel is wide — which was harmless while the
* Division was 842px and wrapped around a table, and is not now that a single row is 1,580px. At
* that width in an 800px panel every label renders at half size, on the map that needs reading
* most. Setting the width to the viewBox width makes one unit one pixel, and the containers
* already scroll (`#division`, `#vdivision`).
*
* THE PLAYABLE PAGE DOES NOT NEED THIS — `applyZoom` (`main.ts`) sets exactly the same width from
* the same viewBox after every render, and overrides this when the zoom is not 100%. THE REPLAYS
* DO: neither `replays.ts` nor the standalone `replay.ts` calls it, so without this they get the
* `width:100%` shrink. It is inline rather than in `BOARD_CSS` because only this function knows
* how wide the row came out.
*
* `flex:none` because `#division` is a flex container and a flex item may be shrunk below an
* explicit width; there is no point pinning it and then letting the panel squeeze it anyway.
*/
let out =
`<svg class="bs bs-div" viewBox="0 0 ${Math.ceil(boardW)} ${Math.ceil(boardH)}" ` +
`style="width:${Math.ceil(boardW)}px;flex:none" ` +
`preserveAspectRatio="xMinYMin meet">`;
// The joins between consecutive cells, drawn as rail so a connection is rail meeting rail. A join
// that crosses from one player's side to the next is drawn heavier and labelled: that boundary is
@@ -316,23 +331,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
for (let i = 0; i + 1 < cells.length; i++) {
const a = cells[i]!;
const b = cells[i + 1]!;
const sameRow = Math.abs(a.y - b.y) < 1;
const sameCol = Math.abs(a.x - b.x) < 1;
if (sameRow && b.x > a.x) out += rail(a.x + a.w, a.y + CH / 2, b.x);
else if (sameRow && b.x < a.x) out += rail(b.x + b.w, a.y + CH / 2, a.x);
else if (sameCol) {
// Stacked down one side of the table: still one straight run of track, not a turn.
const top = Math.min(a.y + CH, b.y + CH);
const bot = Math.max(a.y, b.y);
out += railV(a.x + a.w / 2, top, bot);
} else {
// A turn between sides: an elbow, so the route is visibly continuous around the table.
const ax = a.x + a.w / 2;
const bx = b.x + b.w / 2;
const ay = a.y + CH;
const by = b.y;
out += `<path class="bs-turn" d="M${ax} ${ay} L${ax} ${(ay + by) / 2} L${bx} ${(ay + by) / 2} L${bx} ${by}"/>`;
}
out += rail(a.x + a.w, a.y + RAIL_Y, b.x);
}
cells.forEach((c) => {
@@ -348,15 +347,14 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
const suffix = c.owner?.isYou ? ' (you)' : '';
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
out += rail(c.x + 6, c.y + RAIL_Y, c.x + c.w - 6);
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
// REGIONS. §2.1 divides a Mainline card into two, and §8.2 moves a train one region per Stage.
// The bars are the card's DISTANCE and never vary; what varies is how fast a train covers them,
// so a 60 card is crossed in one Stage and a slow train on a 30 takes three.
// REGIONS. A Mainline card is 1 to 3 of them (Gitea#3) and a train advances one per Stage. The
// bars are the card's DISTANCE and never vary; where a train STARTS is what does.
const RW = c.regions > 0 ? (c.w - 12) / c.regions : 0;
for (let r = 0; r < c.regions; r++) {
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + 20}" x2="${c.x + 6 + RW * r}" y2="${c.y + 44}"/>`;
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
}
/**
@@ -371,36 +369,25 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* So this keeps the two things the Division map is actually for — where a train is and which way
* it is going — and leaves the cars to the tooltip and to the district.
*/
c.trains.forEach((t, k) => {
/**
* A TRAIN IS A CHIP — its number, which way it points, and how many cars.
*
* It was drawn as a full consist here, matching the Office Area card, and reported as too large
* and hard to read. The Office card is where a consist is worth drawing, because that is where
* the switching decisions are made and where there is room to read it. So this keeps the two
* things the Division map is for — where a train is and which way it is going — and leaves the
* cars to the tooltip and to the district.
*/
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
/**
* THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread.
*
* Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was
* sized for the cards it holds, not for its trains — so two chips at a Station used to land at
* x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards
* either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because
* the cell was sized for exactly that many slots (see `CHIP_W` above).
*/
const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0;
const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0;
const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12);
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const tx = isOfficeRun
? c.x + 6 + slotW * (k + 0.5)
: (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
const stages =
typeof t.stagesLeft === 'number'
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card` +
' (Stages, not regions: a card is two regions of fixed distance, and how many Stages a' +
' train takes over them depends on the card speed and the train)'
? ` \u00b7 ${t.stagesLeft} Stage${t.stagesLeft === 1 ? '' : 's'} still to run across this card`
: '';
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
cars.length ? ` (${loaded} loaded)` : ''
@@ -409,15 +396,61 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
// me?" gets asked, and EXPEDITED is the answer more often than not.
t.what ? `\n\n${esc(t.what)}` : ''
}">` +
`<rect x="${tx - w / 2}" y="${c.y + 22}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx}" y="${c.y + 35}" text-anchor="middle">${esc(label)}</text>`;
`<rect x="${tx - w / 2}" y="${ty}" width="${w}" height="19" rx="3"/>` +
`<text class="bs-tlab" x="${tx}" y="${ty + 13}" text-anchor="middle">${esc(label)}</text>`;
out += '</g>';
};
const textW = (t: NonNullable<Cell['trains']>[number]): number =>
(`${t.label} \u25b6${(t.cars ?? []).length || ''}`).length * 6.6 + 12;
/**
* TWO CHIPS TO A REGISTER ON A DISTRICT, in fixed slots — never a centre spread.
*
* Centred spreading pushes its outer chips outward as more trains arrive, which is how two chips
* at a Station once landed outside the cell that held them. Fixed slots cannot overflow, because
* the cell was sized for exactly that many (`OFFICE_W`).
*/
const isDistrict = c.kind === 'run';
const SLOTS = 2;
const slotW = (c.w - 12) / SLOTS;
c.trains.forEach((t, k) => {
if (isDistrict) {
// Row-major within the A/D register: two across, then wrap under. A district can hold four
// trains and only two fit across it.
const col = k % SLOTS;
const row = Math.floor(k / SLOTS);
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + CHIP_Y + row * 21, Math.min(slotW - 4, textW(t)));
return;
}
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const w = Math.min(c.w - 8, textW(t));
const tx = (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
chip(t, tx, c.y + CHIP_Y, w);
});
/**
* THE SECOND REGISTER, under the rail: trains in the district that hold no A/D track (Gitea#18).
*
* A crew switching below the Running Track and a train standing at an A/D track are different
* things — A/D occupancy is a hard capacity that causes collisions, switching is not — and the
* difference is drawn as POSITION rather than as a colour to learn, because below the rail is
* where those trains actually are.
*/
(c.below ?? []).forEach((t, k) => {
const col = k % SLOTS;
const row = Math.floor(k / SLOTS);
chip(t, c.x + 6 + slotW * (col + 0.5), c.y + BELOW_Y + row * 21, Math.min(slotW - 4, textW(t)));
});
out += '</g>';
});
// THE ENDS. The route stops at both Division Points; drawing buffer stops and naming the gap is
// what stops a seated layout being read as a loop.
// THE ENDS. The route stops at both Division Points, and the buffer stops say so — a Division is
// a LINE, not a loop. With a single row (Gitea#18) they simply face outward at the two ends, west
// on the left and east on the right, which is the bug reported twice against the wrapped layout.
const first = cells[0];
const last = cells[cells.length - 1];
/**
+15 -24
View File
@@ -10,6 +10,7 @@
* drift into two different pictures of the same board.
*/
import { regionOfTransit } from '../engine/advance.ts';
import {
areaAtSeat,
areaOf,
@@ -32,7 +33,6 @@ import {
MANEUVER_CARDS,
MODIFIER_PROFILES,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
enhancementRule,
@@ -1165,36 +1165,27 @@ export function snapshot(
// Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
// with how long each still has to run.
const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
const isGrade = n.card === 'heavyGrade';
/**
* WHERE ON THE CARD, from what the crossing already cost.
* WHERE ON THE CARD — now simply what the card says.
*
* §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage.
* The engine crosses in `crossingStages` Stages instead, which varies by card speed, train
* speed, passengers and modifiers — so the printed model is recovered by treating the entry
* point as the thing that varies, exactly as the cards do:
* This used to recover a printed two-region model from a crossing time computed out of the
* card's mph, the train's Fast/Slow class, its consist and any modifiers, by treating the
* ENTRY point as the thing that varied: `entry = 2 - stagesTotal`. It even had to cope with a
* negative entry, for a slow train needing three Stages to cross a card with two regions.
*
* entry = REGIONS - stagesTotal position = entry + elapsed
*
* A 60 card is one Stage, so the train enters at the second region and is gone — which is
* what "Start positions further along the card" means on the printed art. A 30 card is two
* Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three
* Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the
* card's distance is fixed and the train is simply slow across it.
* Gitea#3 turned that the right way up. Regions are the primary thing — printed on the card,
* one per Stage — and the entry point is what the rules actually move. There is nothing left
* to reconstruct.
*/
const place = (t: { stagesRemaining: number; stagesTotal: number }): number => {
// `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into
// two regions, so it notionally starts before the card and spends the extra Stage getting
// to the first region. Clamping only the final position keeps that Stage at the START,
// where being slow shows; clamping `entry` first would have parked it at the exit instead.
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));
};
// One region per Stage, straight off the card's own count: what a train has LEFT to run says
// where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the
// collision rule cannot disagree about who is where.
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
return {
kind: 'ml',
label: name,
regions: REGIONS_PER_MAINLINE_CARD,
regions: mainlineProfile(n.card).regions,
trains: [n.transits.map((t) => {
const chip = trainChip(s, t.tray);
return {