/** * Station Master — solitaire, playable in a browser. * * The whole game runs client-side. There is no server and no network call at any point: the engine * is pure, imports nothing outside itself, and never touches `Math.random`, `Date` or `crypto`, so * a static host is all this needs. (Proven, not assumed — a test runs full games with every Node * global replaced by a throwing stub.) * * WHAT THIS MODULE IS. Everything here is presentation and input. It builds no rules of its own: * * - what you may do -> `legalActions(state, actor)` * - what it means -> `describeIntent()`, shared with the replay * - what the board looks like -> `snapshot()`, shared with the replay * - what just happened -> `narrate()`, shared with the replay * * That is the same discipline the engine holds itself to. A second opinion about which moves are * legal would eventually disagree with `check`, and the failure mode is a UI that offers an illegal * move or refuses a legal one. * * SAVING. The intents ARE the game — the RNG is seeded and `applyIntent` is deterministic — so a save * is the seed plus the list of intents submitted. Replaying them reconstructs the position exactly, * which is far smaller and far more robust than serialising the state graph. (Not the event log: * folding events does not rebuild a game — `protocol.md` §3.) */ import { pump } from '../engine/advance.ts'; import { applyIntent } from '../engine/apply.ts'; import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts'; import { playerAtSeat } from '../engine/state.ts'; import { cuesFor, narrate } from '../sim/narrate.ts'; // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, // which would pull node:fs into a browser bundle. import { cardDescription, cardName, describeIntent, geometryLabel, snapshot, trainName, variantLabel, } from '../sim/view.ts'; import { DEFAULT_HOUSE_RULES, HAND_LIMIT, LEGACY_HOUSE_RULES, houseRules, mainlineProfile, trainProfile, } from '../engine/content.ts'; import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts'; import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts'; import type { Frame } from '../sim/view.ts'; export const SOLO_CONFIG: GameConfig = { mode: 'solitaire', victory: 'highestAfterDays', length: 'standard', optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, // Spelt out rather than left to fall through, so a save written by the page always names the rules // it was played under — see `Save.rules`. houseRules: DEFAULT_HOUSE_RULES, }; /** The same config with the New Game dialog's answers in it. */ export function configWith(rules: HouseRuleOverrides): GameConfig { return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) }; } /** A group of legal actions of one kind, ready to put on screen. */ export type ActionGroup = { kind: string; title: string; /** * `tip` is the card's own description, resolved HERE rather than by the page. * * The button label is short; the hover text used to be looked up with `cardDescription(state, id)` * from the browser, which needs the whole `GameState`. A remote client has no state, so the menu * carries it. See `docs/architecture/multiplayer.md` §5. */ /** * `coord` is the square the action HAPPENS ON, when it happens on one. * * Carried as data rather than left inside the label. Half these buttons are near-identical * sentences distinguished only by a coordinate — "(1,3)" against "(-1,3)" — and picking the wrong * one is recoverable in solitaire, where Undo is a click, and a disaster in a multiplayer game * where it is not. The page hovers the matching square on the board instead of asking the player * to read the row and column off the button. Reported by Jesse. * * Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look * up where a tray is standing. See `docs/architecture/multiplayer.md` §5. */ actions: { index: number; label: string; tip?: string; coord?: { row: number; col: number }; /** * Every square a `switch.move` runs OVER on its way to `coord`, when there is more than one * legal route there (docs/plans/switching-paths.md) — so hovering a route lights the whole * road, not just its destination, which is the only way to tell two buttons reading "move to * (0,0)" apart before clicking one. */ route?: { row: number; col: number }[]; }[]; }; /** * The square an intent acts on, or null when it acts on none. * * Only squares the intent NAMES. A card played out on the Mainline (`node`) is not a district * coordinate and must not be treated as one — that confusion is exactly why `placement` and `node` * are separate fields on the intent (see `intents.ts`) — and an action like ending a turn has no * square at all. */ export function coordOf(i: Intent): { row: number; col: number } | null { if ('at' in i) return i.at; if ('to' in i) return i.to; if (i.type === 'card.play' && i.placement) return i.placement; return null; } /** * Every intermediate square a `switch.move` runs over, resolved the same way `execute` resolves * `via` — so the squares that light up on hover are exactly the squares the move will actually * couple cars from. `undefined` for the overwhelming majority of moves, which run in a straight * line and need nothing beyond the destination `coordOf` already carries. */ function routeFor(state: GameState, i: Intent): { row: number; col: number }[] | undefined { if (i.type !== 'switch.move') return undefined; const tray = state.trays.get(i.trayId); if (!tray || tray.position.at !== 'grid') return undefined; const actor = playerAtSeat(state, tray.position.seat); const dests = destinationsFor(state, actor, i.trayId, tray.position.coord, i.reverse); const dest = selectDestination(dests, i.to, i.via); if (!dest || dest.path.length === 0) return undefined; return dest.path.map((step) => step.coord); } export type Game = { state: GameState; seed: number; /** Every intent submitted, in order — the save file. */ history: Intent[]; /** Narrated lines, newest last. */ log: { text: string; tone: string }[]; /** * True when the hand is OVER the limit, so the turn cannot end until it is played down. * * §6.2 is a hand limit — "reduce his hand to no more than three cards" — not a rule that a draw * must be spent. An earlier version set this on any draw, which forced a play even when two cards * had already been played and the hand held three or fewer. Derived from the hand each render, so * it cannot drift out of step with what is actually held. */ mustPlayCard: boolean; /** * Sounds the last batch of events earned, for the page to play and clear. * * The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view * decides what that sounds like. Detection lives here because this is where events are seen; it * would be guesswork from a rendered frame. */ cues: string[]; /** * The timetable slot the last batch of events filled, or null. * * Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see. * Carrying the slot lets the timetable flash the one that just changed — the roll becomes * something you watch land rather than something you are told about afterwards. */ scheduled: number | null; /** * The card most recently drawn into hand, or null. * * A drawn card arrives among two others that look exactly like it, and nothing said which was new. * Unlike `scheduled`, this is NOT cleared on the next render: it marks WHICH CARD IS NEW rather * than that a draw just happened, so it stands until another draw replaces it. Nothing needs to * clear it when the card is played — no element carries the class once the card leaves the hand. */ justDrawn: CardId | null; /** * A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`, * because it marks a MOMENT — the only event in the game that scores for everybody without anyone * having taken a turn to cause it. */ announced: string | null; }; /** How each intent kind is introduced in the action list, in the order they should appear. */ const GROUP_ORDER: readonly { prefix: string; title: string }[] = [ { prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' }, { prefix: 'localOps.choose', title: 'Local Operations — choose ONE' }, { prefix: 'switch.', title: 'Switching' }, // Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the // three are different acts. Taking a face-up Department card is not the same decision as // gambling on the Home Office deck, and neither is ending the turn. { prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' }, { prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' }, { 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: 'mainline.modify', title: 'Mainline modifiers' }, // American spelling throughout, to match MANEUVER_CARDS and the source deck. { prefix: 'maneuver.', title: 'Maneuvers' }, { prefix: 'freightAgent.', title: 'Freight Agent' }, { prefix: 'newTrain.', title: 'Making up the train' }, { prefix: 'porter.', title: 'Porters' }, { prefix: 'laborer.', title: 'Laborers' }, { prefix: 'loadUnload.', title: 'Finish' }, { prefix: 'redFlag.', title: 'Red flag' }, ]; /** * The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a * log that says "You" cannot be read back by anyone else — a save is meant to be sent around. */ export const SOLO_PLAYER = 'Solitaire'; export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; // A history that opens mid-Stage reads as though something was missed. Say what the game IS // first, then let the clock take over. game.log.push({ text: 'Game Begins', tone: 'start' }); game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' }); drain(game); return game; } /** * Run the engine forward until it needs a decision. * * Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the * player is only ever asked when `advance` genuinely stops. */ export function drain(game: Game): void { record(game, pump(game.state)); } /** Whose turn it is, or null if the game is over or waiting on nothing. */ export function currentActor(game: Game): PlayerIndex | null { if (game.state.status !== 'active') return null; return game.state.clock.pendingDecision !== null ? game.state.clock.superintendent : game.state.clock.currentActor; } /** Every legal action right now, grouped for display. Empty when there is nothing to decide. */ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } { const actor = currentActor(game); if (actor === null) return { options: [], groups: [] }; const options = legalActions(game.state, actor); /** * Entries carry the crew they belong to, rather than it being encoded into the map key. * * An earlier version keyed this map by `type + separator + trayId`, which worked and was a * standing invitation: the key is also what `GROUP_ORDER` prefix-matches on, so the separator had * to survive every edit to a line nobody would think to check. One stray byte and every crew's * moves silently collapsed back into a single group. The tray is data; it travels as data. */ type Entry = { index: number; label: string; tip?: string; trayId?: string; coord?: { row: number; col: number }; route?: { row: number; col: number }[]; }; const byKind = new Map(); options.forEach((intent, index) => { const label = describeIntent(game.state, intent); const trayId = 'trayId' in intent ? intent.trayId : undefined; const list = byKind.get(intent.type) ?? []; const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null; const tip = cardId ? cardDescription(game.state, cardId) : undefined; /** * Identical labels are collapsed, which is right for forty copies of the same track rotation and * wrong for two different trains: "move to (0, 2)" describes Train 8's move and the local crew's * move identically, so one of them was silently dropped and could not be chosen at all. Matching * on the crew as well makes the de-duplication per crew, which is what it always meant. */ if (!list.some((a) => a.label === label && a.trayId === trayId)) { const coord = coordOf(intent); const route = routeFor(game.state, intent); list.push({ index, label, ...(tip ? { tip } : {}), ...(trayId ? { trayId } : {}), ...(coord ? { coord } : {}), ...(route ? { route } : {}), }); } byKind.set(intent.type, list); }); /** Groups take the plain shape; the crew was only ever needed to split them. */ const plain = (entries: Entry[]): ActionGroup['actions'] => entries.map(({ index, label, tip, coord }) => ({ index, label, ...(tip ? { tip } : {}), ...(coord ? { coord } : {}), })); const groups: ActionGroup[] = []; const used = new Set(); for (const { prefix, title } of GROUP_ORDER) { const kinds = [...byKind.keys()].filter((k) => k.startsWith(prefix) && !used.has(k)); /** * ONE GROUP PER CREW, NAMED — because more than one train can be switching in a district. * * Reported from play: with two crews on the board, every move from both of them arrived in a * single "Switching" list of bare coordinates, and there was no way to tell which train a button * belonged to. Naming the train in the heading rather than on every button keeps the buttons * short, and the crew's own square is in the heading so the list can be matched to the board. * * `switch.end` carries no tray and is the whole turn rather than one crew's, so it keeps its own * heading at the bottom. */ if (prefix === 'switch.') { const perCrew = new Map(); const loose: Entry[] = []; for (const k of kinds) { used.add(k); for (const entry of byKind.get(k) ?? []) { if (entry.trayId === undefined) loose.push(entry); else perCrew.set(entry.trayId, [...(perCrew.get(entry.trayId) ?? []), entry]); } } for (const [trayId, entries] of perCrew) { const tray = game.state.trays.get(trayId); const where = tray?.position.at === 'grid' ? `, standing at (${tray.position.coord.col}, ${tray.position.coord.row})` : ''; groups.push({ kind: prefix, title: `Switching ${trainName(game.state, trayId)}${where}`, actions: plain(entries), }); } // `switch.end` belongs to the TURN rather than to any one crew, so it gets its own heading — // "Switching" over a lone "End Local Operations" reads as a crew with nothing it can do. if (loose.length > 0) groups.push({ kind: prefix, title: 'Finish', actions: plain(loose) }); continue; } const actions = kinds.flatMap((k) => { used.add(k); return plain(byKind.get(k) ?? []); }); if (actions.length > 0) { // The New Train group names the TRAIN and what its card calls for. Without it, Extra X22 // "Pee-Dee" — a per-diem train whose consist is one caboose and nothing else — offers a // single button to add a caboose and no reason why, which reads as a broken game rather than // as the card doing exactly what it prints. // The tray the phase is waiting on, so the heading names the train the yard chips will load // rather than whichever tray happened to come first out of the map. const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null; let headed = filling !== null ? (consistTitle(game, filling) ?? title) : title; /** * THE RULING NAMES ITS TRAINS IN THE HEADING TOO. * * "rule on this train" was the only thing said in plain sight about the §8.1 clearance, and * "this train" is exactly the part the Superintendent has to know. The pending decision holds * both trays, so the heading can ask the actual question. */ if (prefix === 'mainline.clearance') { const pending = game.state.clock.pendingDecision; if (pending) { headed = `Superintendent — may ${trainName(game.state, pending.train)} follow ` + `${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`; } } /** * A PENDING EXTRA IS ITS OWN QUESTION, and its own heading. * * "Where does this Extra start?" arrived in the same `newTrain.` group as "which car goes on * this train", under whichever heading the group happened to have — and with no tray being * filled there is no train to name, so it read "Making up the train" over a choice about a * different train entirely. Split out, and headed with the Extra's own card: it is about to * run, and what it may carry is the thing the player needs before choosing where to put it. */ const extras = actions.filter((a) => options[a.index]?.type === 'newTrain.startExtra'); const rest = actions.filter((a) => options[a.index]?.type !== 'newTrain.startExtra'); if (extras.length > 0) { const number = (options[extras[0]!.index] as { trainNumber: number }).trainNumber; groups.push({ kind: prefix, title: `${trainCardTitle(number, true) ?? title} — choose where it starts`, actions: extras, }); } if (rest.length > 0) groups.push({ kind: prefix, title: headed, actions: rest }); } } // Anything the table above does not name still has to be offered — silently dropping a legal // action would make the game unplayable in a way that is very hard to notice. const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k)); for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions }); return { options, groups }; } /** * A thing you might do, and — if it goes on the board — where it could go. * * Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every * (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons * with no way to tell which square each referred to; picking the card first and the square second is * how the choice is actually made at the table. */ export type Placeable = { /** Groups every option that plays the same card or lays the same piece. */ subjectKey: string; subject: string; /** * Where it may go. `coord` drives board highlighting; several spots can share one square when the * piece has more than one legal rotation there, which is why the label carries the rotation too. */ spots: { label: string; index: number; /** * The square on the board this spot would fill, or **null** when the placement is not on the * board at all — ABS Signals goes out on the Mainline, and `node` names which card. * * Null rather than a stand-in coordinate. A Mainline placement used to travel as * `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a * district card for a placement that was never going there. */ coord: { row: number; col: number } | null; /** Division node index, for a placement out on the Mainline. */ node?: number; /** * The rails this placement would put on the card, as `connectionsFor` codes. * * So the UI can DRAW the piece as it would land rather than only describing it. Words alone are * not enough for a turnout: "allows traffic from the east to travel west or turn to the south" * is exact and still leaves a player working out which way the leg points on the board. Taken * from the engine's own connections, so a preview cannot promise a shape the placement will not * produce. */ links: string[]; }[]; }; /** * A CARD IN HAND, AND WHAT MAY BE DONE WITH IT. * * The hand is the action surface. A card used to appear in two panels under two different models: * as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and * as one flat button per Department under "Discard a card from my hand". One card, two mental * models, and the discard block was a cross-product — four cards x three Departments was twelve * buttons and about 290px of the action list, repeating the same three choices four times. * * Both verbs now hang off the card itself, and both use the pattern the board placement already * had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is * already on screen with its top card and depth — exactly what you need to choose between them. */ export type HandAction = { cardId: string; name: string; what: string; /** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */ playNow: number | null; /** Playable onto the board: the `subjectKey` of the matching `placeable` item. */ placeKey: string | null; /** How many squares it may go on, for the count on the button. */ spots: number; /** The option index for discarding onto each Department pile, or null where that is illegal. */ discard: (number | null)[]; /** * Every distinct shape the card could be laid as, for the hover preview. * * Derived from the CARD, not from its legal placements: what a piece looks like does not depend * on whether there is currently a square for it, and a player picking through a hand needs to see * the shape most when there is nowhere obvious to put it. Reading it off the placements meant a * curve with no legal square showed no preview at all. */ shapes: string[][]; }; /** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */ export type MakeUpAction = { carType: string; loaded: boolean; index: number }; export type Menu = { options: Intent[]; /** Actions with no further choice to make. */ direct: ActionGroup[]; /** Actions needing a location, grouped under their card or track piece. */ placeable: { title: string; items: Placeable[] }[]; /** One entry per card in hand, with the verbs available to it. */ hand: HandAction[]; /** * Making up a train: the cars that may be added, and the option to add none. * * Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard * is already on screen showing exactly those cars by type and load state. The yard is the surface; * these key each chip to the option that adds it. */ makeUp: { trayId: string; title: string; cars: MakeUpAction[]; pass: number | null; /** * The order to add the cars in, when the order decides whether the train can work at all. * Absent for every train where it does not matter, which is nearly all of them. */ advice: { text: string; tone: 'hint' | 'warn' } | null; } | null; }; /** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */ export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu { const { options, groups } = actionGroups(game); const direct: ActionGroup[] = []; const placeableByTitle = new Map>(); for (const g of groups) { const plain: ActionGroup['actions'] = []; for (const a of g.actions) { const intent = options[a.index]!; const key = subjectOf(game, intent); if (key === null) { plain.push(a); continue; } const bucket = placeableByTitle.get(g.title) ?? new Map(); const entry = bucket.get(key.subjectKey) ?? { subjectKey: key.subjectKey, subject: key.subject, spots: [], }; if (!entry.spots.some((sp) => sp.label === key.spot)) { entry.spots.push({ label: key.spot, index: a.index, coord: key.coord, links: key.links, ...(key.node === undefined ? {} : { node: key.node }), }); } bucket.set(key.subjectKey, entry); placeableByTitle.set(g.title, bucket); } if (plain.length > 0) direct.push({ ...g, actions: plain }); } const placeable = [...placeableByTitle.entries()].map(([title, m]) => ({ title, items: [...m.values()], })); /** * THE HAND, AND WHAT EACH CARD CAN DO. * * Built from the same `options` everything else reads, so a verb offered here is one `check` has * already accepted. Cards are not regrouped or sorted: the hand is a row of objects the player is * looking at, not a list to sort. * * NEWEST FIRST. The engine pushes a drawn card onto the END of the hand, and with the row wrapping * that put the card you just turned over wherever the eye is least likely to be — reported from * playtesting. Reversing HERE rather than in the engine is deliberate: the bot iterates its hand to * generate options, so changing the stored order would reshuffle its tie-breaks and invalidate * every revenue measurement in TODO.md. `snapshot()` reverses identically for the replay viewers. */ const handIds = [...(game.state.decks.hands.get(seat) ?? [])].reverse(); const hand: HandAction[] = handIds.map((cardId) => { const place = placeable.flatMap((g) => g.items).find((it) => it.subjectKey === `card:${cardId}`); let playNow: number | null = null; const discard: (number | null)[] = [null, null, null]; options.forEach((i, index) => { if (i.type === 'card.play' && i.cardId === cardId && i.placement === undefined) playNow = index; if (i.type === 'card.discard' && i.cardId === cardId) discard[i.toSlot] = index; }); const kind = game.state.cards.get(cardId)?.kind; const shapes = kind?.kind === 'track' ? variantsFor(kind.geometry, kind.hand).map((v) => connectionsFor({ geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], } as never).map(([a, b]) => `${a}${b}`), ) : []; return { cardId, name: cardName(game.state, cardId), what: cardDescription(game.state, cardId), playNow, placeKey: place ? place.subjectKey : null, spots: place ? place.spots.length : 0, discard, shapes: [...new Map(shapes.map((l) => [l.join('|'), l])).values()], }; }); /** * MAKING UP A TRAIN — ONE TRAIN. * * `newTrain.placeCar` carries the car, and the Division Yard chip showing that car is where the * click belongs. But two trains can be built in the same Stage — a timetabled train and a Second * Section, or an Extra — and this collected every option from every tray into one panel titled * with whichever tray came first. Reproduced at seed 99, Day 3 Stage 12: eighteen car chips under * "Making up Train 8", covering two different trains. * * Worse than a wrong caption: the yard chip binds to the FIRST matching option, so clicking a * hopper could couple it to the other train entirely. * * So the panel is scoped to ONE tray — the one the engine is actually waiting on * (`trainNeedingCars`, the same predicate the New Train Phase stops for). The second train comes * up as soon as the first is done, which is how the phase runs anyway. */ const filling = trainNeedingCars(game.state) ?? options.find((i): i is Extract => i.type === 'newTrain.placeCar' || i.type === 'newTrain.passCar', )?.trayId ?? null; const makeUpCars: MakeUpAction[] = []; let pass: number | null = null; options.forEach((i, index) => { if (i.type === 'newTrain.placeCar' && i.trayId === filling) { makeUpCars.push({ carType: i.carType, loaded: i.loaded, index }); } if (i.type === 'newTrain.passCar' && i.trayId === filling) pass = index; }); const makeUp = filling !== null && (makeUpCars.length > 0 || pass !== null) ? { trayId: filling, title: consistTitle(game, filling) ?? 'Making up the train', cars: makeUpCars, pass, advice: makeUpAdvice(game, filling, makeUpCars), } : null; return { options, direct, placeable, hand, makeUp }; } /** Split an intent into "what" and "where", or null if it needs no location. */ function subjectOf( game: Game, i: Intent, ): { subjectKey: string; subject: string; spot: string; coord: { row: number; col: number } | null; node?: number; links: string[]; } | null { // X,Y — east/west then north/south, not the internal row/col storage order. const at = (c: { row: number; col: number }): string => `(${c.col}, ${c.row})`; /** * ABS Signals is placed on a MAINLINE card, which is not in the Office Area at all — so it names * a Division NODE and carries no coordinate. It used to travel as `{ row: -1, col: node }`, and * row −1 is an ordinary district row: an enhancement laid on a real card one row below the * Running Track was described as being "out on the Mainline", and ABS Signals itself lit up * whichever district card sat at that column. */ if (i.type === 'card.play' && i.node !== undefined) { const node = game.state.division.nodes[i.node]; const where = node?.kind === 'mainline' ? mainlineProfile(node.card).name : `Mainline card ${i.node}`; return { subjectKey: `card:${i.cardId}`, subject: cardName(game.state, i.cardId), spot: `on the ${where}, out on the Mainline`, coord: null, node: i.node, links: [], }; } 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)}${track}`, coord: i.placement, links: placementLinks(game, i.cardId, i.variant), }; } return null; } /** * The rails a card would lay, in `connectionsFor` codes — the same source the board draws from. * * Built by asking the engine what the card becomes, not by re-deriving it: a preview that worked out * the shape for itself would eventually show a different piece from the one the placement lays, which * is precisely the confusion it exists to remove. */ function placementLinks(game: Game, cardId: string, variant: number | undefined): string[] { const kind = game.state.cards.get(cardId)?.kind; if (!kind) return []; if (kind.kind === 'track') { const v = variantsFor(kind.geometry, kind.hand)[variant ?? 0]; if (!v) return []; return connectionsFor({ geometry: { kind: 'track', geometry: kind.geometry, ...v, ...(kind.hand !== 'none' ? { hand: kind.hand } : {}) }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], } as never).map(([a, b]) => `${a}${b}`); } // Everything else that goes on the grid is a plain through track, or is not track at all. if (kind.kind === 'freightFacility' || kind.kind === 'office') return ['ew']; return []; } /** * 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, hand: Hand = 'none', ): string { if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`; 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 ''; // The probe is against the district the placement would be made in, i.e. the actor's own. const area = areaOf(game.state, currentActor(game) ?? 0); const probe = { geometry: { kind: 'track', geometry, ...v, ...(hand !== 'none' ? { hand } : {}) }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], } as never; const where: Record = { 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 ')}`; } /** * "Making up Extra X22 — Pee-Dee: 1 caboose (per-diem train, may only pick up MTs)". * * §8.2 lets a train depart with FEWER cars than its card lists but never with the wrong ones, so * the consist is the reason a button is missing. Naming it turns an unexplained restriction into a * card the player can read. */ function consistTitle(game: Game, trayId: string): string | null { const tray = game.state.trays.get(trayId); if (!tray) return null; return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra); } /** * "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…" * * Taken by NUMBER rather than by tray, because a pending Extra has no Crew Tray yet — it is waiting * for the player to say where it starts, and that heading has to name the train just as much as the * one over a train being loaded does. */ function trainCardTitle(number: number, isExtra: boolean): string | null { const p = trainProfile(number, isExtra); if (!p) return null; const parts: string[] = []; if (p.consist.freight > 0) { const types = p.consist.freightTypes?.join('/') ?? 'freight'; parts.push(`${p.consist.freight} ${types}${p.consist.emptiesOnly ? ' (empties only)' : ''}`); } if (p.consist.coach > 0) parts.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`); if (p.consist.caboose > 0) parts.push(`${p.consist.caboose} caboose`); const calls = parts.length > 0 ? parts.join(' + ') : 'no cars at all'; const note = p.rules.note ? ` — ${p.rules.note}` : ''; const name = `${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}”`; return `Making up ${name}: its card calls for ${calls}${note}`; } /** * THE ORDER YOU ADD THE CARS IN CAN DECIDE WHETHER THE TRAIN CAN EVER SWITCH. * * Only trains 7/8 Local, and only because of the rule printed on them: "coach must remain on * station track if switching", which the engine reads as "the coach is never set out". A cut always * comes off an OUTER end, so if the coach is on one outer end and the engine is on the other, every * cut on offer contains the coach and the train is locked — it cannot set out its freight car, and * it cannot even uncouple to run around, because that means leaving the coach standing too. Measured * over 60 games: 1,181 positions where a set-out should have been possible, every one refused. * * Cars are appended as they are clicked and the engine stays on the nose, so the make-up reads * ENGINE, first car, second car — and the LAST car added is the one on the outer end. Hence the * whole of the advice: do not let the coach be last. * * ENGINE coach boxcar the boxcar is on the outer end and can be set out * ENGINE boxcar coach locked — nothing can ever come off * * Said here rather than left to the player to discover, because the dead end is invisible until the * train is out on the district with no button to press and no explanation for it. */ function makeUpAdvice( game: Game, trayId: string, cars: readonly MakeUpAction[], ): { text: string; tone: 'hint' | 'warn' } | null { const tray = game.state.trays.get(trayId); if (!tray) return null; if (!trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.coachStaysOnStationTrack) return null; const hasCoach = tray.consist.some((c) => c.type === 'coach'); const lastIsCoach = tray.consist[tray.consist.length - 1]?.type === 'coach'; // Nothing to say about a coach that is not coming: the Division Yard may hold none, and a Local // made up of freight alone switches perfectly well. const coachToCome = cars.some((c) => c.carType === 'coach'); if (!hasCoach && !coachToCome) return null; /** * The coach is on the outer end — which is where it lands the moment it goes on, including for a * player who has just correctly put it on first. So the tone turns on whether it can still be * fixed: while a freight car is there to add, this is the NEXT STEP and not a mistake, and * colouring it as a mistake punishes the player for taking the advice. */ if (lastIsCoach) { const freightToCome = cars.some((c) => c.carType !== 'coach'); return freightToCome ? { tone: 'hint', text: 'Now add the freight car — it takes the outer end, leaving the coach safely inside. Sent out ' + 'as it stands, with the coach on the outer end, this train would not be able to set anything ' + 'out at all.', } : { tone: 'warn', text: 'The coach is on the outer end and there is nothing left to add that would take that end off ' + 'it. This train may never set its coach out, so it will not be able to set anything out at ' + 'all — not even to uncouple for a run-around.', }; } if (!hasCoach) { // Nothing on yet, so the good order is still free. if (tray.consist.length === 0) { return { tone: 'hint', text: 'Add the coach FIRST. This train may never set its coach out, and the last car added is the one ' + 'on the outer end — so a coach added last blocks the freight car in behind it and the train can ' + 'never switch. ENGINE, coach, freight is the order that works.', }; } // Freight is already on, so a coach added now can only land on the outer end. Saying "add the // coach first" here would be advice it is too late to take. return { tone: 'warn', text: 'The freight car is already on, so a coach added now would land on the outer end — and this train ' + 'may never set its coach out, which would leave it unable to switch at all. Send it out without ' + 'the coach if you want it to work the district.', }; } return null; } /** * §6.2 — "the player must reduce his hand to no more than three cards", four while a Red Flag is * held. The same test the engine applies to `draw.end`, asked here so the page can DISABLE the * button with a reason instead of hiding a move that has simply become illegal. */ export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean { const hand = game.state.decks.hands.get(seat) ?? []; const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT; return hand.length > limit; } /** Submit an action. Returns false and changes nothing if the engine rejects it. */ export function submit(game: Game, intent: Intent): boolean { const actor = currentActor(game); if (actor === null) return false; const result = applyIntent(game.state, actor, intent); if (!result.ok) { game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' }); return false; } game.history.push(intent); record(game, result.events, actor); game.mustPlayCard = overHandLimit(game); drain(game); return true; } /** * Which cards in hand can be played RIGHT NOW, in hand order. * * A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a * Modifier with no Facility to sit beside — looks identical to one where everything is available. * Derived from `legalActions`, so it cannot disagree with what the buttons offer. */ export function handPlayable(game: Game, seat: PlayerIndex = 0): boolean[] { const hand = game.state.decks.hands.get(seat) ?? []; const actor = currentActor(game); if (actor === null) return hand.map(() => false); const playable = new Set( legalActions(game.state, actor) .filter((i) => i.type === 'card.play') .map((i) => (i as Extract).cardId), ); return hand.map((id) => playable.has(id)); } /** * The board as the replay draws it, so the live game and the replay agree. * * `seat` is whose railroad and whose hand to show. Solitaire has one seat and never passes it. */ export function view(game: Game, seat: PlayerIndex = 0): Frame { return snapshot(game.state, [], null, null, null, false, seat); } function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void { const who = actor === null ? null : (game.state.players[actor]?.name ?? null); for (const e of events) { // The same filter the replay uses: actor changes and phase bookkeeping are noise on screen. if (e.type === 'actorChanged') continue; const n = narrate(e, { cardName: (id) => cardName(game.state, id), trainName: (id) => trainName(game.state, id), }); // "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than // one seat. Only events the player caused are attributed; the Division running itself is not. const mine = who !== null && 'player' in e; const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text; game.log.push({ text, tone: mine ? 'act' : n.tone }); } game.cues.push(...cuesFor(events)); // Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can // schedule more than one train, and the most recent is the one the eye should be sent to. for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot; // Which card just came into hand, so the row can badge it. Last one wins for the same reason. for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId; // A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to // make it happen — so it is announced rather than left to be found in the log. for (const e of events) { if (e.type === 'trainCompleted') { game.announced = `Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` + `${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`; } } // Keep the log bounded; the full history lives in `history` and can be replayed. if (game.log.length > 400) game.log.splice(0, game.log.length - 400); } // --------------------------------------------------------------------------- // Saving — seed plus intents, replayed // --------------------------------------------------------------------------- /** * A save is a seed, the house rules it was dealt under, and the intents submitted. * * `rules` is optional because saves written before the New Game dialog existed do not have it, and * those replay under `LEGACY_HOUSE_RULES` — see `configFor`. Everything written from now on carries * its rules, so a save can never again be silently re-dealt by a change of default. */ export type Save = { seed: number; history: Intent[]; rules?: HouseRuleOverrides }; export function toSave(game: Game): Save { const rules = game.state.config.houseRules; return rules ? { seed: game.seed, history: game.history, rules } : { seed: game.seed, history: game.history }; } /** * THE CONFIG A SAVE MUST BE REPLAYED UNDER, which is not necessarily today's default. * * A save is a seed and a list of intents: replay it under different rules and it is a different * game, and the symptom is not an error but a replay that quietly stops early. `TODO.md` records * that happening twice unnoticed, once 42 intents into 360. So a save that names its rules gets * exactly those, and a save that names none is from before the dialog and gets the rules that were * in force then — never the current defaults. */ function configFor(save: Save, config: GameConfig): GameConfig { return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES }; } /** * TAKE THE LAST ACTION BACK. * * The save IS the game — a seed and the intents submitted — so undo is "replay everything except the * last one". That is why this is a few lines rather than a feature: there is no undo stack to keep, * no inverse of each action to write, and no way for it to produce a position the rules could not * have reached, because the position is reached by the rules. * * WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the * whole log is rebuilt with it — so the history panel matches the board afterwards rather than still * describing the move that was taken back. * * WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card * again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and * then spend the turn differently, which is an ordinary solitaire take-back and is the reason this * is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a * commit point. * * Returns null when there is nothing to undo, so the caller can leave the button disabled. */ export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null { if (game.history.length === 0) return null; // `toSave` first, so the rules this game was dealt under come with it. Rebuilding the save by hand // here dropped them, and undo re-dealt the game under the defaults instead of its own settings. return fromSave({ ...toSave(game), history: game.history.slice(0, -1) }, config); } /** * Rebuild a game from a save. * * Replays the intents through the real engine rather than restoring a serialised state, so a save * can never describe a position the rules could not have produced. An intent that no longer applies * stops the replay rather than being forced — better a short game than a corrupt one. */ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game { const game = newGame(save.seed, configFor(save, config)); for (const intent of save.history) { const actor = currentActor(game); if (actor === null) break; const result = applyIntent(game.state, actor, intent); if (!result.ok) break; game.history.push(intent); record(game, result.events); drain(game); } return game; }