A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first real audio beyond a placeholder. - Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order. Display-only. - "No switching" now means may not add or drop cars, not "never touch it" — these trains can still be moved onto Secondary Track to clear the mainline. - Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never use" as a side effect. - evaluateClearance now checks every occupant on a Mainline card before offering a judgment call, instead of returning on whichever it found first — found while explaining a playtest report, fixed with a regression test. - Three new synthesised sounds: arrive, depart, crash. - The splash page shows the box art. Full detail, measurements and reasoning in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
1082 lines
49 KiB
TypeScript
1082 lines
49 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 intents ARE the game — the RNG is seeded and `applyIntent` is deterministic — so a save
|
||
* is the seed plus the list of intents submitted. Replaying them reconstructs the position exactly,
|
||
* which is far smaller and far more robust than serialising the state graph. (Not the event log:
|
||
* folding events does not rebuild a game — `protocol.md` §3.)
|
||
*/
|
||
|
||
import { pump } from '../engine/advance.ts';
|
||
import { applyIntent } from '../engine/apply.ts';
|
||
import type { GameEvent } from '../engine/events.ts';
|
||
import type { Intent } from '../engine/intents.ts';
|
||
import { legalActions } from '../engine/legal.ts';
|
||
import { createGame } from '../engine/setup.ts';
|
||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||
import { playerAtSeat } from '../engine/state.ts';
|
||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||
// which would pull node:fs into a browser bundle.
|
||
import {
|
||
cardDescription,
|
||
cardName,
|
||
describeIntent,
|
||
geometryLabel,
|
||
snapshot,
|
||
trainName,
|
||
variantLabel,
|
||
} from '../sim/view.ts';
|
||
import {
|
||
DEFAULT_HOUSE_RULES,
|
||
HAND_LIMIT,
|
||
LEGACY_HOUSE_RULES,
|
||
houseRules,
|
||
mainlineProfile,
|
||
trainProfile,
|
||
} from '../engine/content.ts';
|
||
import type { Hand, HouseRuleOverrides, TrackGeometry } from '../engine/content.ts';
|
||
import type { Port } from '../engine/track.ts';
|
||
import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.ts';
|
||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||
import type { Frame } from '../sim/view.ts';
|
||
|
||
export const SOLO_CONFIG: GameConfig = {
|
||
mode: 'solitaire',
|
||
victory: 'highestAfterDays',
|
||
length: 'standard',
|
||
optionalRules: {
|
||
reducedVisibility: false,
|
||
sisterTrains: false,
|
||
employeeRotation: false,
|
||
emergencyToolbox: false,
|
||
},
|
||
// Spelt out rather than left to fall through, so a save written by the page always names the rules
|
||
// it was played under — see `Save.rules`.
|
||
houseRules: DEFAULT_HOUSE_RULES,
|
||
};
|
||
|
||
/** The same config with the New Game dialog's answers in it. */
|
||
export function configWith(rules: HouseRuleOverrides): GameConfig {
|
||
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
|
||
}
|
||
|
||
/** A group of legal actions of one kind, ready to put on screen. */
|
||
export type ActionGroup = {
|
||
kind: string;
|
||
title: string;
|
||
/**
|
||
* `tip` is the card's own description, resolved HERE rather than by the page.
|
||
*
|
||
* The button label is short; the hover text used to be looked up with `cardDescription(state, id)`
|
||
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
|
||
* carries it. See `docs/architecture/multiplayer.md` §5.
|
||
*/
|
||
/**
|
||
* `coord` is the square the action HAPPENS ON, when it happens on one.
|
||
*
|
||
* Carried as data rather than left inside the label. Half these buttons are near-identical
|
||
* sentences distinguished only by a coordinate — "(1,3)" against "(-1,3)" — and picking the wrong
|
||
* one is recoverable in solitaire, where Undo is a click, and a disaster in a multiplayer game
|
||
* where it is not. The page hovers the matching square on the board instead of asking the player
|
||
* to read the row and column off the button. Reported by Jesse.
|
||
*
|
||
* Resolved HERE for the same reason `tip` is: a remote client holds no `GameState` and cannot look
|
||
* up where a tray is standing. See `docs/architecture/multiplayer.md` §5.
|
||
*/
|
||
actions: {
|
||
index: number;
|
||
label: string;
|
||
tip?: string;
|
||
coord?: { row: number; col: number };
|
||
/**
|
||
* Every square a `switch.move` runs OVER on its way to `coord`, when there is more than one
|
||
* legal route there (docs/plans/switching-paths.md) — so hovering a route lights the whole
|
||
* road, not just its destination, which is the only way to tell two buttons reading "move to
|
||
* (0,0)" apart before clicking one.
|
||
*/
|
||
route?: { row: number; col: number }[];
|
||
}[];
|
||
};
|
||
|
||
/**
|
||
* The square an intent acts on, or null when it acts on none.
|
||
*
|
||
* Only squares the intent NAMES. A card played out on the Mainline (`node`) is not a district
|
||
* coordinate and must not be treated as one — that confusion is exactly why `placement` and `node`
|
||
* are separate fields on the intent (see `intents.ts`) — and an action like ending a turn has no
|
||
* square at all.
|
||
*/
|
||
export function coordOf(i: Intent): { row: number; col: number } | null {
|
||
if ('at' in i) return i.at;
|
||
if ('to' in i) return i.to;
|
||
if (i.type === 'card.play' && i.placement) return i.placement;
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Every intermediate square a `switch.move` runs over, resolved the same way `execute` resolves
|
||
* `via` — so the squares that light up on hover are exactly the squares the move will actually
|
||
* couple cars from. `undefined` for the overwhelming majority of moves, which run in a straight
|
||
* line and need nothing beyond the destination `coordOf` already carries.
|
||
*/
|
||
function routeFor(state: GameState, i: Intent): { row: number; col: number }[] | undefined {
|
||
if (i.type !== 'switch.move') return undefined;
|
||
const tray = state.trays.get(i.trayId);
|
||
if (!tray || tray.position.at !== 'grid') return undefined;
|
||
const actor = playerAtSeat(state, tray.position.seat);
|
||
const dests = destinationsFor(state, actor, i.trayId, tray.position.coord, i.reverse);
|
||
const dest = selectDestination(dests, i.to, i.via);
|
||
if (!dest || dest.path.length === 0) return undefined;
|
||
return dest.path.map((step) => step.coord);
|
||
}
|
||
|
||
export type Game = {
|
||
state: GameState;
|
||
seed: number;
|
||
/** Every intent submitted, in order — the save file. */
|
||
history: Intent[];
|
||
/** Narrated lines, newest last. */
|
||
log: { text: string; tone: string }[];
|
||
/**
|
||
* True when the hand is OVER the limit, so the turn cannot end until it is played down.
|
||
*
|
||
* §6.2 is a hand limit — "reduce his hand to no more than three cards" — not a rule that a draw
|
||
* must be spent. An earlier version set this on any draw, which forced a play even when two cards
|
||
* had already been played and the hand held three or fewer. Derived from the hand each render, so
|
||
* it cannot drift out of step with what is actually held.
|
||
*/
|
||
mustPlayCard: boolean;
|
||
/**
|
||
* Sounds the last batch of events earned, for the page to play and clear.
|
||
*
|
||
* The model names WHAT happened — a Stage ended, a Day turned, a train was built — and the view
|
||
* decides what that sounds like. Detection lives here because this is where events are seen; it
|
||
* would be guesswork from a rendered frame.
|
||
*/
|
||
cues: string[];
|
||
/**
|
||
* The timetable slot the last batch of events filled, or null.
|
||
*
|
||
* Playing a train card rolls 1D12 for a Stage and the answer landed nowhere the player could see.
|
||
* Carrying the slot lets the timetable flash the one that just changed — the roll becomes
|
||
* something you watch land rather than something you are told about afterwards.
|
||
*/
|
||
scheduled: number | null;
|
||
/**
|
||
* The card most recently drawn into hand, or null.
|
||
*
|
||
* A drawn card arrives among two others that look exactly like it, and nothing said which was new.
|
||
* Unlike `scheduled`, this is NOT cleared on the next render: it marks WHICH CARD IS NEW rather
|
||
* than that a draw just happened, so it stands until another draw replaces it. Nothing needs to
|
||
* clear it when the card is played — no element carries the class once the card leaves the hand.
|
||
*/
|
||
justDrawn: CardId | null;
|
||
/**
|
||
* A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`,
|
||
* because it marks a MOMENT — the only event in the game that scores for everybody without anyone
|
||
* having taken a turn to cause it.
|
||
*/
|
||
announced: string | null;
|
||
};
|
||
|
||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||
{ prefix: 'switch.', title: 'Switching' },
|
||
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||
// three are different acts. Taking a face-up Department card is not the same decision as
|
||
// gambling on the Home Office deck, and neither is ending the turn.
|
||
{ prefix: 'draw.fromHomeOffice', title: 'Draw a card from the Home Office deck' },
|
||
{ prefix: 'draw.fromDepartment', title: 'Take a Department card (face up)' },
|
||
{ prefix: 'card.play', title: 'Play a card from my hand' },
|
||
{ prefix: 'card.discard', title: 'Discard a card from my hand' },
|
||
{ prefix: 'draw.end', title: 'Finish' },
|
||
{ prefix: 'mainline.modify', title: 'Mainline modifiers' },
|
||
// American spelling throughout, to match MANEUVER_CARDS and the source deck.
|
||
{ prefix: 'maneuver.', title: 'Maneuvers' },
|
||
{ prefix: 'freightAgent.', title: 'Freight Agent' },
|
||
{ prefix: 'newTrain.', title: 'Making up the train' },
|
||
{ prefix: 'porter.', title: 'Porters' },
|
||
{ prefix: 'laborer.', title: 'Laborers' },
|
||
{ prefix: 'loadUnload.', title: 'Finish' },
|
||
{ prefix: 'redFlag.', title: 'Red flag' },
|
||
];
|
||
|
||
/**
|
||
* The solitaire seat still has a NAME, because the history reads "Player Solitaire chose…" and a
|
||
* log that says "You" cannot be read back by anyone else — a save is meant to be sent around.
|
||
*/
|
||
export const SOLO_PLAYER = 'Solitaire';
|
||
|
||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||
// first, then let the clock take over.
|
||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||
game.log.push({ text: `Solitaire · one player · seed ${seed}`, tone: 'quiet' });
|
||
drain(game);
|
||
return game;
|
||
}
|
||
|
||
/**
|
||
* Run the engine forward until it needs a decision.
|
||
*
|
||
* Most of a Stage is automatic — the Mainline Phase moves trains, the clock turns over — so the
|
||
* player is only ever asked when `advance` genuinely stops.
|
||
*/
|
||
export function drain(game: Game): void {
|
||
record(game, pump(game.state));
|
||
}
|
||
|
||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||
export function currentActor(game: Game): PlayerIndex | null {
|
||
if (game.state.status !== 'active') return null;
|
||
return game.state.clock.pendingDecision !== null
|
||
? game.state.clock.superintendent
|
||
: game.state.clock.currentActor;
|
||
}
|
||
|
||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||
export function actionGroups(game: Game): { options: Intent[]; groups: ActionGroup[] } {
|
||
const actor = currentActor(game);
|
||
if (actor === null) return { options: [], groups: [] };
|
||
|
||
const options = legalActions(game.state, actor);
|
||
/**
|
||
* Entries carry the crew they belong to, rather than it being encoded into the map key.
|
||
*
|
||
* An earlier version keyed this map by `type + separator + trayId`, which worked and was a
|
||
* standing invitation: the key is also what `GROUP_ORDER` prefix-matches on, so the separator had
|
||
* to survive every edit to a line nobody would think to check. One stray byte and every crew's
|
||
* moves silently collapsed back into a single group. The tray is data; it travels as data.
|
||
*/
|
||
type Entry = {
|
||
index: number;
|
||
label: string;
|
||
tip?: string;
|
||
trayId?: string;
|
||
coord?: { row: number; col: number };
|
||
route?: { row: number; col: number }[];
|
||
};
|
||
const byKind = new Map<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) {
|
||
headed =
|
||
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
||
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
||
*
|
||
* "Where does this Extra start?" arrived in the same `newTrain.` group as "which car goes on
|
||
* this train", under whichever heading the group happened to have — and with no tray being
|
||
* filled there is no train to name, so it read "Making up the train" over a choice about a
|
||
* different train entirely. Split out, and headed with the Extra's own card: it is about to
|
||
* run, and what it may carry is the thing the player needs before choosing where to put it.
|
||
*/
|
||
const extras = actions.filter((a) => options[a.index]?.type === 'newTrain.startExtra');
|
||
const rest = actions.filter((a) => options[a.index]?.type !== 'newTrain.startExtra');
|
||
if (extras.length > 0) {
|
||
const number = (options[extras[0]!.index] as { trainNumber: number }).trainNumber;
|
||
groups.push({
|
||
kind: prefix,
|
||
title: `${trainCardTitle(number, true) ?? title} — choose where it starts`,
|
||
actions: extras,
|
||
});
|
||
}
|
||
if (rest.length > 0) groups.push({ kind: prefix, title: headed, actions: rest });
|
||
}
|
||
}
|
||
// Anything the table above does not name still has to be offered — silently dropping a legal
|
||
// action would make the game unplayable in a way that is very hard to notice.
|
||
const leftovers = [...byKind.entries()].filter(([k]) => !used.has(k));
|
||
for (const [kind, actions] of leftovers) groups.push({ kind, title: kind, actions });
|
||
|
||
return { options, groups };
|
||
}
|
||
|
||
/**
|
||
* A thing you might do, and — if it goes on the board — where it could go.
|
||
*
|
||
* Placeable actions are presented as SUBJECT then LOCATION rather than as one flat list of every
|
||
* (card x square x rotation) combination. A single turn offered 29 track buttons and 7 card buttons
|
||
* with no way to tell which square each referred to; picking the card first and the square second is
|
||
* how the choice is actually made at the table.
|
||
*/
|
||
export type Placeable = {
|
||
/** Groups every option that plays the same card or lays the same piece. */
|
||
subjectKey: string;
|
||
subject: string;
|
||
/**
|
||
* Where it may go. `coord` drives board highlighting; several spots can share one square when the
|
||
* piece has more than one legal rotation there, which is why the label carries the rotation too.
|
||
*/
|
||
spots: {
|
||
label: string;
|
||
index: number;
|
||
/**
|
||
* The square on the board this spot would fill, or **null** when the placement is not on the
|
||
* board at all — ABS Signals goes out on the Mainline, and `node` names which card.
|
||
*
|
||
* Null rather than a stand-in coordinate. A Mainline placement used to travel as
|
||
* `{ row: -1, col: node }`, and row −1 is an ordinary district row, so the board lit up a
|
||
* district card for a placement that was never going there.
|
||
*/
|
||
coord: { row: number; col: number } | null;
|
||
/** Division node index, for a placement out on the Mainline. */
|
||
node?: number;
|
||
/**
|
||
* The rails this placement would put on the card, as `connectionsFor` codes.
|
||
*
|
||
* So the UI can DRAW the piece as it would land rather than only describing it. Words alone are
|
||
* not enough for a turnout: "allows traffic from the east to travel west or turn to the south"
|
||
* is exact and still leaves a player working out which way the leg points on the board. Taken
|
||
* from the engine's own connections, so a preview cannot promise a shape the placement will not
|
||
* produce.
|
||
*/
|
||
links: string[];
|
||
}[];
|
||
};
|
||
|
||
/**
|
||
* A CARD IN HAND, AND WHAT MAY BE DONE WITH IT.
|
||
*
|
||
* The hand is the action surface. A card used to appear in two panels under two different models:
|
||
* as a *subject* under "Play a card from my hand", which then highlighted squares on the board, and
|
||
* as one flat button per Department under "Discard a card from my hand". One card, two mental
|
||
* models, and the discard block was a cross-product — four cards x three Departments was twelve
|
||
* buttons and about 290px of the action list, repeating the same three choices four times.
|
||
*
|
||
* Both verbs now hang off the card itself, and both use the pattern the board placement already
|
||
* had: pick the thing, then pick where it goes. A discard's "where" is a Department pile, which is
|
||
* already on screen with its top card and depth — exactly what you need to choose between them.
|
||
*/
|
||
export type HandAction = {
|
||
cardId: string;
|
||
name: string;
|
||
what: string;
|
||
/** Playable with NO placement — an Office upgrade, a train card, a maneuver. One click does it. */
|
||
playNow: number | null;
|
||
/** Playable onto the board: the `subjectKey` of the matching `placeable` item. */
|
||
placeKey: string | null;
|
||
/** How many squares it may go on, for the count on the button. */
|
||
spots: number;
|
||
/** The option index for discarding onto each Department pile, or null where that is illegal. */
|
||
discard: (number | null)[];
|
||
/**
|
||
* Every distinct shape the card could be laid as, for the hover preview.
|
||
*
|
||
* Derived from the CARD, not from its legal placements: what a piece looks like does not depend
|
||
* on whether there is currently a square for it, and a player picking through a hand needs to see
|
||
* the shape most when there is nowhere obvious to put it. Reading it off the placements meant a
|
||
* curve with no legal square showed no preview at all.
|
||
*/
|
||
shapes: string[][];
|
||
};
|
||
|
||
/** A car that may be added to the train being made up, keyed to its chip in the Division Yard. */
|
||
export type MakeUpAction = { carType: string; loaded: boolean; index: number };
|
||
|
||
export type Menu = {
|
||
options: Intent[];
|
||
/** Actions with no further choice to make. */
|
||
direct: ActionGroup[];
|
||
/** Actions needing a location, grouped under their card or track piece. */
|
||
placeable: { title: string; items: Placeable[] }[];
|
||
/** One entry per card in hand, with the verbs available to it. */
|
||
hand: HandAction[];
|
||
/**
|
||
* Making up a train: the cars that may be added, and the option to add none.
|
||
*
|
||
* Ten buttons reading "add loaded hopper", "add empty boxcar" and so on, when the Division Yard
|
||
* is already on screen showing exactly those cars by type and load state. The yard is the surface;
|
||
* these key each chip to the option that adds it.
|
||
*/
|
||
makeUp: {
|
||
trayId: string;
|
||
title: string;
|
||
cars: MakeUpAction[];
|
||
pass: number | null;
|
||
/**
|
||
* The order to add the cars in, when the order decides whether the train can work at all.
|
||
* Absent for every train where it does not matter, which is nearly all of them.
|
||
*/
|
||
advice: { text: string; tone: 'hint' | 'warn' } | null;
|
||
} | null;
|
||
};
|
||
|
||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||
const { options, groups } = actionGroups(game);
|
||
const direct: ActionGroup[] = [];
|
||
const placeableByTitle = new Map<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 {
|
||
const hand = game.state.decks.hands.get(seat) ?? [];
|
||
const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
||
return hand.length > limit;
|
||
}
|
||
|
||
/** Submit an action. Returns false and changes nothing if the engine rejects it. */
|
||
export function submit(game: Game, intent: Intent): boolean {
|
||
const actor = currentActor(game);
|
||
if (actor === null) return false;
|
||
|
||
const result = applyIntent(game.state, actor, intent);
|
||
if (!result.ok) {
|
||
game.log.push({ text: `That is not allowed: ${result.code}`, tone: 'bad' });
|
||
return false;
|
||
}
|
||
game.history.push(intent);
|
||
record(game, result.events, actor);
|
||
game.mustPlayCard = overHandLimit(game);
|
||
drain(game);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Which cards in hand can be played RIGHT NOW, in hand order.
|
||
*
|
||
* A hand where some cards are simply unplayable — a Station upgrade before the Office is a Depot, a
|
||
* Modifier with no Facility to sit beside — looks identical to one where everything is available.
|
||
* Derived from `legalActions`, so it cannot disagree with what the buttons offer.
|
||
*/
|
||
export function handPlayable(game: Game, seat: PlayerIndex = 0): boolean[] {
|
||
const hand = game.state.decks.hands.get(seat) ?? [];
|
||
const actor = currentActor(game);
|
||
if (actor === null) return hand.map(() => false);
|
||
const playable = new Set(
|
||
legalActions(game.state, actor)
|
||
.filter((i) => i.type === 'card.play')
|
||
.map((i) => (i as Extract<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);
|
||
}
|
||
|
||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||
for (const e of events) {
|
||
// The same filter the replay uses: actor changes and phase bookkeeping are noise on screen.
|
||
if (e.type === 'actorChanged') continue;
|
||
const n = narrate(e, {
|
||
cardName: (id) => cardName(game.state, id),
|
||
trainName: (id) => trainName(game.state, id),
|
||
});
|
||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||
const mine = who !== null && 'player' in e;
|
||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||
|
||
}
|
||
game.cues.push(...cuesFor(events));
|
||
// Which timetable slot the die just filled, so the panel can flash it. Last one wins: a batch can
|
||
// schedule more than one train, and the most recent is the one the eye should be sent to.
|
||
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
|
||
// Which card just came into hand, so the row can badge it. Last one wins for the same reason.
|
||
for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId;
|
||
// A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to
|
||
// make it happen — so it is announced rather than left to be found in the log.
|
||
for (const e of events) {
|
||
if (e.type === 'trainCompleted') {
|
||
game.announced =
|
||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||
}
|
||
}
|
||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Saving — seed plus intents, replayed
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* A save is a seed, the house rules it was dealt under, and the intents submitted.
|
||
*
|
||
* `rules` is optional because saves written before the New Game dialog existed do not have it, and
|
||
* those replay under `LEGACY_HOUSE_RULES` — see `configFor`. Everything written from now on carries
|
||
* its rules, so a save can never again be silently re-dealt by a change of default.
|
||
*/
|
||
export type Save = { seed: number; history: Intent[]; rules?: HouseRuleOverrides };
|
||
|
||
export function toSave(game: Game): Save {
|
||
const rules = game.state.config.houseRules;
|
||
return rules ? { seed: game.seed, history: game.history, rules } : { seed: game.seed, history: game.history };
|
||
}
|
||
|
||
/**
|
||
* THE CONFIG A SAVE MUST BE REPLAYED UNDER, which is not necessarily today's default.
|
||
*
|
||
* A save is a seed and a list of intents: replay it under different rules and it is a different
|
||
* game, and the symptom is not an error but a replay that quietly stops early. `TODO.md` records
|
||
* that happening twice unnoticed, once 42 intents into 360. So a save that names its rules gets
|
||
* exactly those, and a save that names none is from before the dialog and gets the rules that were
|
||
* in force then — never the current defaults.
|
||
*/
|
||
function configFor(save: Save, config: GameConfig): GameConfig {
|
||
return { ...config, houseRules: save.rules ?? LEGACY_HOUSE_RULES };
|
||
}
|
||
|
||
/**
|
||
* TAKE THE LAST ACTION BACK.
|
||
*
|
||
* The save IS the game — a seed and the intents submitted — so undo is "replay everything except the
|
||
* last one". That is why this is a few lines rather than a feature: there is no undo stack to keep,
|
||
* no inverse of each action to write, and no way for it to produce a position the rules could not
|
||
* have reached, because the position is reached by the rules.
|
||
*
|
||
* WHAT IT COSTS. Replaying is O(history), which at a few hundred intents is imperceptible, and the
|
||
* whole log is rebuilt with it — so the history panel matches the board afterwards rather than still
|
||
* describing the move that was taken back.
|
||
*
|
||
* WHAT IT ALLOWS, deliberately: the RNG advances with the replay, so playing the same train card
|
||
* again rolls the same Stage — you cannot undo your way to a better die. You CAN see the roll and
|
||
* then spend the turn differently, which is an ordinary solitaire take-back and is the reason this
|
||
* is solitaire-only. `TODO.md` carries the open question of whether a Stage boundary should become a
|
||
* commit point.
|
||
*
|
||
* Returns null when there is nothing to undo, so the caller can leave the button disabled.
|
||
*/
|
||
export function undo(game: Game, config: GameConfig = SOLO_CONFIG): Game | null {
|
||
if (game.history.length === 0) return null;
|
||
// `toSave` first, so the rules this game was dealt under come with it. Rebuilding the save by hand
|
||
// here dropped them, and undo re-dealt the game under the defaults instead of its own settings.
|
||
return fromSave({ ...toSave(game), history: game.history.slice(0, -1) }, config);
|
||
}
|
||
|
||
/**
|
||
* Rebuild a game from a save.
|
||
*
|
||
* Replays the intents through the real engine rather than restoring a serialised state, so a save
|
||
* can never describe a position the rules could not have produced. An intent that no longer applies
|
||
* stops the replay rather than being forced — better a short game than a corrupt one.
|
||
*/
|
||
export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||
const game = newGame(save.seed, configFor(save, config));
|
||
for (const intent of save.history) {
|
||
const actor = currentActor(game);
|
||
if (actor === null) break;
|
||
const result = applyIntent(game.state, actor, intent);
|
||
if (!result.ok) break;
|
||
game.history.push(intent);
|
||
record(game, result.events);
|
||
drain(game);
|
||
}
|
||
return game;
|
||
}
|