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
+49
View File
@@ -341,6 +341,35 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited';
/**
* §8.2 — why this train is not fit to run, or null if it is.
*
* "The engine in front, cars behind, and if there is a caboose, the caboose at the rear."
*
* DIRECTION-FREE, deliberately. The engine may be at either end of the tray — pulling or pushing —
* and which of those counts as "in front" depends on which way the train is pointed, which the tray
* does not reliably record for a crew that has been shunting round a siding. What a train may NOT be
* is broken-backed: the engine buried among its own cars, with some ahead of it and some behind. The
* caboose then has to ride at the far end from the engine, which is the rear whichever way it runs.
*
* This is reachable purely through switching. A train arrives made up, and only comes apart because
* the player took cars onto the nose or picked up a cut in a run-around.
*/
function badlyMadeUp(tray: CrewTray): string | null {
const n = tray.consist.length;
if (n === 0) return null;
const pulling = tray.engineAt === 0;
const pushing = tray.engineAt === n;
if (!pulling && !pushing) {
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
}
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
if (caboose === -1) return null;
// The rear is the end away from the engine.
const rear = pulling ? n - 1 : 0;
return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train';
}
/** Puts a train onto a Mainline card with its crossing time already computed. */
function enterMainline(
s: GameState,
@@ -449,6 +478,26 @@ function moveTrain(
return 'held';
}
/**
* §8.2 — A TRAIN MUST BE MADE UP BEFORE IT MAY LEAVE.
*
* The engine leads, the cars follow, and a caboose rides at the rear. A train that has been
* shunting can easily be in none of those states: taking cars on the nose puts them AHEAD of the
* engine ("pushing them into the Facility"), and cars picked up in a run-around land wherever the
* approach put them. Nothing checked, so a crew could shove a cut into a siding and then highball
* onto the Mainline engine-last with the caboose in the middle.
*
* The remedy is in the player's hands and is the reason both exist: run around the train, or
* spend a Move in a Small Yard, which re-makes it (`consistSorted` puts the engine back on the
* nose). Held rather than rejected — a train that cannot leave stays where it is, which is what
* makes an A/D track fill up and eventually bite.
*/
const badOrder = badlyMadeUp(tray);
if (badOrder !== null) {
events.push({ type: 'trainHeld', trainNumber: tray.trainNumber ?? 0, reason: badOrder });
return 'held';
}
const officeIndex = nodeIndexOfOffice(s, owner);
const target = officeIndex + dir;
const node = s.division.nodes[target];
+125 -81
View File
@@ -292,6 +292,18 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
/**
* A CUT COMES OFF AN OUTER END, never out of the middle.
*
* The tray runs `[cars ahead of the engine] ENGINE [cars behind it]`. Setting out from the
* nose takes from the front of that, and only the cars actually ahead of the engine; setting
* out from the tail takes from the back, and only the cars behind it. Without this, a drop
* could lift cars from beside the engine and leave the far end of the train still attached to
* nothing — a cut no coupler could make.
*/
const ahead = tray.engineAt;
const behind = tray.consist.length - tray.engineAt;
if (i.fromNose ? i.count > ahead : i.count > behind) return 'CONSIST_EMPTY';
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
}
@@ -331,7 +343,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
return s.decks.departments[i.slot] ? null : 'SLOT_EMPTY';
return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY';
case 'card.play': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
@@ -349,17 +361,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return null;
}
case 'track.lay': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
const key = `${i.geometry}:${i.hand}`;
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
}
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
@@ -594,8 +595,21 @@ function checkPlay(
case 'freightFacility': {
if (!placement) return 'NO_PLACEMENT';
// Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and
// the consumer of the same commodity, so this forces traffic to flow between districts.
/**
* ON A STRAIGHT STUB, NEVER THE RUNNING TRACK.
*
* The sheet's "Placed" column reads the same for all six industries: **"Straight, Stub (not on
* Running Track)"**. An industry has to hang off a siding, which is what makes a siding worth
* building — the whole switching puzzle is getting a car from the main down to a spur and back.
*
* This was allowed, and merely warned about: an industry could sit on the Running Track and
* the card text noted that a car left standing there would be hit by the next arrival. That is
* a hazard, not a rule, and it let a player skip the district entirely and spot cars on the
* main line.
*/
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
// Q4 — a lockout prevents BUILDING both in one district: no duplicate, and never a producer
// alongside the consumer of the same commodity.
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
@@ -730,9 +744,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'switch.dropCars': {
const tray = s.trays.get(i.trayId)!;
const here = trayCoord(s, i.trayId)!;
// §A.3 — cars come off in the order they are seated in the tray.
const stock = tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
// §A.3 — cars come off in the order they are seated in the tray, from whichever end is being
// set out. The nose is the end ahead of the engine.
const stock = i.fromNose
? tray.consist.slice(0, i.count)
: tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
}
case 'switch.sortConsist': {
@@ -753,8 +770,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'draw.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
case 'draw.fromHomeOffice':
return [
case 'draw.fromHomeOffice': {
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
@@ -762,23 +779,33 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
},
];
const sweep = reshuffleIfDepleted(s, 1);
if (sweep) events.push(sweep);
return events;
}
case 'draw.fromDepartment': {
// §6.2 — "take the TOP face-up card". Never anything buried: a player who discarded onto this
// pile chose to put a card out of reach as much as to offer one, and letting a rival dig
// would take that decision away.
const pile = s.decks.departments[i.slot]!;
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
source: 'department',
slot: i.slot,
cardId: s.decks.departments[i.slot]!,
cardId: pile[pile.length - 1]!,
},
];
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in
// the empty spot." Without this the three face-up slots empty out permanently and the market
// silently disappears from the game.
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in the
// empty spot." Only when taking the last card actually empties the pile; refilling on every
// draw would grow the Departments without limit and drain the Home Office deck into them.
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
if (refill) {
if (pile.length === 1 && refill) {
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
const sweep = reshuffleIfDepleted(s, 1);
if (sweep) events.push(sweep);
}
return events;
}
@@ -866,21 +893,6 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
}
case 'track.lay': {
const key = `${i.geometry}:${i.hand}`;
return [
{
type: 'trackLaid',
player,
geometry: i.geometry,
hand: i.hand,
at: i.placement,
variant: i.variant ?? 0,
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
},
];
}
case 'freightAgent.stockOutbound':
return [
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
@@ -1025,8 +1037,16 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'carsCoupled': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
if (e.toNose) tray.consist.unshift(...e.stock);
else tray.consist.push(...e.stock);
if (e.toNose) {
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
// engine is no longer at the front and its index has to follow. It never did, so a crew that
// shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's
// make-up rule had nothing truthful to check.
tray.consist.unshift(...e.stock);
tray.engineAt += e.stock.length;
} else {
tray.consist.push(...e.stock);
}
// ONLY the cards the crew ran over. Clearing the whole grid emptied industry tracks the crew
// never went near.
for (const coord of e.from) {
@@ -1041,6 +1061,9 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
tray.engineAt = 0;
// "Spends one move in the yard" — the sort costs a Move.
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
break;
@@ -1049,7 +1072,15 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
if (e.fromNose) {
// Off the front: everything ahead of the engine shortens, so the engine moves up by that
// much. This is how a train that took cars onto its nose gets back to being made up.
tray.consist.splice(0, e.stock.length);
tray.engineAt = Math.max(0, tray.engineAt - e.stock.length);
} else {
tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
}
tray.engineAt = Math.min(tray.engineAt, tray.consist.length);
const card = area.grid.get(coordKey(e.at));
// On a Facility card the industry track is where cars stand (§9.3).
if (card) carsOn(card).push(...e.stock);
@@ -1058,13 +1089,30 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'departmentRefilled':
s.decks.homeOffice.pop();
s.decks.departments[e.slot] = e.cardId;
s.decks.departments[e.slot]!.push(e.cardId);
break;
case 'deckReshuffled': {
// Everything swept comes back as ONE pile, then §4.6-4.7's opening is re-run: three cards
// turned face up as the Departments, the rest face down as the Home Office deck. The
// Departments start one deep again, exactly as at setup.
s.decks.salvageYard = [];
s.decks.departments = [[], [], []];
const order = [...e.order];
for (const pile of s.decks.departments) {
const card = order.pop();
if (card) pile.push(card);
}
// The END of the array is the top of the deck — `cardDrawn` pops from there.
s.decks.homeOffice = order;
s.rngState = e.rngState;
break;
}
case 'cardDrawn': {
const hand = s.decks.hands.get(e.player) ?? [];
if (e.source === 'homeOffice') s.decks.homeOffice.pop();
else if (e.slot !== undefined) s.decks.departments[e.slot] = null;
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
hand.push(e.cardId);
s.decks.hands.set(e.player, hand);
s.turn.drawnThisTurn = true;
@@ -1135,18 +1183,6 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'trackLaid': {
const area = areaOf(s, e.player);
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
s.turn.laidThisTurn = true;
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
if (built) {
area.grid.set(coordKey(e.at), built);
extendLimitsIfNeeded(area, e.at);
}
break;
}
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
@@ -1164,7 +1200,10 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'cardDiscarded': {
const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
s.decks.hands.set(e.player, hand);
s.decks.departments[e.toSlot] = e.cardId;
// ON TOP of the pile. Assigning here overwrote whatever was already face up on that
// Department, quietly destroying a card from a closed deck — and it threw away the whole
// point of choosing WHICH Department to discard onto.
s.decks.departments[e.toSlot]!.push(e.cardId);
break;
}
@@ -1561,35 +1600,39 @@ function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
}
/**
* A track piece from the player's own supply, at the chosen rotation — which for a printed card is
* 0° or 180°, and never a flip, so `hand` decides which diagonal the 45° leg can lie on.
* §6.2 — "If drawing a card has depleted the Home Office deck, immediately collect all cards from
* the Salvage Yard and three Department decks, reshuffle, and reestablish the Home Office deck and
* Department slots."
*
* `taking` is how many cards the events already queued will pop off the deck, so this can be asked
* BEFORE they are applied: a draw takes one, and refilling an emptied Department takes another.
*
* Returns null when the deck is not about to run out, or when there is nothing to sweep — a game
* that has genuinely used every card ends on `DECK_EMPTY` rather than reshuffling an empty pile.
* Cards played onto the board are NOT recovered: they are on the table, which is where they belong.
*/
function protoTrackCard(
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
): TrackCard | null {
const options = variantsFor(geometry, hand);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.arc ? { arc: v.arc } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
function reshuffleIfDepleted(s: GameState, taking: number): GameEvent | null {
if (s.decks.homeOffice.length > taking) return null;
const collected = [...s.decks.salvageYard, ...s.decks.departments.flat()];
if (collected.length === 0) return null;
const rng = createRng(s.rngState);
return { type: 'deckReshuffled', order: rng.shuffle(collected), rngState: rng.getState() };
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
*
* TWO RULES, both from the sheet's "Lockouts" column.
*
* 1. **No two of the same industry in one Office Area.** The sheet states it in the Freight House
* row, which lists Freight House among its own lockouts; it is a general rule, so it is applied
* to every kind here rather than repeated in all six catalogue entries.
* 2. **Not a producer and the consumer of the same commodity.** Mine Tipple makes coal and the
* Power Plant burns it; the Refinery makes oil and the Power Plant burns that too; Packing Sheds
* fill reefers and the Grocer's Warehouse empties them. Build one end of a chain or the other,
* never both — which is what pushes freight to run BETWEEN districts instead of circling inside
* one.
*
* The relation is symmetric, so checking either direction is enough.
*/
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
@@ -1597,6 +1640,7 @@ export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
for (const card of area.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
const present = card.geometry.facility;
if (present === kind) return true;
if (wanted.lockouts.includes(present)) return true;
if (industryProfile(present).lockouts.includes(kind)) return true;
}
+54 -20
View File
@@ -42,14 +42,15 @@ export type TrackGeometry = 'straight' | 'curved' | 'sharpCurved' | 'turnout';
export type Hand = 'left' | 'right' | 'none';
// ---------------------------------------------------------------------------
// Track — a PER-PLAYER supply, not deck cards
// Track — deck cards, drawn and played like any other
// ---------------------------------------------------------------------------
export type TrackProfile = {
geometry: TrackGeometry;
hand: Hand;
name: string;
perPlayer: number;
/** Column B of the sheet, "Number in Deck". */
copiesInDeck: number;
/** Column J of the sheet, "Train can stop here". Turnouts are blank. */
isOperationalRail: boolean;
/** Sharp curves: "Any move across this track is doubled (two moves)". */
@@ -57,30 +58,42 @@ export type TrackProfile = {
};
/**
* 26 pieces per player, 104 for four players. Track is NOT in the Home Office deck — this is the
* single biggest structural change from the placeholder, and it means the draw is entirely about
* industry, trains and enhancements.
* TRACK IS IN THE HOME OFFICE DECK, and is drawn and played like any other card.
*
* Handedness is PRINTED, not chosen on placement. That supersedes Gap 11.
* 104 cards, straight from column B of `docs/Deck cards2.xlsx`. An earlier reading made track a
* separate per-player supply of 26 pieces, sitting outside the deck and laid one a turn. That came
* from misreading the sheet's LAST column, headed "Track Per Player" — 8/4/4/1/1/4/4 = 26, which is
* a note about each player's likely share of 104 across four players, not a second stack of cards.
* The sheet's own totals settle it: "Sum other 115", "Total track 104", and a grand total of 231
* once the 12 start cards are counted. 115 + 104 + 12 = 231.
*
* 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.
*
* Handedness is PRINTED, not chosen on placement: it is the diagonal the 45° leg lies on.
*/
export const TRACK_SUPPLY: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', perPlayer: 8, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', perPlayer: 4, isOperationalRail: true, moveCost: 1 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', perPlayer: 1, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', perPlayer: 4, isOperationalRail: false, moveCost: 1 },
export const TRACK_CARDS: readonly TrackProfile[] = [
{ geometry: 'straight', hand: 'none', name: 'Straight track', copiesInDeck: 32, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'left', name: 'Curved track (left)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'curved', hand: 'right', name: 'Curved track (right)', copiesInDeck: 16, isOperationalRail: true, moveCost: 1 },
{ geometry: 'sharpCurved', hand: 'left', name: 'Sharp Curved Track (left)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'sharpCurved', hand: 'right', name: 'Sharp Curved Track (right)', copiesInDeck: 4, isOperationalRail: true, moveCost: 2 },
{ geometry: 'turnout', hand: 'left', name: 'Turnout (left)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
{ geometry: 'turnout', hand: 'right', name: 'Turnout (right)', copiesInDeck: 16, isOperationalRail: false, moveCost: 1 },
];
export const TRACK_PER_PLAYER = TRACK_SUPPLY.reduce((n, t) => n + t.perPlayer, 0); // 26
/** 104 — the sheet's "Total track". */
export const TRACK_IN_DECK = TRACK_CARDS.reduce((n, t) => n + t.copiesInDeck, 0);
/** Start cards, placed at setup and never shuffled: 4 Whistle Posts, 8 Limits. */
export const WHISTLE_POST_SUPPLY = 4;
export const LIMITS_SUPPLY = 8;
/** Legacy alias kept so existing call sites resolve; prefer TRACK_SUPPLY. */
export const TRACK_PROFILES = TRACK_SUPPLY;
/** Look up a track card's profile by the two things that identify it. */
export function trackProfile(geometry: TrackGeometry, hand: Hand): TrackProfile | undefined {
return TRACK_CARDS.find((t) => t.geometry === geometry && t.hand === hand);
}
// ---------------------------------------------------------------------------
// Offices — Depot 4, Station 2, Terminal 1
@@ -174,6 +187,26 @@ export type IndustryProfile = {
* 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.
*/
/**
* LOCKOUTS, from the sheet's "Lockouts" column verbatim:
*
* Freight house -> Freight house, Grocer's warehouse
* Mine Tipple -> Power Plant
* Refinery -> Power plant
* Power Plant -> Mine Tipple, Refinery
* Packing Sheds -> Grocer's Warehouse
* Grocer's Warehouse -> Packing Sheds, Freight House
*
* Every pair is a PRODUCER and the CONSUMER of the same commodity: Mine Tipple makes coal and the
* Power Plant burns it, the Refinery makes oil and the Power Plant burns that too, Packing Sheds
* fill reefers and the Grocer's Warehouse empties them. You may build one end of a chain or the
* other, never both — which is what forces the traffic to run BETWEEN districts rather than in
* circles inside one.
*
* Freight house listing ITSELF is the sheet stating the general rule in the one row where it would
* otherwise look like an omission: no two of the same industry in one Office Area. That rule is
* 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 },
@@ -672,7 +705,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
}
// ---------------------------------------------------------------------------
// Deck composition — 140 cards
// Deck composition
// ---------------------------------------------------------------------------
/**
@@ -688,6 +721,7 @@ export function isOpponentOnly(category: string): boolean {
export function deckComposition(): { category: string; count: number }[] {
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
return [
{ category: 'track', count: TRACK_IN_DECK },
{ category: 'office', count: OFFICE_PROFILES.reduce((n, o) => n + o.copiesInDeck, 0) },
{ category: 'industry', count: sum(INDUSTRY_PROFILES) },
{ category: 'modifier', count: sum(MODIFIER_PROFILES) },
@@ -700,9 +734,9 @@ export function deckComposition(): { category: string; count: number }[] {
];
}
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
/** The solitaire deck drops the 22 opponent-directed cards. */
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
+11 -3
View File
@@ -35,12 +35,21 @@ export type GameEvent =
*/
toNose: boolean;
}
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
/**
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
* collected, reshuffled, and dealt back out as a fresh deck plus three face-up Departments.
*
* The full shuffled `order` rides the event rather than being recomputed from `rngState`. A save
* is a seed plus the intents, so events are never serialised and the size costs nothing — and an
* event that states the outcome outright cannot drift from the reducer the way a re-derivation
* can. `rngState` still rides along so the next roll follows on.
*/
| { type: 'deckReshuffled'; order: CardId[]; rngState: number }
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
| {
@@ -53,7 +62,6 @@ export type GameEvent =
| { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
| { type: 'deckReshuffled' }
| { type: 'departmentRefilled'; slot: number; cardId: CardId }
// -- freight agent
| { type: 'stockToOutbound'; player: PlayerIndex; at: GridCoord; stock: RollingStock }
+14 -9
View File
@@ -17,7 +17,13 @@ export type Intent =
| { type: 'localOps.choose'; option: LocalOpsOption }
// -- switch (§6.1, Appendix A)
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
/**
* Set out a cut. `fromNose` takes it off the FRONT of the train rather than the back — Appendix A's
* worked example does exactly this: "Back up and drop off everything on the nose of your train
* (red and blue) on Card B." Without it, cars taken onto the nose could never be set out again and
* an engine buried in its own train had no way back to an end.
*/
| { type: 'switch.dropCars'; trayId: TrayId; count: number; fromNose?: boolean }
/**
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
* order, including cars in front of the engine". This is the designed answer to §A.3's
@@ -28,16 +34,13 @@ export type Intent =
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
| { type: 'draw.fromDepartment'; slot: number }
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
/**
* `variant` indexes `variantsFor(geometry, hand)` — 0° or 180°, the only two ways a printed card
* can be laid. Track is played through here like every other card; there is no separate lay
* intent, because there is no separate supply to lay from.
*/
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'card.discard'; cardId: CardId; toSlot: number }
/**
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
*
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
* Confirmed; see implications.md §10 Q10.
*/
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
| { type: 'draw.end' }
// -- freight agent (§6.3)
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
@@ -110,6 +113,8 @@ export type RejectionCode =
| 'NO_PENDING_DECISION'
| 'NOT_UPGRADEABLE'
| 'FACILITY_LOCKED'
/** An Industry goes on a straight stub, never on the Running Track (sheet: "Placed" column). */
| 'ON_RUNNING_TRACK'
| 'NOT_IMPLEMENTED'
| 'WRONG_INTENT'
| 'NOT_A_GRADE'
+10 -19
View File
@@ -90,6 +90,9 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
}
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
// Off the nose as well as the tail — the only way to get cars back off the front of a train
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
}
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
@@ -136,31 +139,19 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
});
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
const kind = s.cards.get(cardId)?.kind;
// Enhancements ATTACH to a card already down, so their candidates are the occupied cells.
const targets = kind?.kind === 'enhancement' ? attachments : placements;
// Orientation is chosen on placement, and a printed card turns but never flips, so the widest
// variant set is TWO. Anything that is not track has a single orientation and needs one entry.
const rotations = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).length : 1;
for (const placement of targets) {
// A printed card turns but does not flip, so the widest variant set is TWO. This offered six
// per placement per card back when a curve could be laid at all four rotations; four of every
// six were dead weight in an option list the bot scores in full.
for (let variant = 0; variant < 2; variant++) {
for (let variant = 0; variant < rotations; variant++) {
out.push({ type: 'card.play', cardId, placement, variant });
}
}
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
}
// Lay track from the personal supply — the only way a district grows now that track is not in
// the deck.
for (const [key, count] of s.turn.laidThisTurn ? [] : area.trackSupply) {
if (count < 1) continue;
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
for (const placement of placements) {
// 0° and 180°. A straight is the same card either way; `check` discards the duplicate.
for (let variant = 0; variant < variantsFor(geometry, hand).length; variant++) {
out.push({ type: 'track.lay', geometry, hand, placement, variant });
}
}
}
// Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
for (const cardId of s.decks.hands.get(player) ?? []) {
if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
+13 -8
View File
@@ -18,7 +18,7 @@ import {
OFFICE_PROFILES,
MAINLINE_PROFILES,
mainlineProfile,
TRACK_SUPPLY,
TRACK_CARDS,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
@@ -90,7 +90,11 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key });
}
}
// Track is NOT in the deck any more — it is a per-player supply (content.ts TRACK_SUPPLY).
// Track is IN the deck, 104 cards of it — the single largest category, and the reason building a
// district costs you the industry or train you did not draw instead.
for (const t of TRACK_CARDS) {
for (let i = 0; i < t.copiesInDeck; i++) push({ kind: 'track', geometry: t.geometry, hand: t.hand });
}
return cards;
}
@@ -152,7 +156,6 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
adOccupancy: [],
heldAtLimits: [],
dispatchUsedToday: [],
trackSupply: new Map(TRACK_SUPPLY.map((t) => [`${t.geometry}:${t.hand}`, t.perPlayer])),
};
}
@@ -254,11 +257,13 @@ export function createGame(opts: SetupOptions): GameState {
hands.set(p, shuffled.slice(cursor, cursor + 3));
cursor += 3;
}
const departments: (CardId | null)[] = [
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
shuffled[cursor++] ?? null,
];
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
// that grows as players discard onto it.
const departments: CardId[][] = [[], [], []];
for (const pile of departments) {
const card = shuffled[cursor++];
if (card) pile.push(card);
}
const homeOffice = shuffled.slice(cursor);
const redFlags = new Map<PlayerIndex, boolean>();
+40 -16
View File
@@ -9,6 +9,7 @@
import type {
CarType,
Direction,
Hand,
MainlineKind,
FreightKind,
GameLength,
@@ -127,11 +128,6 @@ export type OfficeArea = {
heldAtLimits: TrayId[];
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
dispatchUsedToday: string[];
/**
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
* Home Office deck — it is laid from here.
*/
trackSupply: Map<string, number>;
};
// ---------------------------------------------------------------------------
@@ -192,10 +188,24 @@ export function spaceOn(card: TrackCard): number {
export function isOperationalRail(card: TrackCard): boolean {
if (!card.baseOperationalRail) return false;
if (card.facility && card.facility.menAtWork.some((slot) => slot !== null)) return false;
if (isLockedByWork(card)) return false;
return true;
}
/**
* §9.3 — "While ANY loads are in the MEN | AT | WORK track, the industry's track is locked down for
* safety reasons. It loses its status as Operational Rail. No cars can be picked up or dropped off,
* and **no trains may occupy or move on it**."
*
* That last clause is stronger than losing Operational Rail status. A turnout is not Operational
* Rail either, and a train runs straight through one — so "cannot stop here" and "cannot pass
* through here" are different properties, and only a locked industry has both. Kept separate from
* `isOperationalRail` for exactly that reason.
*/
export function isLockedByWork(card: TrackCard): boolean {
return !!card.facility && card.facility.menAtWork.some((slot) => slot !== null);
}
// ---------------------------------------------------------------------------
// Trains
// ---------------------------------------------------------------------------
@@ -223,7 +233,16 @@ export type CrewTray = {
* the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them.
*/
engineAt: number;
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
/**
* ORDERED NOSE FIRST: index 0 is the end nearest the front of the train, the last index is the
* tail. Max 4 including any caboose (§A.4); the engine is not one of them.
*
* Both diagrams in Appendix A read this way. A train drawn `[<ENG][yellow][brown][blue][red]`
* drops "red as he starts, then brown and blue", ending with only yellow — cars come off the TAIL,
* which is the end of the array. And a train that picks cars up on its nose gets them "in the
* order that they were in, pushing them into the Facility" — ahead of the engine, which is why
* `engineAt` moves when it happens.
*/
consist: RollingStock[];
direction: Direction;
/**
@@ -298,7 +317,8 @@ export type CardKind =
| { kind: 'office'; tier: OfficeTier }
| { kind: 'freightFacility'; facility: FreightKind }
| { kind: 'modifier'; modifier: ModifierKind }
| { kind: 'track'; geometry: TrackGeometry }
/** Handedness is printed on the card: it is the diagonal the 45° leg lies on. */
| { kind: 'track'; geometry: TrackGeometry; hand: Hand }
/**
* The four categories recovered from the design. They are in the deck so the composition is
* right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so
@@ -315,8 +335,18 @@ export type Card = { id: CardId; kind: CardKind };
export type Decks = {
/** Face down. Order is SECRET — never projected to any client. */
homeOffice: CardId[];
/** Three face-up market slots fed from the deck (§2.6). */
departments: (CardId | null)[];
/**
* THE THREE DEPARTMENT DECKS — shared by every player, and stacks rather than single slots.
*
* §6.2: a discard goes "face up ON TOP of one of the three Department slots", and a draw takes
* "the TOP face-up card". Both phrases only mean anything if a Department is a pile, and modelling
* them as one-card slots silently DESTROYED whatever was underneath — discarding onto an occupied
* slot overwrote it, leaking cards out of a closed deck.
*
* Last element is the top of the pile: the card that is face up, the only one that may be drawn,
* and the one a discarding player is choosing to offer or to bury.
*/
departments: CardId[][];
/** Face up, so players can audit discards (§2.6). */
salvageYard: CardId[];
/** Private to the owner. Max 3, or 4 with a Red Flag. */
@@ -412,11 +442,6 @@ export type TurnState = {
option: 'switch' | 'draw' | 'freightAgent' | null;
movesRemaining: number;
drawnThisTurn: boolean;
/**
* Track was a CARD before it became a per-player supply, so it was naturally limited to one play
* a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
*/
laidThisTurn: boolean;
freightAgentUsed: boolean;
/** Set when the actor finishes; the phase driver then moves to the next player. */
done: boolean;
@@ -427,7 +452,6 @@ export function freshTurn(moves: number): TurnState {
option: null,
movesRemaining: moves,
drawnThisTurn: false,
laidThisTurn: false,
freightAgentUsed: false,
done: false,
};
+7 -1
View File
@@ -34,7 +34,7 @@ import type {
TrackArc,
TurnoutOrientation,
} from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import { carsOn, coordKey, isLockedByWork, isOperationalRail, spaceOn } from './state.ts';
// ---------------------------------------------------------------------------
// Ports and geometry
@@ -357,6 +357,12 @@ export function reachableDestinations(
if (!hasPort(card, node.entry)) continue;
// §9.3 — a locked industry may not be occupied OR MOVED ON. Losing Operational Rail status only
// stops a train FINISHING here; a turnout is not Operational Rail either and trains run through
// one all day. Without this a crew rolled straight over a locked industry, and coupled the cars
// spotted on it on the way past — the two things the safety lockout exists to prevent.
if (isLockedByWork(card)) continue;
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
// overfill the tray is illegal, not a move that picks up fewer cars.
const couples = [...node.couples, ...carsOn(card)];
+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}`;
}
+58 -15
View File
@@ -41,8 +41,10 @@ import {
variantLabel,
} from '../sim/view.ts';
import { HAND_LIMIT, trainProfile } from '../engine/content.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { joins, neighbour, variantsFor } from '../engine/track.ts';
import { areaOf } from '../engine/apply.ts';
import type { Frame } from '../sim/view.ts';
export const SOLO_CONFIG: GameConfig = {
@@ -103,7 +105,6 @@ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
{ prefix: 'card.play', title: 'Play a card from my hand' },
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
{ prefix: 'draw.end', title: 'Finish' },
{ prefix: 'track.lay', title: 'Lay track from your supply' },
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
{ prefix: 'maneuver.', title: 'Maneuvers' },
@@ -262,19 +263,18 @@ function subjectOf(
): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } } | null {
const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`;
if (i.type === 'card.play' && i.placement) {
// A track card is an ordinary card play; its rotation and what it would meet are the whole of
// the decision, so they ride on the spot rather than being left for the player to work out.
const kind = game.state.cards.get(i.cardId)?.kind;
const track =
kind?.kind === 'track'
? rotationNote(kind.geometry, i.variant, kind.hand) +
joinsNote(game, kind.geometry, kind.hand, i.variant, i.placement)
: rotationNote(null, i.variant);
return {
subjectKey: `card:${i.cardId}`,
subject: cardName(game.state, i.cardId),
spot: `${at(i.placement)}${rotationNote(null, i.variant)}`,
coord: i.placement,
};
}
if (i.type === 'track.lay') {
const hand = i.hand === 'none' ? '' : `${i.hand}-hand `;
return {
subjectKey: `track:${i.geometry}:${i.hand}`,
subject: `${hand}${geometryLabel(i.geometry)}`,
spot: `${at(i.placement)}${rotationNote(i.geometry, i.variant)}`,
spot: `${at(i.placement)}${track}`,
coord: i.placement,
};
}
@@ -285,10 +285,53 @@ function subjectOf(
* Rotations share a square, so the square alone does not identify the choice — and "rotation 2"
* does not tell a player which way the rail will run, which for a curve or turnout is the entire
* decision. Only shown when there is more than one way to lay the piece.
*
* HAND IS NOT OPTIONAL HERE. It decides which diagonal the 45° leg lies on, so `variantsFor` without
* it answers for the left-hand card whatever you are actually holding: every right-hand turnout was
* offered as "stem west, through east, diverges south" — the mirror of the card it would lay — and
* every right-hand curve named the wrong edge. The placement was right and the description was
* backwards, which is worse than no description at all.
*/
function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string {
function rotationNote(
geometry: TrackGeometry | null,
variant: number | undefined,
hand: Hand = 'none',
): string {
if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`;
return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : '';
return variantsFor(geometry, hand).length > 1 ? variantLabel(geometry, variant, hand) : '';
}
/**
* WHAT THIS PLACEMENT WOULD ACTUALLY CONNECT TO.
*
* Two cards meeting at an edge is not a rail — on a north or south edge their 45° legs must also lie
* on the same diagonal — so "is this square legal" and "does this piece join the one I am aiming at"
* are different questions, and only the first was on screen. A player building a crossover down onto
* a siding had to pick a hand and a rotation and find out afterwards.
*/
function joinsNote(
game: Game,
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
placement: { row: number; col: number },
): string {
const v = variantsFor(geometry, hand)[variant ?? 0];
if (!v) return '';
const area = areaOf(game.state, 0);
const probe = {
geometry: { kind: 'track', geometry, ...v, ...(hand !== 'none' ? { hand } : {}) },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never;
const where: Record<Port, string> = { n: 'above', s: 'below', e: 'to the east', w: 'to the west' };
const met: string[] = [];
for (const p of ['n', 's', 'w', 'e'] as Port[]) {
const n = neighbour(placement, p);
const nb = area.grid.get(`${n.row},${n.col}`);
if (nb && joins(probe, p, nb)) met.push(where[p]);
}
return met.length === 0 ? '' : ` · joins the track ${met.join(' and ')}`;
}
/**
+64 -16
View File
@@ -254,25 +254,50 @@ function render(): void {
$('hand').innerHTML = f.hand.length
? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('')
: '<span class="dim">empty</span>';
// Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there
// is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they
// do not read as clickable.
$('depts').innerHTML = f.departments.length
? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('')
: '<span class="dim">none</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
*
* They are decks, not single face-up cards: a discard goes on TOP of the one its owner chooses, so
* a pile is a card being offered with a history of cards buried under it. Only the top may be
* taken, which makes the depth real information — a deep pile is where cards have been put beyond
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
const pileCard = (label: string, top: string, depth: number, why: string, tipExtra = ''): string => {
const tip = [why, tipExtra].filter(Boolean).join(' · ');
return (
`<div class="handcard"${tip ? ` data-tip="${esc(tip)}"` : ''} tabindex="0">` +
`<div class="pilehd"><span>${esc(label)}</span><span class="depth">${depth}</span></div>` +
`<b>${esc(top)}</b></div>`
);
};
$('depts').innerHTML =
f.departments
.map((d, i) => {
const depth = f.departmentDepth[i] ?? 0;
const under = depth - 1;
return pileCard(
`Dept ${i + 1}`,
d,
depth,
f.departmentsWhat[i] ?? '',
under > 0 ? `${under} card${under === 1 ? '' : 's'} buried beneath it and out of reach` : '',
);
})
.join('') +
// The Salvage Yard beside them, because §6.2 sweeps it back into the deck and it is the only
// visible sign that the reshuffle is coming.
pileCard(
'Salvage',
f.salvage.top,
f.salvage.depth,
'',
'Played cards that did not stay on the board. Swept back into the Home Office deck, with the Departments, when the deck runs out.',
);
renderYards(f);
const total = f.trackSupply.reduce((n, t) => n + t.left, 0);
$('supply').innerHTML =
`<div class="dim">${total} pieces left · one may be laid per turn, during the DRAW option</div>` +
f.trackSupply
.map(
(t) =>
`<span class="card ${t.left === 0 ? 'gone' : ''}">${esc(t.piece)} <b>${t.left}</b></span>`,
)
.join(' ');
$('blocked').innerHTML =
f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
@@ -541,6 +566,29 @@ installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
/**
* Forget the saved game and deal a fresh one.
*
* The only way out used to be finishing the game — `start()` restores from localStorage on every
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
* appeared solely on the game-over screen. Confirmed because the save is the game: there is no undo,
* and the replay download is right beside it.
*
* `location.search = ''` rather than a direct re-render, so a `?seed=` in the URL goes too — leaving
* it would deal the same game again and look like the button had done nothing.
*/
const newBtn = document.getElementById('newgame');
if (newBtn) {
newBtn.onclick = () => {
const day = game.state.clock.day;
const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1);
if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return;
clearSave();
if (location.search === '') location.reload();
else location.search = '';
};
}
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
+9 -2
View File
@@ -46,6 +46,13 @@ header button:hover{border-color:#4d6fa8}
padding:5px 8px;font-size:11px;min-width:96px;position:relative}
.handcard:focus{outline:2px solid #4d6fa8;outline-offset:1px}
.cardrow.ref .handcard{background:#1c2129;border-style:dashed;border-color:#39424e;color:#b6bec9}
/* A pile shows two things: which card is face up on top, and how many are under it. The depth is a
count and not a hint — only the top card may ever be drawn, so everything below it is out of
reach, and choosing where to discard is choosing what to put there. */
.pilehd{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin-bottom:2px;
font-size:10px;text-transform:uppercase;letter-spacing:.07em;color:var(--dim)}
.pilehd .depth{font-variant-numeric:tabular-nums;background:#2a3038;border-radius:8px;
padding:0 6px;color:#cfe0f5}
/* NOT YET PLAYABLE, drawn rather than captioned. A "not yet" tag had to be read; a crosshatch is
seen. The card stays legible underneath — it is unavailable, not irrelevant. */
.handcard.unplayable{color:#7d8794;border-color:#39424e}
@@ -188,6 +195,7 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<span class="dim">saved in this browser · add ?seed=1234 for a set deal</span>
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
<button id="newgame" title="Forget the game saved in this browser and deal a fresh one. There is no undo — download the replay first if you want to keep it.">New game</button>
<a class="home" href="./replays.html" style="font-size:12px">replays</a>
<span class="dim build" title="what is actually deployed">__BUILD__</span>
</header>
@@ -219,9 +227,8 @@ ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:w
<section><h2 class="big">Your Move</h2><div id="actions"></div></section>
<section><h2 class="big">Cards Available</h2>
<h3>Cards in My Hand</h3><div id="hand" class="cardrow">—</div>
<h3>Department Cards (face up)</h3><div id="depts" class="cardrow ref">—</div>
<h3>Department Decks &amp; Salvage Yard — top card face up, with depth</h3><div id="depts" class="cardrow ref">—</div>
</section>
<section><h2 class="big">Your Track Supply</h2><div id="supply"></div></section>
<section><h2 class="big">The Yards</h2>
<h3>Division Yard <span class="dim" id="divtot"></span></h3>
<div id="divyard" class="yard"></div>