/** * 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 { 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 { cardName, describeIntent, geometryLabel, snapshot, trainName, variantLabel, } from '../sim/view.ts'; import type { TrackGeometry } from '../engine/content.ts'; import { variantsFor } from '../engine/track.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 }[]; /** * Set when a card has been drawn this turn and not yet played or discarded. * * §6.2 makes you reduce your hand before the turn ends; this is that requirement made visible, * as a disabled button rather than a rejection after the click. UI state deliberately — putting * it in the engine would change what the bot may do, and with it every balance figure measured * against the current rules. */ mustPlayCard: boolean; }; /** 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: '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' }, { 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 }; // 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) groups.push({ kind: prefix, title, 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; coord: { row: number; col: 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[] }[]; }; /** 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 }); } 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()], })); return { options, direct, placeable }; } /** 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 { const at = (c: { row: number; col: number }): string => `(${c.row}, ${c.col})`; if (i.type === 'card.play' && i.placement) { 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)}`, coord: i.placement, }; } return null; } /** * 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. */ function rotationNote(geometry: TrackGeometry | null, variant: number | undefined): string { if (geometry === null) return variant === undefined || variant === 0 ? '' : ` — option ${variant + 1}`; return variantsFor(geometry).length > 1 ? variantLabel(geometry, variant) : ''; } /** 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); if (intent.type === 'draw.fromHomeOffice' || intent.type === 'draw.fromDepartment') { game.mustPlayCard = true; } else if (intent.type === 'card.play' || intent.type === 'card.discard' || intent.type === 'localOps.choose') { // Choosing an option starts a fresh turn — and Freight Agent and Switching never draw at all, // so nothing can be left outstanding from them. game.mustPlayCard = false; } record(game, result.events, actor); 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 }); } // 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 }; } /** * 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; }