/** * 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 event log is the game (`state = fold(events)`), and the RNG is seeded, 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. */ 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 { GameConfig, GameState, PlayerIndex } 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 { HAND_LIMIT, mainlineProfile, trainProfile } from '../engine/content.ts'; import type { Hand, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts'; import { areaOf, 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, }, }; /** A group of legal actions of one kind, ready to put on screen. */ export type ActionGroup = { kind: string; title: string; actions: { index: number; label: string }[]; }; 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; }; /** 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 }; // 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); const byKind = new Map(); options.forEach((intent, index) => { const label = describeIntent(game.state, intent); const list = byKind.get(intent.type) ?? []; // Orientation variants and duplicate copies describe identically; showing one is enough, and a // list of forty identical rows hides the real choice rather than presenting it. if (!list.some((a) => a.label === label)) list.push({ index, label }); byKind.set(intent.type, list); }); 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)); const actions = kinds.flatMap((k) => { used.add(k); return 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; const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title; groups.push({ kind: prefix, title: headed, actions }); } } // 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 } | null; }; /** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */ export function actionMenu(game: Game): 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 keep their order in hand rather than being regrouped: the hand is a row * of objects the player is looking at, not a list to sort. */ const handIds = game.state.decks.hands.get(0) ?? []; 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, } : 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 { const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`; /** * 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 ''; 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 = { 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; const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra); 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}`; } /** * §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): boolean { const hand = game.state.decks.hands.get(0) ?? []; const limit = game.state.decks.redFlags.get(0) ? 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): boolean[] { const hand = game.state.decks.hands.get(0) ?? []; 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. */ export function view(game: Game): Frame { return snapshot(game.state, [], null); } 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; // 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 // --------------------------------------------------------------------------- export type Save = { seed: number; history: Intent[] }; export function toSave(game: Game): Save { return { seed: game.seed, history: game.history }; } /** * 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; return fromSave({ seed: game.seed, 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, 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; }