Files
station-master/src/web/game.ts
T
Jesse.MarkowitzandClaude Opus 5 02289e94b8 v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen.
Every accepted intent, and every automatic phase that does anything, becomes an
ordered presentation step. A bot's whole switching turn used to land in one push;
now it arrives as a run of steps, the district panel follows whoever is acting,
and a [N behind] … [Skip] row says how far the board is from the game.

Solitaire runs the same path — one collector inside submit(), which both session
kinds already funnel through — which is where its automatic phases finally get a
visible beat.

Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs
nothing, and the clock turning over earns the beat. Tunable per viewer without a
rebuild, and off entirely at pace 0.

Also: switching was the one class of action logging unattributed, and now names
its train. Reasoning, measurements and the three things that turned out wrong are
in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 15:31:46 -04:00

1433 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { advance, 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 { overHandLimit as overHandLimitOf } from '../engine/state.ts';
import { playerAtSeat } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
import { collectStep, newCollector } from '../sim/display-step.ts';
import type { DisplayCollector } from '../sim/display-step.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,
currentActorOfState,
describeIntent,
geometryLabel,
snapshot,
trainName,
variantLabel,
} from '../sim/view.ts';
import {
DEFAULT_DAYS,
DEFAULT_HOUSE_RULES,
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
LEGACY_HOUSE_RULES,
collectiveRevenueFloor,
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';
/**
* The canonical "what does a fresh solitaire game look like" config — also what the New Game
* dialog's solitaire defaults are drawn from (`main.ts`). One player, so `minCombinedRevenue` uses
* `collectiveRevenueFloor(1, DEFAULT_DAYS)` — the same formula multiplayer configs use, just at
* player count 1.
*/
export const SOLO_CONFIG: GameConfig = {
mode: 'solitaire',
days: DEFAULT_DAYS,
minCombinedRevenue: collectiveRevenueFloor(1, DEFAULT_DAYS),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: 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 lobby's (`lobby.ts`) starting point for a Competitive or Co-op `Lobby.Create` — same formula
* `SOLO_CONFIG` uses, at a nominal player count. `minCombinedRevenue` is necessarily a guess at
* create time: nobody is seated yet, so there is no real headcount to size it against. It stays a
* guess rather than being fixed up at `Lobby.Start`, matching what the New Game dialog already told
* players before there was a lobby at all ("assumes a 4-player table until there's a lobby to ask
* who's actually seated") — Phase 5 or a follow-up can revisit sizing it to the seats actually
* filled once that is worth the complexity.
*/
export function defaultMultiplayerConfig(mode: 'competitive' | 'coop', players = 4): GameConfig {
return {
mode,
days: DEFAULT_DAYS,
minCombinedRevenue: collectiveRevenueFloor(players, DEFAULT_DAYS),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: mode === 'competitive',
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
houseRules: DEFAULT_HOUSE_RULES,
};
}
/**
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
* Solitaire's `pvpCardsAllowed` is not here: it is forced off (`SOLO_CONFIG`), never a player choice.
*/
export type NewGameOptions = {
houseRules?: HouseRuleOverrides;
days?: number;
minCombinedRevenue?: number;
maxCollisionsPerDay?: number;
maxCollisionsTotal?: number;
/**
* Appendix B's three, added 2026-08-23 when the New Game dialog gained them.
*
* The lobby has offered these since v0.6.0 and the solitaire dialog could not, which meant a
* solitaire game could never be played under Employee Rotation or the Emergency Toolbox at all.
* Partial like `houseRules`: a caller names only what it is setting.
*/
optionalRules?: Partial<GameConfig['optionalRules']>;
};
/** The same config with the New Game dialog's answers in it. */
export function configWith(opts: NewGameOptions): GameConfig {
const days = opts.days ?? SOLO_CONFIG.days;
return {
...SOLO_CONFIG,
days,
/**
* DERIVED FROM THE DAYS ACTUALLY IN PLAY, not from `SOLO_CONFIG`'s five-Day constant.
*
* It fell back to the constant until 2026-08-30, so `configWith({ days: 1 })` asked a one-Day
* game to clear **15** — a floor a five-Day game averages barely half of — and
* `configWith({ days: 10 })` asked for the same 15 a five-Day game does. The two fields silently
* disagreed, which is the one thing a "build me a config" helper must not let happen.
*
* Not a live fault when it was found: `createLocalSession` is the only caller, and the page
* always writes `minCombinedRevenue` itself (`solitaireDefaults` re-derives it from the preset).
* Found by a throwaway probe that passed only `days` — which is exactly how the next caller
* would use this. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own
* floor is this same formula at `DEFAULT_DAYS`.
*/
minCombinedRevenue: opts.minCombinedRevenue ?? collectiveRevenueFloor(1, days),
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
houseRules: houseRules(opts.houseRules ? { houseRules: opts.houseRules } : {}),
};
}
/** 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.
*/
/**
* 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;
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
* what everyone else did rather than find the board already rearranged.
*
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
* see `sim/display-step.ts`.
*/
display: DisplayCollector;
};
/** 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: 'mainline.yardOffice', title: 'Where does this train arrive?' },
{ 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: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
// 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;
}
/**
* `newGame`'s multi-player sibling — the server session host (Phase 2) needs a `Game` wrapper for
* more than one seat, and `newGame` hardcodes `[SOLO_PLAYER]`. Kept as a separate function rather
* than a shared parametrized helper: `newGame` is exercised by every solitaire test and save, and
* reordering its log-then-drain sequence to share code with this risks nothing for a marginal DRY
* gain. `submit`/`drain`/`actionMenu`/`currentActor` all work on either unchanged, since none of them
* know or care how many players a `Game` has.
*/
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
game.log.push({ text: 'Game Begins', tone: 'start' });
/**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
*
* `game.log` is one shared list and `linesSince(seat)` (`server/session.ts`) slices it with no
* per-seat filter, so every line here reaches every player. Announcing the seed therefore handed
* each of them the whole future of the deal — every card order, every die — in the opening line
* of the game. Found while planning the public common board; it is a multiplayer leak with or
* without that display, which is why it is fixed here rather than waiting for it.
*
* `newGame` still records it, deliberately: a solitaire table has nobody to leak to, and the seed
* in the log is what a bug report quotes. The rule is "do not tell the OTHER seats", not "write
* less down". The seed remains in `game.seed`, in every save (`session.ts` persistence) and in the
* lobby record, so nothing administrative or replayable loses it.
*/
game.log.push({ text: `${config.mode} · ${playerNames.length} players`, 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.
*
* `currentActorOfState` (sim/view.ts) IS this, taking the state rather than the `Game` — so this is
* the adapter and not a second copy. It used to be the second copy: it carried the status guard and
* the view's version did not, which is #96 — the turn chart named the last seat to move all the way
* through the §3.3 vote, while this function correctly refused every intent that seat could send.
* Two functions that agree until they don't are worse than one, because the disagreement surfaces
* as a screen nobody can square with the server.
*/
export function currentActor(game: Game): PlayerIndex | null {
return currentActorOfState(game.state);
}
/** 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<string, Entry[]>();
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<string>();
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<string, Entry[]>();
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?.kind === 'clearance') {
headed =
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
}
}
/**
* §11 (Gitea#5) — the Yard Office offer interrupts the Mainline Phase, so it arrives with no
* context around it: the player was not thinking about this train a moment ago.
*/
if (prefix === 'mainline.yardOffice') {
const pending = game.state.clock.pendingDecision;
if (pending?.kind === 'yardOffice') {
headed =
`${trainName(game.state, pending.train)} is arriving with no coaches — ` +
'take it into the Yard Office, or hold it at the Train Order Office?';
}
}
/**
* 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 {
/**
* SEAT-SAFETY. `actionGroups`/`legalActions` answer for `currentActor(game)`, not for `seat` — there
* is exactly one acting player at a time, so a Menu built for anyone else must show no actions at
* all, only their own hand (found the hard way: calling this for a non-acting seat used to hand
* back the ACTOR's legal moves paired with the WRONG seat's cards). Emptying `options`/`groups` here
* degrades every downstream computation (`direct`, `placeable`, `makeUp`) to empty for free — the
* hand section below still reads `seat`'s own cards correctly either way.
*/
const isActor = seat === currentActor(game);
const { options, groups } = isActor ? actionGroups(game) : { options: [] as Intent[], groups: [] as ActionGroup[] };
const direct: ActionGroup[] = [];
const placeableByTitle = new Map<string, Map<string, Placeable>>();
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<string, Placeable>();
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<Intent, { type: 'newTrain.placeCar' | 'newTrain.passCar' }> =>
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<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 ')}`;
}
/**
* "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 {
return overHandLimitOf(game.state, seat);
}
/**
* Intents any seat may send regardless of whose turn it is — and, for `game.extend`, regardless of
* whether the game is still running at all (§3.3, Gitea#11).
*
* `currentActor` is null once the timetable has run out, which is correct for everything else and
* exactly wrong for the vote on playing another Day. Rather than teach `currentActor` about a state
* where EVERY seat may act at once — which it has no way to express — callers name the seat.
*/
export function isOutOfTurn(intent: Intent): intent is Extract<Intent, { type: 'game.extend' }> {
return intent.type === 'game.extend';
}
/**
* WHO ACTED, when replaying a saved history.
*
* A save is a flat `Intent[]` with no seat written beside each move, so a replay normally derives
* the actor from the turn order — the same order the live game went round in, reproduced exactly.
* That breaks for exactly one intent: the extension vote, which every seat may cast in any order,
* and which `currentActor` answers `null` for because the game has stopped. Replaying such a save
* used to fail outright with `NO_ACTOR`, which is to say an extended game could not be resumed at
* all — found by `test/server/session.test.ts`'s resume test, and the reason `game.extend` carries
* its voter (`intents.ts`).
*/
function replayActor(game: Game, intent: Intent): PlayerIndex | null {
return isOutOfTurn(intent) ? intent.player : currentActor(game);
}
/**
* Submit an action. Returns false and changes nothing if the engine rejects it.
*
* `as` names the seat for an out-of-turn intent (see `isOutOfTurn`). It cannot be used to smuggle an
* ordinary move past the turn order: `check` is still the authority and still asks `isActor`, so a
* named seat that is not the actor is refused exactly as it would have been.
*/
export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null): boolean {
const actor = as ?? 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);
/**
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
*
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
* that append, and the slice after them is exactly this intent's narration including whatever
* automatic phases it drained.
*/
const saidFrom = game.log.length;
record(game, result.events, actor);
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
drainStepping(game);
return true;
}
/**
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
*
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
*
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
*
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
* no lines, so it is skipped rather than given a dwell to sit through.
*
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
* re-emitting a whole game as steps.
*/
function drainStepping(game: Game): void {
for (let i = 0; i < 10_000; i++) {
const from = game.log.length;
const r = advance(game.state);
record(game, r.events);
/**
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
*
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
* therefore skipped those, and the last step's frame was then a position behind the real one:
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
*
* A step whose narration is empty still carries the board. It simply costs no time to show —
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
* empty phase, arrived at from the other direction.
*/
if (r.events.length > 0) {
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
}
if (r.needsInput || game.state.status === 'finished') return;
}
throw new Error('phase driver failed to settle — probable infinite loop');
}
/**
* 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<Intent, { type: 'card.play' }>).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);
}
/**
* Fold a narration's opening word into the middle of a sentence — "Chose to draw" after a name has
* to read "Player Bob chose to draw".
*
* ONLY A SENTENCE-CASED WORD, which is the whole point. It used to be a flat
* `text.charAt(0).toLowerCase()`, so every line opening with an all-caps keyword came out mangled:
* `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18 started…`, and the same happened to
* `TRAIN 1 MADE UP` and `COLLISION`. Those words are shouted deliberately.
*
* `^[A-Z][a-z]` is the test — a capital followed by a lower-case letter is an ordinary word that was
* capitalised because it began a sentence, and nothing else is. It leaves all-caps keywords alone,
* and it also leaves alone a word whose second character is a digit or a hyphen (`X22 Pee-Dee`),
* which a naive "is it uppercase?" check would get wrong because `'2'.toUpperCase() === '2'`.
*/
function uncapitalise(text: string): string {
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
}
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),
});
/**
* A BLIND DRAW IS PUBLIC; WHICH CARD CAME UP IS NOT (Gitea#20 step 1).
*
* Everybody at the table sees a hand go to the Home Office deck, so the draw itself belongs in
* the shared log. The card's NAME does not: the deck is face down, and this log goes to every
* seat unfiltered, so naming it told three opponents exactly what the fourth was holding.
*
* A DEPARTMENT SLOT IS NOT THE SAME and stays named. Those piles are face up — a discard goes
* onto one precisely so a rival can take it — so the card was public before it was drawn, and
* hiding it would lose real information for no gain.
*
* The drawing seat still learns what it got. `justDrawn` below is the owner-only channel and
* `session.ts` sends it to that seat alone, so this costs the drawer nothing. Solitaire keeps
* the name for the same reason it keeps the seed: a one-seat table has nobody to leak to, and
* a solo player's history naming their own draw is the record rather than a leak.
*/
const blindDraw = e.type === 'cardDrawn' && e.source === 'homeOffice' && game.state.players.length > 1;
const said = blindDraw ? 'Drew a card from the Home Office deck' : n.text;
// "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} ${uncapitalise(said)}` : said;
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 = game.state.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.
//
// THE VICTORY DIALS COME FROM THE GAME ITSELF for the same reason (2026-08-23). `Save` carries only
// the house rules, so defaulting this parameter to `SOLO_CONFIG` meant undoing a game dealt over
// eight Days replayed it as a five-Day game — the length, the Revenue floor and both collision
// caps all quietly reverting to the defaults.
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 = replayActor(game, intent);
if (actor === null) break;
const result = applyIntent(game.state, actor, intent);
if (!result.ok) break;
game.history.push(intent);
/**
* `actor` IS PASSED HERE, so a replayed game narrates exactly as the live one did.
*
* It was omitted, and the omission was invisible in solitaire for a reason worth keeping: the
* only test that compares logs ("leaves nothing in the log describing a move that was taken
* back") compares one `fromSave`-built log against ANOTHER, so the missing attribution cancelled
* out on both sides. Live play attributes (`submit` passes `actor`) and so does multiplayer's
* replay (`fromMultiplayerSave`) — this was the one path of the three that did not, which meant
* a restored save, an undone game (undo rebuilds through here) and the replay viewer all
* described the same moves in different words from the game that produced them.
*/
record(game, result.events, actor);
drain(game);
}
return game;
}
/**
* `fromSave`'s multi-player sibling — Phase 3 (`docs/architecture/multiplayer.md`): "a mid-game
* server restart is a replay rather than a recovery" (`lobby-and-sessions.md` §6). Built on
* `newMultiplayerGame` instead of `newGame` since a saved multiplayer game did not deal to
* `[SOLO_PLAYER]`. The engine-version check belongs to the caller (`src/server/persistence.ts`) —
* this function only ever reconstructs from history that is already known to have been recorded
* under the currently-running rules.
*
* LIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
* resumed game hits every time.
*
* `fromSave` HAD THE SAME GAP AND NO LONGER DOES (fixed 2026-08-30). It predated multiplayer, and
* nothing ever compared its output against a LIVE-played log: `undo`'s rebuilt game is itself
* `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compared one unattributed
* replay against another and the gap cancelled out on both sides. The test that now pins it plays a
* game live, restores it from its own save, and asserts the two logs are identical — which is the
* comparison that had been missing rather than a new requirement.
*/
/**
* Why the intent a replay stopped at is reported rather than swallowed.
*
* The loop below has always stopped at the first intent the engine will not accept, and used to do
* it in silence — which is the one outcome nobody can afford to guess at, because the result is a
* game that looks fine and is short of where it should be. That silence was survivable only
* because `loadGame` refused any save whose engine version was not an exact match, so a replay
* that could fail was never attempted. Refusing on the version is a proxy question, though, and it
* answered "no" for four releases running that changed no rules at all — so the real question gets
* asked instead, and its answer has to be legible.
*/
export type ReplayStop = { index: number; intent: Intent; code: string };
export function fromMultiplayerSave(
seed: number,
config: GameConfig,
playerNames: string[],
history: Intent[],
): { game: Game; stopped: ReplayStop | null } {
const game = newMultiplayerGame(seed, config, playerNames);
for (const [index, intent] of history.entries()) {
const actor = replayActor(game, intent);
if (actor === null) {
return { game, stopped: { index, intent, code: 'NO_ACTOR' } };
}
const result = applyIntent(game.state, actor, intent);
if (!result.ok) {
return { game, stopped: { index, intent, code: result.code } };
}
game.history.push(intent);
record(game, result.events, actor);
drain(game);
}
return { game, stopped: null };
}