Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+44 -2
View File
@@ -464,9 +464,36 @@ export function officeSvg(
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
for (let i = 0; i < Math.min(spots, 4); i++) {
const filled = i < cell.cars.length;
out += `<rect class="bs-slot${filled ? ' bs-occ' : ''}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
/**
* WHICH CAR IS STANDING HERE, not just that one is.
*
* The switching game is entirely about getting the RIGHT car to the right industry, so "a car
* is here" is not enough — a Grocer's Warehouse wants a boxcar or a reefer and nothing else
* will do. This printed the first three characters of the label, which for "loaded hopper" and
* "loaded boxcar" alike is `loa`: every car on the board looked the same.
*
* Type by colour and by three letters, loaded by a filled slot and empty by an outlined one —
* the same distinction the printed game makes with coloured tokens.
*/
const car = cell.cars[i] ?? '';
const type = /caboose/.test(car)
? 'cab'
: /hopper/.test(car)
? 'hop'
: /boxcar/.test(car)
? 'box'
: /reefer/.test(car)
? 'rfr'
: /tank/.test(car)
? 'tnk'
: /coach/.test(car)
? 'cch'
: '';
const loaded = /^loaded/.test(car);
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ''}` : '';
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2"/>`;
if (filled) {
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc((cell.cars[i] ?? '').slice(0, 3))}</text>`;
out += `<text class="bs-carlab" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle">${esc(type)}</text>`;
}
}
@@ -535,6 +562,21 @@ export const BOARD_CSS = `
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
.bs-slot.bs-occ{fill:rgba(90,169,230,.20);stroke:#5aa9e6;stroke-dasharray:none}
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL. An industry takes one commodity and nothing else, so the
type has to be readable at a glance; and a loaded car and an empty one of the same type are
opposite halves of the same job. Empty rides as an outline, loaded as a solid face. */
.bs-slot.bs-car-box{stroke:#c08a4a}
.bs-slot.bs-car-hop{stroke:#8d97a5}
.bs-slot.bs-car-tnk{stroke:#7a6ab0}
.bs-slot.bs-car-rfr{stroke:#6fb5a0}
.bs-slot.bs-car-cch{stroke:#5aa9e6}
.bs-slot.bs-car-cab{stroke:#c05a5a}
.bs-slot.bs-car-box.bs-loaded{fill:rgba(192,138,74,.42)}
.bs-slot.bs-car-hop.bs-loaded{fill:rgba(141,151,165,.42)}
.bs-slot.bs-car-tnk.bs-loaded{fill:rgba(122,106,176,.42)}
.bs-slot.bs-car-rfr.bs-loaded{fill:rgba(111,181,160,.42)}
.bs-slot.bs-car-cch.bs-loaded{fill:rgba(90,169,230,.42)}
.bs-slot.bs-car-cab{fill:rgba(192,90,90,.42)}
.bs-train rect{fill:#2f6b3d;stroke:#8fd6a0;stroke-width:1.2}
.bs-crew rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.2}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
+151 -21
View File
@@ -21,7 +21,7 @@
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { Hand } from '../engine/content.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
@@ -219,9 +219,47 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return because('no option has a clear purpose this turn', can('switch') ?? can('freightAgent') ?? choices[0] ?? options[0]!);
}
/** The face-up card on top of a Department pile — the only one that may be taken. */
function topOfDepartment(s: GameState, slot: number): string | undefined {
const pile = s.decks.departments[slot] ?? [];
return pile[pile.length - 1];
}
/**
* WHERE TO DISCARD, now that the Departments are shared piles and a discard goes on TOP.
*
* Burying a card puts it out of reach — only the top of a pile may ever be taken — so the choice is
* which face-up card to cover. Cover the one you would not have taken anyway, and the cards you DO
* want stay reachable. `pickFirst` always chose Department 1, which buried whatever happened to be
* sitting there, including the Depot the bot was waiting on.
*
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'card.discard') continue;
// Cover a card that is no use, never one worth taking. Ties break toward the SHALLOWEST pile:
// three piles offer three face-up cards, and piling discards onto one of them leaves the other
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, i.toSlot);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean {
const id = s.decks.departments[slot];
const id = topOfDepartment(s, slot);
if (!id) return false;
const k = s.cards.get(id)?.kind;
if (!k) return false;
@@ -453,9 +491,9 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
// Closer to the Office is fewer Moves to reach, same as track scoring.
let score = -(Math.abs(row - area.officeCoord.row) * 2 + Math.abs(col - area.officeCoord.col));
if (row === area.runningRow) {
score -= 20;
} else {
// The Running Track is not merely a bad square for an industry any more, it is an illegal one
// (`ON_RUNNING_TRACK`), so `check` has already filtered those out before this sees them.
{
let meetsLoop = false;
let meetsReachable = false;
for (const p of ports) {
@@ -546,8 +584,33 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
c.geometry.geometry === 'straight',
).length;
/**
* A track play, split into the two things the scoring cares about.
*
* Track is an ordinary deck card now, so a lay arrives as `card.play` with a placement and a
* rotation, and the piece it would put down has to be read back off the card in hand.
*/
type Lay = {
intent: Intent;
geometry: TrackGeometry;
hand: Hand;
variant: number | undefined;
placement: GridCoord;
};
const laysIn = (opts: Intent[]): Lay[] => {
const out: Lay[] = [];
for (const i of opts) {
if (i.type !== 'card.play' || i.placement === undefined) continue;
const kind = s.cards.get(i.cardId)?.kind;
if (kind?.kind !== 'track') continue;
out.push({ intent: i, geometry: kind.geometry, hand: kind.hand, variant: i.variant, placement: i.placement });
}
return out;
};
/** The card a lay would place, built exactly as the reducer builds it. */
const probeOf = (i: Extract<Intent, { type: 'track.lay' }>): TrackCard => {
const probeOf = (i: Lay): TrackCard => {
const v = variantsFor(i.geometry, i.hand)[i.variant ?? 0];
return {
geometry: {
@@ -565,8 +628,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
} as unknown as TrackCard;
};
const arcOf = (i: Extract<Intent, { type: 'track.lay' }>): string | undefined =>
variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
const arcOf = (i: Lay): string | undefined => variantsFor(i.geometry, i.hand)[i.variant ?? 0]?.arc;
/**
* RAIL THAT CAN NEVER GO ANYWHERE.
@@ -581,7 +643,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
* end that DOES connect, and the last two attempts to forbid something outright starved the
* district instead of tidying it.
*/
const deadEnds = (i: Extract<Intent, { type: 'track.lay' }>): number => {
const deadEnds = (i: Lay): number => {
const probe = probeOf(i);
let dead = 0;
for (const p of new Set(connectionsFor(probe).flat())) {
@@ -595,15 +657,22 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
};
/**
* Arcs left that could meet THIS turnout's leg.
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
*
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
* cutting one with nothing left to hang there is pure loss. Asked per hand, because handedness is
* the slope: a left-hand turnout's 45° leg can only be continued by a left-hand curve, and a
* supply full of right-hand ones is no help to it at all.
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
* no help to it.
*
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
* a draw away rather than a certainty.
*/
const arcsLeft = (hand: Hand): number =>
(area.trackSupply.get(`curved:${hand}`) ?? 0) + (area.trackSupply.get(`sharpCurved:${hand}`) ?? 0);
const arcInHand = (hand: Hand): boolean =>
(s.decks.hands.get(player) ?? []).some((id) => {
const k = s.cards.get(id)?.kind;
return k?.kind === 'track' && (k.geometry === 'curved' || k.geometry === 'sharpCurved') && k.hand === hand;
});
// Where the district already turns off the main, either side.
const turnouts = [...area.grid.entries()]
@@ -618,8 +687,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
for (const i of laysIn(options)) {
const { row, col } = i.placement;
const dRow = Math.abs(row - area.officeCoord.row);
const dCol = Math.abs(col - area.officeCoord.col);
@@ -643,7 +711,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
// without competing further with the ways down. Scored below a first turnout, which is still
// the most valuable single piece on the board, and below closing a run-around.
bonus += 13;
} else if (row === area.runningRow && i.geometry === 'turnout' && arcsLeft(i.hand) > 0) {
} else if (row === area.runningRow && i.geometry === 'turnout' && arcInHand(i.hand)) {
// A first way down is the most valuable single piece on the board; a second closes the
// run-around. Beyond that they are just holes in the Running Track — measured at 6.4 per game
// when unrestrained, which consumed the whole 26-piece supply on ways down and none on the
@@ -722,7 +790,7 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
if (score + bonus > bestScore) {
bestScore = score + bonus;
best = i;
best = i.intent;
}
}
return best;
@@ -942,13 +1010,30 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const facility = bestFacilityPlay(s, player, options);
if (facility) return because('put the industry where a crew can actually work it', facility);
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
/**
* TRACK IS NOT A FALLBACK.
*
* Track became an ordinary `card.play` when it moved into the deck, so this generic "play
* anything placeable" catch dumped it on whatever square was legal — bypassing `bestTrackLay`
* entirely, which had already looked at the same piece and declined it. Measured: 26 of 60
* districts ran the siding east past the last column with a way up, spending track on a loop
* that could never close. A track card `bestTrackLay` will not use is a card to discard.
*/
const placed = options.find(
(i) =>
i.type === 'card.play' &&
i.placement !== undefined &&
s.cards.get(i.cardId)?.kind.kind !== 'track',
);
if (placed) return because('develop the district with a card that goes on the board', placed);
const play = options.find((i) => i.type === 'card.play');
if (play) return because('play what is in hand', play);
const end = options.find((i) => i.type === 'draw.end');
if (end) return because('nothing in hand can be played anywhere legal', end);
return because('nothing playable and nothing to draw — discard to a Department slot', pickFirst(options, 'card.discard') ?? options[0]!);
return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
case 'switch': {
@@ -965,6 +1050,51 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
);
if (flyingFirst) return because('a Flying Switch rolls the back car straight into an industry that wants it', flyingFirst);
/**
* DIG THE ENGINE OUT BEFORE ANYTHING ELSE.
*
* §8.2 will not let a train leave the Office with its engine buried among its own cars, and
* taking a cut onto the nose is exactly how that happens. Left alone it is terminal: the train
* holds an A/D track for the rest of the game, every train behind it collides, and the freight
* chain it was serving starves. Measured before this: 90 holds across 40 games, six of them
* with a train stuck permanently.
*
* Setting the nose cars out is the move Appendix A uses for it — "back up and drop off
* everything on the nose of your train" — and it is worth spending a Move on ahead of any
* freight work, because nothing else the crew does matters until the train can run again.
*/
const buried = trayOf(s, player);
if (buried && buried.engineAt > 0 && buried.engineAt < buried.consist.length) {
const dig = options.find(
(i) => i.type === 'switch.dropCars' && i.fromNose === true && i.count === buried.engineAt,
);
if (dig) return because('the engine is buried in its own train — set out the cars on the nose so it can leave the Office', dig);
}
/**
* A CABOOSE OUT OF PLACE STOPS THE TRAIN JUST AS DEAD.
*
* It has to ride at the rear (§8.2), and a run-around can easily leave it mid-train. If it is
* sitting at an outer end that is the WRONG end, setting it out is the one Move that frees the
* train — §8.2 lets a train depart with fewer cars, never with the wrong ones in the wrong
* order, so running cabooseless beats not running at all.
*/
if (buried) {
const n = buried.consist.length;
const cab = buried.consist.findIndex((c) => c.type === 'caboose');
const rear = buried.engineAt === 0 ? n - 1 : 0;
if (cab !== -1 && cab !== rear) {
const atNoseEnd = cab === 0 && buried.engineAt > 0;
const atTailEnd = cab === n - 1 && buried.engineAt < n;
const shed = options.find(
(i) => i.type === 'switch.dropCars' && i.count === 1 && (i.fromNose === true) === atNoseEnd,
);
if ((atNoseEnd || atTailEnd) && shed) {
return because('the caboose is not at the rear and the train cannot leave — set it out', shed);
}
}
}
if (!usefulSwitching(s, player)) {
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn
+7 -8
View File
@@ -186,21 +186,20 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
where: e.to,
text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
};
case 'trackLaid':
return {
tone: 'plain',
where: e.at,
text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)} — ${e.remaining} left in the supply`,
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
case 'cardDiscarded':
return {
tone: 'quiet',
text: `Discarded ${card(e.cardId)} face-up to Department slot ${e.toSlot + 1}`,
text: `Discarded ${card(e.cardId)} face-up on top of Department ${e.toSlot + 1}`,
};
case 'deckReshuffled':
return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
return {
tone: 'quiet',
text:
`Home Office deck ran out — the Salvage Yard and all three Department decks ` +
`(${e.order.length} cards) were collected, reshuffled and dealt back out`,
};
case 'departmentRefilled':
return {
tone: 'quiet',
+79 -30
View File
@@ -178,9 +178,26 @@ export type Frame = {
/** What each hand card does, in the same order — names alone are not a playable hand. */
handWhat: string[];
deck: number;
/** The face-up card on top of each Department pile — the only one that may be drawn. */
departments: string[];
/** What each face-up Department card does. */
departmentsWhat: string[];
/**
* How deep each Department pile is.
*
* A discard goes on TOP, so a deep pile is a card a rival buried and a shallow one is a card
* freshly offered. Without the depth the board cannot say which, and choosing where to discard is
* the whole of the decision.
*/
departmentDepth: number[];
/**
* The Salvage Yard — face up (§2.6), so its top card and its depth are both public.
*
* It is where a played card goes when it does not stay on the board, and §6.2 sweeps it back into
* the Home Office deck when that runs out. Watching it fill is watching the reshuffle approach,
* which is the only warning a player gets that the deck is about to turn over.
*/
salvage: { top: string; depth: number };
/**
* The two yards, by car type.
*
@@ -203,12 +220,7 @@ export type Frame = {
* had the score but no way to know whether it was good.
*/
objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
/**
* What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
* each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
* got left" is a real planning question the board could not answer.
*/
trackSupply: { piece: string; left: number }[];
/** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
decision: Decision | null;
/** A Local Operations turn that changed nothing — the frames worth your attention. */
@@ -334,13 +346,20 @@ export function describeIntent(s: GameState, i: Intent): string {
: 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
case 'card.play':
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
case 'card.discard':
return `discard ${cardName(s, i.cardId)}`;
case 'track.lay':
return (
`lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${geometryLabel(i.geometry)} ` +
`at ${at(i.placement)}${variantLabel(i.geometry, i.variant, i.hand)}`
);
case 'card.discard': {
/**
* NAME THE DEPARTMENT, and what the card would land on.
*
* All three discards described identically as "discard X", and the action list drops
* duplicate labels — so the three choices collapsed into one button and the player could not
* pick a Department at all. The choice IS the strategy: onto an empty-ish pile the card is an
* offer a rival may take, and on top of a card a rival wants it puts that card out of reach.
*/
const pile = s.decks.departments[i.toSlot] ?? [];
const top = pile[pile.length - 1];
const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)';
return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`;
}
case 'switch.move':
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
case 'switch.dropCars':
@@ -388,10 +407,14 @@ export function describeIntent(s: GameState, i: Intent): string {
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
}
case 'draw.fromDepartment': {
// Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
// the choice between a visible card and a blind draw is unmakeable without it.
const id = s.decks.departments[i.slot];
return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
// Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing,
// and the choice between a visible card and a blind draw is unmakeable without it.
const pile = s.decks.departments[i.slot] ?? [];
const id = pile[pile.length - 1];
if (!id) return `Department ${i.slot + 1} (empty)`;
const under = pile.length - 1;
const buried = under === 0 ? '' : `, ${under} buried beneath it`;
return `take ${cardName(s, id)} from Department ${i.slot + 1}${buried}`;
}
case 'draw.fromHomeOffice':
return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
@@ -611,8 +634,21 @@ export function snapshot(
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
return top ? cardName(s, top) : '—';
}),
departmentsWhat: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
return top ? cardDescription(s, top) : '';
}),
departmentDepth: s.decks.departments.map((pile) => pile.length),
salvage: {
top: s.decks.salvageYard.length
? cardName(s, s.decks.salvageYard[s.decks.salvageYard.length - 1]!)
: '—',
depth: s.decks.salvageYard.length,
},
yards: {
division: countStock(s.yards.divisionYard),
classification: countStock(s.yards.classificationYard),
@@ -623,12 +659,6 @@ export function snapshot(
decision,
wasted,
objective: objectiveOf(s),
trackSupply: [...area.trackSupply.entries()]
.map(([key, left]) => {
const [geometry, hand] = key.split(':');
return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
})
.sort((a, b) => a.piece.localeCompare(b.piece)),
runningRow: area.runningRow,
blocked: impediments(s, 0),
trains: [...s.trays.values()].map((t) => ({
@@ -662,7 +692,9 @@ export function cardName(s: GameState, id: string): string {
case 'modifier':
return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
case 'track':
return `${geometryLabel(k.geometry)} track`;
// The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs
// in the name: "curve" alone does not tell you what it can be joined to.
return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`;
case 'spaceUse':
case 'enhancement':
case 'mainlineModifier':
@@ -732,6 +764,24 @@ export function cardDescription(s: GameState, id: string): string {
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
}
case 'track': {
// Track is the largest category in the deck, so a player holds it constantly — and what it
// can be joined to is decided by the hand, which is not something the name alone conveys.
const cost = k.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
const stop = k.geometry === 'turnout' ? ' · a train may pass through but not stop on it' : '';
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const arcs = variantsFor(k.geometry, k.hand)
.map((v) => v.arc ?? `${v.turnout?.stem}-${v.turnout?.diverge}`)
.join(' or ');
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
return (
`east-west track with a 45° leg through the middle of the north or south edge · ` +
`lay it as ${arcs} · its leg is on the ${diagonal} diagonal and only meets a card on the ` +
`same one${stop}${cost}`
);
}
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
@@ -794,11 +844,10 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
: '';
const p = industryProfile(g.facility as never);
const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
// §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
// block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
// between the Limits and the Office a collision when a train arrives.
// An industry can no longer BE on the Running Track — the sheet puts every one of them on a
// straight stub off it — so this reads as a leftover only if one is somehow there.
const where = onRunning
? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival'
: '';
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
}