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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user