Fifteen items from two playtest sessions. Three that read as drawing faults were engine bugs: cars could be added to a train that was not being made up (50 offers in 8 games), the make-up panel merged two trains and could couple a car to the wrong one, and an Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel inside a coordinate's own value range — a Mainline placement travelling as row -1, which is an ordinary district row. Trains are now drawn the way they stand: west on the left, nose toward the way the engine faces, on both the district card and the Division chip. Undo steps back through the game by replaying the save without its last intent. The switching walk keeps its rejections, so the board can say why a square is not offered. Laborers and Porters are on the card, and the rule that a district only grows outwards is finally written down. Versions start here: third digit for fixes, second for a feature set, 1.0 for a release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
715 lines
31 KiB
TypeScript
715 lines
31 KiB
TypeScript
/**
|
||
* 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<string, { index: number; label: string }[]>();
|
||
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<string>();
|
||
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<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 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<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,
|
||
}
|
||
: 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<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;
|
||
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<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. */
|
||
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;
|
||
}
|