Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
1587 lines
77 KiB
TypeScript
1587 lines
77 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 { 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, GridCoord, 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,
|
||
industryProfile,
|
||
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;
|
||
/**
|
||
* What the card still wants, after what is already coupled up — "1 boxcar/hopper + 1 caboose".
|
||
*
|
||
* The title says what the card CALLS FOR and never changes as cars go on, so a player had to
|
||
* diff it against the consist drawn on the Division map, in the other column. Null when the
|
||
* train is complete and only the send-it-out button is left.
|
||
*/
|
||
needs: string | null;
|
||
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',
|
||
needs: consistNeeds(game, filling),
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* WHAT THE TRAIN STILL WANTS — the card's demand minus what is already on it.
|
||
*
|
||
* The heading says "its card calls for 3 boxcar/hopper + 1 caboose" and goes on saying it whether
|
||
* you have added none or three; the cars themselves are drawn on the Division map, in the other
|
||
* column. So the one question a player actually has while clicking — what is left? — was the one
|
||
* thing on screen that had to be worked out by eye, across two panels (Jesse, playtest 2026-09-16).
|
||
*
|
||
* BY CATEGORY, exactly as `acceptsCar` counts them, so this cannot promise a car the engine would
|
||
* then refuse. Null when nothing is outstanding.
|
||
*/
|
||
function consistNeeds(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 cat = (t: string): 'coach' | 'caboose' | 'freight' =>
|
||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||
const have = (k: 'coach' | 'caboose' | 'freight'): number =>
|
||
tray.consist.filter((c) => cat(c.type) === k).length;
|
||
|
||
const parts: string[] = [];
|
||
const freight = p.consist.freight - have('freight');
|
||
const coach = p.consist.coach - have('coach');
|
||
const caboose = p.consist.caboose - have('caboose');
|
||
if (freight > 0) {
|
||
parts.push(`${freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`);
|
||
}
|
||
if (coach > 0) parts.push(`${coach} coach${coach > 1 ? 'es' : ''}`);
|
||
if (caboose > 0) parts.push(`${caboose} caboose`);
|
||
return parts.length > 0 ? parts.join(' + ') : null;
|
||
}
|
||
|
||
/**
|
||
* "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;
|
||
}
|
||
|
||
/**
|
||
* The Facility on one of a player's squares, or null — for naming the place a switching line is
|
||
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
|
||
*/
|
||
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
|
||
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
|
||
const f = card?.facility;
|
||
// The name printed on the card, not the internal key: `industryProfile` is the one place that
|
||
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
|
||
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
|
||
/**
|
||
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
|
||
*
|
||
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
|
||
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
|
||
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
|
||
* square DOES without being somewhere a player aims a crew at.
|
||
*/
|
||
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
|
||
}
|
||
|
||
/**
|
||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
|
||
*
|
||
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
|
||
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
|
||
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
|
||
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
|
||
* and the Small Yard sort. Not every move, and not every coupling.
|
||
*
|
||
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
|
||
* and is left alone; work at an industry is the point of switching and changes what can be loaded
|
||
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
|
||
* one's own track changes nothing anybody needs to read back.
|
||
*
|
||
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
|
||
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
|
||
* produced no narration, so a switching move with no line became a silent step and the board stopped
|
||
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
|
||
* let the table watch. The line still rides with its display step and still captions the board as
|
||
* the move goes up; only the history panel skips it.
|
||
*
|
||
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
|
||
*/
|
||
function inHistory(game: Game, e: GameEvent): boolean {
|
||
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
|
||
coords.some((c) => facilityOn(game, player, c) !== null);
|
||
switch (e.type) {
|
||
/**
|
||
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
|
||
*
|
||
* It says a crew set off and from where, which is the half of "somebody switched" that the
|
||
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
|
||
* the last until the turn is over, and by then the line has already been written and streamed to
|
||
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
|
||
* carries it instead, as the line that closes the turn.
|
||
*
|
||
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
|
||
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
|
||
* five-Move night Stage too.
|
||
*/
|
||
case 'trayMoved':
|
||
return e.movesRemaining === e.movesAllowed - 1;
|
||
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
|
||
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
|
||
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
|
||
case 'carsCoupled':
|
||
return industry(e.player, e.at, ...e.from);
|
||
case 'carsDropped':
|
||
return industry(e.player, e.at);
|
||
default:
|
||
return true;
|
||
}
|
||
}
|
||
|
||
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),
|
||
facilityAt: (player, at) => facilityOn(game, player, at),
|
||
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
||
// narration resolves the name itself rather than being prefixed with one by the code below.
|
||
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
||
// under Employee Rotation, which is why `seatOf` exists — so "Seat 3" here would be a wrong
|
||
// number dressed as a right one, and `session.test.ts` rightly refuses any raw index shown to
|
||
// a person. Every caller passes real names; an unnamed player is anonymous rather than mislabelled.
|
||
playerName: (p) => game.state.players[p]?.name ?? 'another player',
|
||
});
|
||
/**
|
||
* 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.
|
||
/**
|
||
* A RULING IS MADE AS SUPERINTENDENT, NOT AS YOURSELF (playtest, 2026-09-15: "maybe it could say
|
||
* 'Superintendent Player Tom', so it's clear they got the move because they're Superintendent").
|
||
* These three are the only moves a player makes out of turn, by holding the office: §8.1's
|
||
* clearance, §11's Yard Office offer and §Q's Red Flag prompt. `clearanceGiven` carries no
|
||
* player at all — the office made it, whoever holds it — so the actor is what names it.
|
||
*/
|
||
const RULINGS = ['clearanceGiven', 'yardOfficeRuled', 'redFlagRuled'];
|
||
const ruling = RULINGS.includes(e.type) && who !== null;
|
||
const mine = who !== null && 'player' in e;
|
||
const text = ruling
|
||
? `Superintendent Player ${who} ${uncapitalise(said)}`
|
||
: mine
|
||
? `Player ${who} ${uncapitalise(said)}`
|
||
: said;
|
||
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||
|
||
}
|
||
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.`;
|
||
}
|
||
/**
|
||
* THE FEDORA MOVING IS ANNOUNCED, NOT JUST LOGGED (playtest, 2026-09-16).
|
||
*
|
||
* It is the one thing in the game that changes hands on the clock rather than because somebody
|
||
* did something, so nobody is watching for it — and it decides who rules on clearances and who
|
||
* every round starts with. A line in the history is where you find it afterwards; this is what
|
||
* tells the table as it happens, the same treatment a completed run already gets.
|
||
*/
|
||
if (e.type === 'superintendentChanged') {
|
||
const name = game.state.players[e.player]?.name ?? 'the next player';
|
||
game.announced = `${name} is now the Superintendent — the Fedora passed at the end of Stage ${e.stage}.`;
|
||
}
|
||
}
|
||
// 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 };
|
||
}
|