Files
station-master/src/web/main.ts
T
Jesse.MarkowitzandClaude Sonnet 5 b4f09f05cb v0.7.6 — the solitaire door could not reach solitaire
Found by Jesse playing v0.7.5 on phoenix.local: a browser that had ever
held a multiplayer seat could not reach the new solitaire setup screen
at all. start() checked a browser-remembered multiplayer session before
ever looking at solitaire's own state, and a bare ./play.html load could
not tell "clicked Play solitaire" apart from "reloaded mid multiplayer
game" — the same problem ?lobby already solved for the door on the
other side, never applied to this one.

The door now links to ./play.html?solitaire, and start() treats that,
an explicit ?seed=, or the setup screen's own ?hand= (written by every
Deal) as proof this navigation means solitaire — checked ahead of the
remembered-session lookup rather than only below it.

862 tests pass, three new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2026-08-29 19:59:37 -04:00

2238 lines
101 KiB
TypeScript

/**
* Browser entry point — wires the DOM to a `Session`.
*
* Presentation only. Every question of what is legal, what it means, or what the board looks like
* is answered by the engine or by the shared view helpers.
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts';
import { seatLabel } from '../sim/view.ts';
import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import {
MOVES_PER_LOCAL_OPS,
EXTRA_START_LABELS,
STARTING_HAND_LABELS,
houseRules,
} from '../engine/content.ts';
import type { ExtraStartRule, HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
import type { NewGameOptions } from './game.ts';
import type { LocalSession, Session } from './session.ts';
import { createLocalSession, createRemoteSession } from './session.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { notice, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
import {
closestPreset,
configFromFrame,
gameTypeLabel,
preset,
presetOf,
presetSettings,
settingsOf,
} from './presets.ts';
import type { GameType, PresetName } from './presets.ts';
import { settingsForm } from './settings-form.ts';
import type { SettingsForm } from './settings-form.ts';
const SAVE_KEY = 'station-master.save.v1';
const SETTINGS_KEY = 'station-master.settings.v1';
/**
* The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh
* `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from
* `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between
* browsers; this is a credential for THIS origin's server and must never be treated as one.
*/
const REMOTE_KEY = 'station-master.remote.v1';
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
/**
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
* in it, and in a multiplayer game two players may reasonably want these set differently. Grows as
* more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the
* first three.
*/
type Settings = {
districtMode: 'auto' | 'open' | 'closed';
soundOn: boolean;
zoom: number;
};
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
function loadSettings(): Settings {
try {
const raw = localStorage.getItem(SETTINGS_KEY);
const parsed = raw ? (JSON.parse(raw) as Partial<Settings>) : {};
// A missing key, a corrupt value, or a level dropped from `ZOOM_LEVELS` since it was saved all
// fall back to the default for that one field, rather than rejecting the whole object.
return {
districtMode:
parsed.districtMode === 'open' || parsed.districtMode === 'closed' ? parsed.districtMode : 'auto',
soundOn: typeof parsed.soundOn === 'boolean' ? parsed.soundOn : DEFAULT_SETTINGS.soundOn,
zoom:
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
? parsed.zoom
: DEFAULT_SETTINGS.zoom,
};
} catch {
// A full or disabled localStorage must not take the game down with it — same guard as the save.
return { ...DEFAULT_SETTINGS };
}
}
let settings = loadSettings();
function saveSettings(patch: Partial<Settings>): void {
settings = { ...settings, ...patch };
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
/* nothing to do */
}
}
/**
* The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL)
* or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every
* LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather
* than assuming, since `session` may now be either.
*/
let session: Session;
/**
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
* discriminant. `newGame` is used here since it reads clearly at every call site: "only if this
* session can deal locally."
*/
function isLocal(s: Session): s is LocalSession {
return s.capabilities.newGame;
}
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
* WHAT the picked card is being used for.
*
* The hand is the action surface, and a card has two verbs: play it onto the board, or discard it
* onto a Department. Both then ask "where?", and the answer is highlighted in place — squares on the
* board for a play, the three Department piles for a discard. Without this the two flows would need
* two selections, which is the thing being removed.
*/
let mode: 'play' | 'discard' | null = null;
/** A square picked on the board, waiting for a rotation. */
let pendingAt: string | null = null;
/**
* AUTO-FOCUS on the district.
*
* It is only worth the vertical space during the phases that change it — Local Operations, where
* cards and track are placed and the crew switches, and Cargo, where loads move. During New Train,
* Mainline and Supervisor Shift nothing in the district moves and the Division map is what matters.
*
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
* they change it again. Display only — in a multiplayer game two players may reasonably want it
* set differently, so this must never become part of game state. Persisted in `settings`, not the
* save, for exactly that reason.
*/
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
/**
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
*
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
* rather than the finished thing. One click in the title bar turns it on, and that click is also
* the gesture browsers require before any audio may start.
*/
let soundOn = settings.soundOn;
/** Preset board zoom (see `ZOOM_LEVELS`), persisted in `settings`. */
let zoom = settings.zoom;
/**
* The phase the page last drew, so a change of phase can be announced.
*
* Local Operations ends the moment the last Move is spent, and New Train and Mainline then run
* themselves — so a player looking at the board finds themselves in Cargo with no idea their turn
* ended or what happened in between. Reported exactly that way.
*/
let lastPhase: string | null = null;
/**
* The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10).
*
* Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page
* reloaded mid-game would otherwise announce a rollover that happened before it was watching.
*/
let lastDay: number | null = null;
/**
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
*
* Reported from play: "make it clear which train you are switching — it is possible to have more
* than one train available." A train standing on an A/D track while a local shunts is ordinary, and
* the board used to highlight whichever crew came first out of the map while the action list offered
* every crew's moves under one heading.
*
* Display state, deliberately not in the game: which train a player is looking at is not a fact
* about the railroad, and in a multiplayer game two players may reasonably be looking at different
* ones. Cleared whenever the named crew stops being one of the choices.
*/
let selectedCrew: string | null = null;
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
/** The crew whose squares the board is drawing: the chosen one, or the only one there is. */
function pickedCrew(f: Frame): Frame['moves'][number] | null {
if (f.moves.length === 0) return null;
return f.moves.find((m) => m.trayId === selectedCrew) ?? f.moves[0] ?? null;
}
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el as HTMLElement;
};
const esc = (s: string): string =>
s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/**
* Size a freshly-rendered board SVG off its own `viewBox`, at the current `zoom`.
*
* `#grid` and `#division` already scroll horizontally when their content is wider than the column
* (`overflow-x:auto` in `play.html`) — that mechanism is untouched. This only changes how big the
* SVG itself renders, in real pixels rather than a CSS `transform` (which would leave the container's
* scrollable area the wrong size), so zooming in genuinely grows the scrollable area and zooming out
* genuinely shrinks it.
*/
function applyZoom(container: HTMLElement): void {
const svg = container.querySelector('svg');
const box = svg?.viewBox.baseVal;
if (!svg || !box || box.width === 0) return;
svg.style.width = `${box.width * zoom}px`;
svg.style.height = `${box.height * zoom}px`;
}
/**
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
*
* "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 will point once the card is down. Rendered by `officeSvg` —
* the board's own renderer, on a one-card board — so the preview and the board cannot disagree about
* what the piece looks like. The coordinate stamp and the RUNNING TRACK caption are stripped: they
* belong to a position, and this is a card on its own.
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
'',
);
}
// ---------------------------------------------------------------------------
/**
* THE TURN CHART — the five phases of a Stage, in order, with the current one lit.
*
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
// Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a
// chip that can never change is a chip to read past.
const superName =
f.players.length > 1 ? (f.players.find((p) => p.index === f.superintendent)?.name ?? null) : null;
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName);
}
/**
* The settings this game was dealt under, beside the seed, because the seed alone does not name it.
*
* Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
* thing at 1 Revenue per transit and another at 5.
*/
function renderHouseRules(rules: HouseRules): void {
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
const el = $('houserules');
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
el.title =
`Opening hand: ${handWords.toLowerCase()}.\n` +
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
}
/**
* THE HOUSE RULES AND VICTORY DIALS TRAVEL IN THE URL, BESIDE THE SEED.
*
* A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different
* railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a
* different economy again — and now a game with `minrev=15` is a different game from one with
* `minrev=0`. The link has to carry all of it or "same link, same deal" stops being true — and the
* New Game dialog navigates by URL, so this is also how its answers reach `start()`.
*
* Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at
* today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`).
*/
const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const;
/** The four victory-condition dials added 2026-08-20 (`GameConfig`), same URL-round-trip convention. */
const VICTORY_PARAMS = {
days: 'days',
minrev: 'minCombinedRevenue',
colday: 'maxCollisionsPerDay',
coltotal: 'maxCollisionsTotal',
} as const;
/**
* Appendix B's three, in the URL like everything else the dialog asks (2026-08-23).
*
* The dialog navigates to a URL and `start()` reads the game back out of it, so a setting missing
* from here is a setting the dialog silently discards — which is exactly what happened to these
* three for as long as only the lobby offered them.
*/
const OPTIONAL_PARAMS = {
vis: 'reducedVisibility',
rot: 'employeeRotation',
tool: 'emergencyToolbox',
} as const;
function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
const rules: HouseRuleOverrides = {};
const hand = params.get('hand');
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
const extra = params.get('extra');
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
// §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`.
// Read the same way the optional rules are: present and not "0" means on.
const toss = params.get('toss');
if (toss !== null) rules.discardTimetabled = toss !== '0';
const revenue: Partial<RevenueRules> = {};
for (const [param, key] of Object.entries(RULE_PARAMS)) {
const raw = params.get(param);
// `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather
// than dealing a game at 900 Revenue a coach.
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
}
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
const options: NewGameOptions = { houseRules: rules };
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
const raw = params.get(param);
// Negative or fractional values from a hand-edited URL are clamped the same way `configWith`'s
// defaults are — a stray `minrev=-5` should mean "off-ish", not a config the engine never sees.
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) {
options[key] = Math.max(0, Math.round(Number(raw)));
}
}
const optional: Partial<NonNullable<NewGameOptions['optionalRules']>> = {};
for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) {
const raw = params.get(param);
// Present and not "0" means on: `?rot=1` and `?rot` alike, since a bare flag reads as ''.
if (raw !== null) optional[key] = raw !== '0';
}
if (Object.keys(optional).length > 0) options.optionalRules = optional;
return options;
}
/**
* THE SOLITAIRE GAME TYPE'S RULES, under whatever the URL actually named.
*
* `SOLO_CONFIG` is the ENGINE's fallback and stays where it is — every engine test and every sim run
* is measured against it, and moving it would silently re-deal all of them. What a PLAYER is dealt
* when they open the page is a different question, and its answer is the Solitaire game type
* (`presets.ts`) — which since 2026-08-23 opens with six cards, like every other type, so that the
* New Game dialog and the lobby agree about what "Solitaire" means.
*/
function solitaireDefaults(options: NewGameOptions): NewGameOptions {
const days = options.days ?? 5;
const p = presetSettings('solitaire', 1, days);
const revenue = options.houseRules?.revenue ?? {};
return {
days,
minCombinedRevenue: options.minCombinedRevenue ?? p.minCombinedRevenue,
maxCollisionsPerDay: options.maxCollisionsPerDay ?? p.maxCollisionsPerDay,
maxCollisionsTotal: options.maxCollisionsTotal ?? p.maxCollisionsTotal,
optionalRules: {
reducedVisibility: options.optionalRules?.reducedVisibility ?? p.reducedVisibility,
// One player, so there is nobody to rotate with whatever a hand-edited URL says.
employeeRotation: false,
emergencyToolbox: options.optionalRules?.emergencyToolbox ?? p.emergencyToolbox,
},
houseRules: {
startingHand: options.houseRules?.startingHand ?? p.startingHand,
extraStart: options.houseRules?.extraStart ?? p.extraStart,
discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled,
revenue: {
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
trainPerTransit: revenue.trainPerTransit ?? p.trainPerTransit,
},
},
};
}
function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string {
const params = new URLSearchParams();
if (seed !== '') params.set('seed', seed);
params.set('hand', rules.startingHand);
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
// here is a setting the dialog silently discards.
params.set('extra', rules.extraStart);
// Written only when OFF, for the reason the optional rules are written only when on: this one
// defaults to on, so `toss=1` on every link would say nothing and cost a parameter.
if (!rules.discardTimetabled) params.set('toss', '0');
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
const value = options[key];
if (value !== undefined) params.set(param, String(value));
}
for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) {
// Only the ones that are ON: a URL that spells out three `=0`s says nothing extra and is three
// parameters longer.
if (options.optionalRules?.[key] === true) params.set(param, '1');
}
return `?${params}`;
}
/**
* WHAT THIS BROWSER REMEMBERS ABOUT A MULTIPLAYER GAME, and when.
*
* It used to be written only at `Lobby.Start`, which meant a refresh while SEATED — before the host
* started — orphaned the chair: the token existed nowhere else, so the player could not return and
* the seat could not be freed, and a table that needs every chair filled could no longer start.
* The record is written the moment a seat is taken, and `stage` says how far it got.
*/
type RemoteRecord = {
token: string;
gameId: string;
gameCode: string;
/** Only known once the game exists; a lobby-stage record has no seat yet. */
seat?: PlayerIndex;
stage: 'lobby' | 'game';
};
/**
* EVERY multiplayer game this browser holds a seat in, and which was last played.
*
* It used to be ONE record under one key, so joining a second game overwrote the first — and since
* the token IS the identity (`lobby-and-sessions.md` §1), that seat was then locked out for good.
* `TODO.md` had it as "a second, nearer limit" under the lost-token item; Jesse hit it from the
* other side, asking how to leave a game and play a different one later.
*/
type RemoteStore = { games: Record<string, RemoteRecord>; last: string | null };
function readStore(): RemoteStore {
try {
const raw = localStorage.getItem(REMOTE_KEY);
if (!raw) return { games: {}, last: null };
const parsed = JSON.parse(raw) as Partial<RemoteStore> & Partial<RemoteRecord>;
// The single-record shape written before 2026-08-23 — carried across rather than dropped, so an
// update does not throw away the game somebody is in the middle of.
if (typeof parsed.token === 'string' && typeof parsed.gameId === 'string') {
const one: RemoteRecord = {
token: parsed.token,
gameId: parsed.gameId,
gameCode: parsed.gameCode ?? '',
...(parsed.seat === undefined ? {} : { seat: parsed.seat }),
stage: parsed.stage ?? 'game',
};
return { games: { [one.gameId]: one }, last: one.gameId };
}
const games = parsed.games ?? {};
return { games, last: parsed.last ?? null };
} catch {
return { games: {}, last: null };
}
}
function writeStore(store: RemoteStore): void {
try {
localStorage.setItem(REMOTE_KEY, JSON.stringify(store));
} catch {
// A full or disabled localStorage must not take the game down with it — the session in memory
// keeps working, it simply will not survive a reload.
}
}
/** The game to re-enter on a bare page load: the one most recently played. */
function loadRemote(): RemoteRecord | null {
const store = readStore();
return store.last === null ? null : (store.games[store.last] ?? null);
}
function saveRemote(record: RemoteRecord): void {
const store = readStore();
store.games[record.gameId] = record;
store.last = record.gameId;
writeStore(store);
}
/** Deliberate, and the one irreversible thing on the lobby screen: the token is the only proof of
* who you are, so forgetting it gives up the seat with no way back from this browser. */
function forgetRemote(gameId: string): void {
const store = readStore();
delete store.games[gameId];
if (store.last === gameId) store.last = null;
writeStore(store);
}
function knownRemote(): RemoteRecord[] {
return Object.values(readStore().games);
}
/** The handlers the lobby drives this page through — one place, since four callers open a lobby.
* Storage lives here rather than in `lobby.ts`, which owns the screen and not the browser. */
const lobbyHandlers = {
onReady: (ready: LobbyReady): void => beginRemote(ready),
onSeated: (s: { token: string; gameId: string; gameCode: string }): void =>
saveRemote({ ...s, stage: 'lobby' }),
onLeft: (gameId?: string): void => {
const target = gameId ?? readStore().last;
if (target !== null && target !== undefined) forgetRemote(target);
},
known: (): { gameId: string; gameCode: string; stage: 'lobby' | 'game' }[] =>
knownRemote().map((r) => ({ gameId: r.gameId, gameCode: r.gameCode, stage: r.stage })),
forget: (gameId: string): void => forgetRemote(gameId),
rejoin: (gameId: string): void => {
const record = readStore().games[gameId];
if (!record) return;
if (record.stage === 'game' && record.seat !== undefined) {
beginRemote({ ...record, seat: record.seat });
return;
}
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
// and its own probe handles a game that began while we were away.
showScreen('lobby');
runLobby(lobbyHandlers, { token: record.token, gameId: record.gameId, gameCode: record.gameCode });
},
};
/**
* THE GAME CODE THIS PAGE IS IN, once it is in one.
*
* It ended at the lobby door before 2026-08-23 — `LobbyReady` carried the token, the game id and the
* seat, and the code (the only one of the four a person can read out) was dropped. A seated player
* could not say which game they were in, match it against the administrator's Games in Progress
* list, or pass it to a latecomer. Empty in solitaire, where there is no code.
*/
let gameCode = '';
/** How long the handoff curtain holds, so the start of a game is a moment rather than a snap. */
const HANDOFF_BEAT_MS = 1500;
/** How long to wait before saying the board has not arrived. */
const HANDOFF_STALL_MS = 8000;
let handoffOpenedAt = 0;
let handoffStall: number | null = null;
let firstFrameSeen = false;
function setText(id: string, text: string): void {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
/** The curtain between `Lobby.Start` and the first Frame — see `#handoff` in `play.html`. */
function openHandoff(): void {
const el = document.getElementById('handoff');
if (!el) return;
firstFrameSeen = false;
handoffOpenedAt = Date.now();
el.classList.add('shown');
setText('handoff-title', 'Dealing the railroad…');
setText('handoff-note', 'Laying out the Division and rolling for seats.');
if (handoffStall !== null) clearTimeout(handoffStall);
handoffStall = window.setTimeout(() => {
if (firstFrameSeen) return;
setText('handoff-title', 'Still waiting for the server');
setText(
'handoff-note',
'The game exists, but its board has not arrived yet. It will appear as soon as the server sends it.',
);
}, HANDOFF_STALL_MS);
}
function closeHandoff(): void {
document.getElementById('handoff')?.classList.remove('shown');
if (handoffStall !== null) clearTimeout(handoffStall);
handoffStall = null;
}
/** Flash a one-line announcement over the board. Shared by the session's own announcements and by
* the "the game has begun" line, which comes from the page rather than from an event. */
function flashAnnounce(text: string): void {
const el = document.getElementById('announce');
if (!el) return;
el.textContent = text;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent === text) el.className = '';
}, 4200);
}
/**
* THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10).
*
* "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
* time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which
* Day it was — but a Day turns over inside the phases that run themselves, so it happens while the
* player is looking at the board waiting for their next turn. The two transient signals the page
* already had are both gone in under five seconds.
*
* Not shown when:
* - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending;
* - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing;
* - the game finished on that rollover, when the outcome panel is the thing to read instead.
*/
function noteDayEnd(f: Frame): void {
const previous = lastDay;
lastDay = f.day;
if (previous === null || f.day <= previous) return;
if (f.status !== 'active') return;
const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null;
const body = document.getElementById('dayendbody');
if (!dlg || !body) return;
body.innerHTML = dayEndHtml(f);
// A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws
// on an already-open dialog rather than doing nothing.
if (!dlg.open) dlg.showModal();
}
/**
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
*
* The board simply appeared, mid-Local-Operations, with a log already several bot turns deep and
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
* phase, and there is no previous phase to have changed from.
*/
function noteFirstFrame(f: Frame): void {
if (firstFrameSeen || isLocal(session)) return;
firstFrameSeen = true;
const held = Date.now() - handoffOpenedAt;
const config = configFromFrame(f);
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
window.setTimeout(
() => {
closeHandoff();
flashAnnounce(
`The game has begun — ${type} · ${f.players.length} players · Day ${f.day}, Stage ${f.stage}`,
);
},
Math.max(0, HANDOFF_BEAT_MS - held),
);
}
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
* 2026-08-29). All three start `hidden` in the markup so none ever flashes before `start()` decides
* which one this load actually needs. */
function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
document.getElementById('lobby')!.hidden = which !== 'lobby';
document.getElementById('gameui')!.hidden = which !== 'gameui';
document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup';
}
/**
* The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s
* `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
* so it is always written back here before anything else happens.
*/
function beginRemote(ready: LobbyReady): void {
saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
gameCode = ready.gameCode;
showScreen('gameui');
// Nothing can be drawn until the first push arrives, and a page showing nothing at all is
// indistinguishable from a page that is broken — which is exactly what a dead session used to
// look like, forever. This is its own state now rather than a borrowed line in the DISCONNECT
// banner (`#presence`), and it holds a beat so the game visibly begins.
openHandoff();
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
// `createRemoteSession` explains why `view()` would otherwise throw).
session.subscribe(render);
}
/**
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
*
* Two things legitimately destroy a game under a seated player, and both are by design: an
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
* short of clearing site data.
*
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
*/
function abandonRemote(): void {
// That one game is gone; any OTHER game this browser is in is untouched.
const store = readStore();
if (store.last !== null) forgetRemote(store.last);
closeHandoff();
showScreen('lobby');
$('presence').textContent = '';
runLobby(lobbyHandlers);
// The lobby's own notice slot, not the create form's error line: the player may well have been a
// joiner, and with the two doors that line is behind a panel they are not looking at.
notice(
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
'service was updated, which does not carry games in progress across. Create or join a new one.',
);
}
/**
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
* cannot produce one. `start()`'s job is only to decide which of three screens this load is: back
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/
function start(): void {
const params = new URLSearchParams(location.search);
/**
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
*
* The splash's "Play multiplayer" door and an invite link both land here with `?lobby`, and both
* mean "I want to pick a game" — but a remembered session used to be checked first, so anyone
* already in a game was dropped straight back into it and could never reach the lobby from the
* door at all. A BARE load still resumes, which is the common case and the one D11 is about.
*/
const invited = params.get('code');
if (params.get('lobby') !== null || invited !== null) {
showScreen('lobby');
if (invited !== null && invited !== '') prefillCode(invited);
runLobby(lobbyHandlers);
return;
}
/**
* ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby`
* above, for the door on the other side. A browser that has ever held a multiplayer seat carries
* `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell
* "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door
* always lost to whatever multiplayer game or lobby this browser last touched, and could never
* actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed
* back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new
* setup screen. The door now marks its intent explicitly, the same way `?lobby` already does —
* and so does everything else that already means "this is a solitaire navigation": an explicit
* `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes
* it on every commit, so landing back here with it set is that navigation, not a bare reload).
* Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would
* work once and then bounce the very next load into whatever multiplayer game this browser last
* touched, since its URL carries `hand=` but not `solitaire=`.
*/
const wantsSolitaire =
params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand');
// Entered without checking it still exists — deliberately. Verifying up front would mean an
// await before anything renders on the common path, where the game IS still there; instead the
// session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = wantsSolitaire ? null : loadRemote();
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
beginRemote({ ...remembered, seat: remembered.seat });
return;
}
/**
* A SEAT TAKEN BUT NOT YET PLAYING — this browser reloaded while the lobby was still seating.
*
* The lobby stream answers all three cases from here without another route: it pushes the seating
* screen if the lobby is still open, and its `onerror` probe finds either a game that started
* while we were away (straight in) or a lobby that is gone (back to the doors, with a reason).
*/
if (remembered) {
showScreen('lobby');
runLobby(lobbyHandlers, {
token: remembered.token,
gameId: remembered.gameId,
gameCode: remembered.gameCode,
});
return;
}
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
// `configFor`. Read once, here, so the same answer decides both whether to ask before dealing and
// (below) whether to restore.
const saved = load();
const requested = params.get('seed');
/**
* ASK BEFORE THE FIRST DEAL, THE SAME WAY THE LOBBY ASKS BEFORE THE FIRST MULTIPLAYER GAME
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
* "asking first is the only path").
*
* A saved game or an explicit `seed=` both mean this visit is not "no plan yet" — a saved game is
* a game to resume, and a seed names a specific deal someone already chose to share or bookmark,
* the same reasoning `?lobby` already uses to skip past the doors on an invite link. `hand` is the
* one field every `commitNewGame` write always sets (`rulesToUrl`), so its presence means this
* navigation IS the setup screen's own Deal button, landing back here to actually deal — checking
* it is what stops the screen asking itself the question a second time.
*/
if (!saved && requested === null && !params.has('hand')) {
showScreen('solitairesetup');
runSolitaireSetup(params);
return;
}
showScreen('gameui');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
session = local;
if (saved && requested === null) local.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
// which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render);
render();
}
/**
* Hide the controls this session does not offer.
*
* Undo, a local save and dealing a new game are all things only a local session can do — a server
* cannot un-see what the other players have already seen, the server is the store, and dealing is
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there.
*/
function applyCapabilities(): void {
const c = session.capabilities;
const hide = (id: string, on: boolean): void => {
const el = document.getElementById(id);
if (el) el.hidden = !on;
};
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('newgame', c.newGame);
// Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this
// page offers — same reasoning as `newgame`, and the same capability answers both.
hide('multiplayer', c.newGame);
// The mirror of the three above: leaving a game is the one control that only a REMOTE session has.
hide('leavegame', !c.newGame);
}
/**
* The west-to-east chain in words, with the D12 that decided it (§4.4).
*
* The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was
* kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and
* here is the roll.
*/
function renderSeatingChain(f: Frame): void {
const el = document.getElementById('seating-chain');
if (!el) return;
/**
* ONLY WHILE THE GAME IS STILL OPENING.
*
* This answers "who is where, and why" — which is a question you have once, at the start, when
* the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been
* answering it for a while, and a permanent line restating it is a permanent line to read past.
*/
const opening = f.day === 1 && f.stage === 1;
if (f.players.length < 2 || !opening) {
el.textContent = '';
return;
}
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
const chain = bySeat
.map((p) => {
const roll = f.openingRolls.division[p.index];
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
.filter(Boolean)
.join(', ');
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
})
.join(' → ');
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
}
/**
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
* has anything to report), and empty again the moment everyone reports back in — `#presence:empty`
* collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read.
*/
function renderPresence(f: Frame): void {
const name = (seat: PlayerIndex): string =>
f.players.find((pl) => pl.index === seat)?.name ?? `Seat ${seatLabel(seat)}`;
const presence = session.presence();
/**
* TWO DIFFERENT ABSENCES, and they call for two different things from the table.
*
* A seat the server has never heard from has not opened the game yet — somebody needs to send them
* the link. A seat that WAS here and dropped will probably be back. The server reports both from
* `/api/stream`'s connect push (2026-08-23); before that a client learnt of a seat only when it
* disconnected AFTER you connected, so a table where two people had not shown up yet said nothing
* at all.
*/
const dropped = presence.filter((p) => p.seen && !p.connected).map((p) => name(p.seat));
const never = presence.filter((p) => !p.seen).map((p) => name(p.seat));
const parts: string[] = [];
if (dropped.length > 0) parts.push(`${dropped.join(', ')} — disconnected`);
if (never.length > 0) parts.push(`${never.join(', ')} — not here yet`);
$('presence').textContent = parts.length === 0 ? '' : `⚠ waiting on ${parts.join(' · ')}`;
}
/**
* WHICH GAME THIS IS, in the header: its code and its type.
*
* Neither used to be anywhere on the board. The code ended at the lobby door, and the type was never
* on the Frame at all — so a player could read what a load paid but not whether they were in a Co-op
* game or a Cutthroat one, which is the difference between helping the table and racing it.
*/
function renderGameIdentity(f: Frame): void {
const codeEl = document.getElementById('gamecode');
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
const el = document.getElementById('gametype');
if (!el) return;
const config = configFromFrame(f);
const players = f.players.length;
const type = presetOf(config, players, f.days);
const near = closestPreset(config, players, f.days);
el.textContent = gameTypeLabel(type, f.mode);
el.title =
type === 'custom'
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
}
/** The victory conditions in force, spelled out for the header's tooltip. */
function rulesSummary(f: Frame): string {
const off = (n: number): string => (n === 0 ? 'off' : String(n));
return (
`Days: ${f.days}\n` +
`Combined Revenue floor: ${off(f.minCombinedRevenue)}\n` +
`Collisions in one Day that end the game: ${off(f.maxCollisionsPerDay)}\n` +
`Collisions in the whole game that end it: ${off(f.maxCollisionsTotal)}`
);
}
function render(): void {
const f = session.view();
const menu = session.menu();
noteFirstFrame(f);
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
// Squares light up only while a card is picked FOR PLAY. `selected` names the card; the placeable
// entry that carries its squares is keyed the same way the menu keys it.
const forPlay =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
const chosen = forPlay
? menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === forPlay.placeKey)
: undefined;
// A spot with no coordinate is not on this board — ABS Signals goes out on the Mainline — so it
// is chosen from the action list and lights nothing in the district.
const spotsAt = new Map<string, { label: string; index: number }[]>();
for (const sp of chosen?.spots ?? []) {
if (sp.coord === null) continue;
const key = `${sp.coord.row},${sp.coord.col}`;
spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]);
}
renderTurnChart(f);
renderPresence(f);
$('revenue').textContent = String(f.revenue);
/**
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
*
* It used to read "3 of 20 · 2 Days left · behind the pace (expected 8)". The score, the target and
* the Days left are what a player steers by; the pace verdict and the engine's guess at what the
* score ought to be were an opinion taking up the one line that must not wrap — and the expected
* figure came from a target that is itself an open question.
*/
const obj = $('objective');
/**
* Past the original timetable this has to stop saying "N Days left" of a Day count that no longer
* applies (Gitea#11). `objective.daysLeft` already counts against the EXTENDED timetable; what it
* cannot say on its own is that the Days being counted are borrowed ones.
*/
const left = `${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.textContent =
f.extraDays > 0
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
: `${f.revenue} of ${f.objective.target} · ${left}`;
obj.className = 'pace';
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
renderGameIdentity(f);
renderHouseRules(f.houseRules);
// -- division
$('division').innerHTML = divisionSvg(f.division, {
players: f.players,
actor: f.actor,
viewer: f.viewer,
});
renderSeatingChain(f);
applyZoom($('division'));
// -- board. Both renderers are shared with the replay so the two can never draw different
// pictures of the same position.
const grid = $('grid');
// The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the
// same pass so they share its origin and its canvas: a target outside the canvas is a legal move
// the player cannot click.
const ghostCoords = [...spotsAt.keys()]
.filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k))
.map((k) => {
const [gr, gc] = k.split(',').map(Number);
return { row: gr!, col: gc! };
});
/**
* WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO.
*
* Three different acts light up the same blue: building on empty ground, EXTENDING the Running
* Track at a Limits sign (the sign moves outward with the card — the only way a district grows
* along the main), and ATTACHING an Enhancement to a card already down. The first says "place
* here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up
* read as a bug rather than as an offer.
*/
const legalCaps = [...spotsAt.keys()].flatMap((k) => {
const cell = f.cells.find((c) => `${c.row},${c.col}` === k);
if (!cell) return [];
const label =
cell.kind === 'limits'
? 'EXTEND THE RUNNING TRACK HERE'
: cell.kind === 'office'
? 'ATTACH TO YOUR OFFICE'
: 'ATTACH TO THIS CARD';
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
applyZoom(grid);
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
// you switching?" picker writes, so the board and the action panel drive one value either way.
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
const trayId = (g as HTMLElement).dataset['crew'];
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
}
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
const [gr, gc] = key.split(',').map(Number);
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
if (g) {
g.classList.add('bs-legal');
(g as unknown as HTMLElement).onclick = () => pick(key, list);
}
}
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
for (const [key, list] of spotsAt) {
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
const g = grid.querySelector(`g[data-ghost="${key}"]`);
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
}
/**
* THE SWITCHING MOVE, ON THE BOARD.
*
* Every switching decision is about geography — which card the crew can reach, what it will couple
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
* months; the play page simply never used them.
*
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
*/
/**
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
* THIS train can go", which is the whole reason they are on the board.
*/
const crew = pickedCrew(f);
if (crew && !forPlay) {
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
// share, and outlining the card would claim it belongs to both.
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
if (strip) strip.classList.add('bs-from');
for (const c of crew.to) {
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
if (g) g.classList.add('bs-focus');
}
for (const b of crew.blocked) {
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
if (!g) continue;
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
// your way — you run through it — so it must not be drawn like an industry that is locked.
const passable = b.kind === 'noStopping';
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
const own = g.getAttribute('data-tip') ?? '';
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
}
}
function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) {
const intent = menu.options[list[0]!.index];
if (intent) void session.submit(intent);
selected = null;
pendingAt = null;
} else {
pendingAt = key;
}
render();
}
// -- facilities
$('facs').innerHTML = facilitiesHtml(f);
// Name AND effect. A hand of names alone tells a player nothing about what they can do.
// Name and status stay on the page; what the card DOES is reference detail, so it hovers.
/**
* THE HAND, AS THE ACTION SURFACE.
*
* Each card carries its own verbs. This used to be a read-only row, with the same cards appearing
* again in the action list — once as subjects to play and once as one button per Department to
* discard, which for a four-card hand was twelve buttons repeating three choices four times.
*/
$('hand').innerHTML = menu.hand.length
? menu.hand
.map((h) => {
const canPlace = h.placeKey !== null && h.spots > 0;
const canPlay = canPlace || h.playNow !== null;
const canDiscard = h.discard.some((d) => d !== null);
const picked = selected === `hand:${h.cardId}`;
const verb = (kind: string, on: boolean, text: string, tip: string): string =>
on
? `<button class="cardact ${kind}${picked && mode === kind ? ' on' : ''}" ` +
`data-card="${esc(h.cardId)}" data-verb="${kind}" data-tip="${esc(tip)}">${text}</button>`
: '';
// A track card shows what it would look like on the board, in every orientation it has —
// which is how a player sees a turnout has two at all, and what a curve actually is.
const fig = h.shapes.length
? ` data-tip-html="${esc(h.shapes.map((l) => piecePreview(l, h.name)).join(''))}"`
: '';
// The card just drawn, badged so it can be told from the two beside it. It stands until
// another draw replaces it, rather than flashing once — the question a player asks looking
// at the row is "which of these is new", not "did something happen".
const fresh = h.cardId === session.justDrawn();
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
`<b>${esc(h.name)}</b>` +
`<div class="cardacts">` +
verb(
'play',
canPlay,
canPlace ? `play <span class="dim">${h.spots}</span>` : 'play',
canPlace
? `Play it onto the board — ${h.spots} square${h.spots === 1 ? '' : 's'} will light up.`
: 'Play it. This card needs no square on the board.',
) +
verb('discard', canDiscard, 'discard', 'Discard it face up on top of a Department — pick which one.') +
`</div></div>`
);
})
.join('')
: '<span class="dim">empty</span>';
/**
* THE THREE DEPARTMENT PILES, and how deep each one is.
*
* They are decks, not single face-up cards: a discard goes on TOP of the one its owner chooses, so
* a pile is a card being offered with a history of cards buried under it. Only the top may be
* taken, which makes the depth real information — a deep pile is where cards have been put beyond
* reach. Drawn like the hand so they read as cards, dashed and unlit because taking one is a draw
* action rather than a click on the card itself.
*/
$('depts').innerHTML = pilesHtml(f);
renderYards(f);
// -- the hand's verbs. Picking one selects the card and says what it is for; the "where" is then
// highlighted in place, on the board or on the Department piles.
for (const b of Array.from($('hand').querySelectorAll('button.cardact'))) {
const node = b as HTMLElement;
node.onclick = () => {
const cardId = node.dataset['card']!;
const verb = node.dataset['verb'] as 'play' | 'discard';
const entry = menu.hand.find((h) => h.cardId === cardId);
if (!entry) return;
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
render();
return;
}
const key = `hand:${cardId}`;
const same = selected === key && mode === verb;
selected = same ? null : key;
mode = same ? null : verb;
pendingAt = null;
render();
};
}
// -- discarding: the three Department piles become the targets. They already show their top card
// and their depth, which is exactly what you choose between.
const discarding = mode === 'discard' && selected !== null
? menu.hand.find((h) => `hand:${h.cardId}` === selected)
: undefined;
if (discarding) {
// Dim the piles that are not targets, so the three that are read as a choice being offered
// rather than as four cards that happen to be there.
$('depts').classList.add('aiming');
for (const el of Array.from($('depts').querySelectorAll('[data-dept]'))) {
const node = el as HTMLElement;
const slot = Number(node.dataset['dept']);
const index = discarding.discard[slot];
if (index === null || index === undefined) continue;
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) void session.submit(intent);
selected = null;
mode = null;
render();
};
}
}
else {
$('depts').classList.remove('aiming');
}
// -- making up a train: the Division Yard chip that shows the car IS the button.
if (menu.makeUp) {
for (const el of Array.from($('divyard').querySelectorAll('[data-car]'))) {
const node = el as HTMLElement;
const car = menu.makeUp!.cars.find(
(c) => c.carType === node.dataset['car'] && c.loaded === (node.dataset['loaded'] === '1'),
);
if (!car) continue;
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) void session.submit(intent);
render();
};
}
}
/**
* THE TIMETABLE, with the slot the die just filled flashed.
*
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
* it is gone by the next render, which is what makes it read as "that just happened".
*/
// Draining: the flash marks the moment the die was read, not a state, so it is gone by the next
// render. Taken once here and handed to both the timetable and the action panel.
const justSet = session.takeScheduled();
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
const allLines = session.lines();
const shownLines = allLines.slice(-60);
/**
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
* by the time the board paints the log already has several turns in it and nothing says which of
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
*/
const startMarker =
!isLocal(session) && allLines.length === shownLines.length
? '<div class="line t-phase">— the game began —</div>'
: '';
log.innerHTML =
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
log.scrollTop = log.scrollHeight;
/**
* SAY WHEN THE PHASE TURNS OVER.
*
* The five phases run themselves once Local Operations ends, so the page can change out from under
* a player between one click and the next. The turn chart already shows WHERE you are; this says
* that it moved, which is the part you miss when you were looking at the board.
*/
if (lastPhase !== null && lastPhase !== f.phaseKey) {
const el = $('phasenote');
el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent?.startsWith(f.phase)) el.className = '';
}, 2600);
}
lastPhase = f.phaseKey;
noteDayEnd(f);
/**
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
*/
const announcement = session.takeAnnouncement();
if (announcement) flashAnnounce(announcement);
renderDistrict(f);
renderActions(menu, f, justSet);
renderUndo();
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
const cues = session.takeCues();
if (soundOn) for (const c of cues) playCue(c);
save();
}
/**
* UNDO, as far back as you like.
*
* The game is a seed and a list of intents, so stepping back is replaying without the last one —
* see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get
* back?" is the question it exists to answer; it goes quiet when there is nothing to take back.
*
* The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on
* screen is left describing a move that no longer happened. Any card picked up mid-choice is
* dropped: the position it was going to be played into may not exist any more.
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn || !isLocal(session)) return;
// Captured as a `const` rather than read as `session` again inside the closure below: `session` is
// a mutable module-level `let`, so TypeScript cannot carry the `isLocal` narrowing across a closure
// boundary — a local const it can never see reassigned keeps the narrowed `LocalSession` type.
const local = session;
const n = local.steps();
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => {
// The session drops the rebuilt game's cues and draws — replaying the history re-records them,
// and none of it is news to a player who just stepped back.
if (!local.undo()) return;
selected = null;
mode = null;
pendingAt = null;
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
// different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null;
// Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a
// rollover and then replaying forward through it would announce the same Day ending twice.
lastDay = null;
};
}
/**
* The two yards, and how close the Division Yard is to running out.
*
* §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the
* Division Yard is BARE. So the interesting number is not how much has been used but how little is
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
$('clstot').textContent = `${f.yards.classificationTotal} cars`;
// The one thing worth calling out: the yard about to turn over.
const bare = f.yards.divisionTotal === 0;
$('divyard').classList.toggle('bare', bare);
$('yardnote').textContent = bare
? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.`
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
$('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
// status line and was missed entirely; "always show" is an instruction.
const btn = $('districttoggle');
btn.textContent =
districtMode === 'auto'
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
: districtMode === 'open'
? 'always showing — click for auto-hide'
: 'always hidden — click for auto-hide';
btn.onclick = () => {
// auto -> pin it to the opposite of what auto is doing -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
saveSettings({ districtMode });
render();
};
}
/**
* The square an action button acts on, as an attribute the board can be matched against.
*
* `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card
* that goes to the timetable — so those buttons carry nothing and highlight nothing.
*/
function cellRef(
coord: { row: number; col: number } | null | undefined,
route?: { row: number; col: number }[],
): string {
const square = coord ? ` data-square="${coord.row},${coord.col}"` : '';
// Only a `switch.move` with more than one legal route carries this (docs/plans/switching-paths.md)
// — every intermediate square the chosen route runs over, so hovering lights the whole road, not
// just where it ends.
const path = route && route.length > 0 ? ` data-route="${route.map((c) => `${c.row},${c.col}`).join(' ')}"` : '';
return square + path;
}
/**
* POINT AT THE SQUARE THE BUTTON MEANS.
*
* The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" —
* and the board is right beside it saying nothing about which one is which. Hovering a button now
* lights its square up, so the check happens with the eye rather than by reading two numbers off a
* button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a
* click; a disaster in multiplayer, where it is not.
*
* ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the
* keyboard route is not a lesser one.
*
* BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty
* `data-ghost` target, which is precisely the case where the player is choosing between coordinates.
* Matching only one of the two would leave the most mistake-prone moment unhelped.
*/
function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string[] = []): void {
const grid = $('grid');
const keys = [key, ...routeKeys];
const marks = (): Element[] =>
keys
.flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)])
.filter((g): g is Element => g !== null);
const on = (): void => {
for (const g of marks()) g.classList.add('bs-point');
};
const off = (): void => {
for (const g of marks()) g.classList.remove('bs-point');
};
node.onmouseenter = on;
node.onmouseleave = off;
node.onfocus = on;
node.onblur = off;
}
/**
* THE END OF THE GAME — the action area once there is nothing left to decide, or only one thing.
*
* Two states share this, and they are genuinely different (Gitea#11):
*
* - `awaitingExtension` — the timetable ran out on an ending the table MAY play past. The result
* is already recorded and already readable; the only question open is whether to run one more
* Day. In multiplayer that is a unanimous vote, so this also has to show who is still to answer.
* - `finished` — over for good. The results screen, and a new game.
*
* The results are reachable in BOTH, and stay reachable after play continues, which is the
* constraint Gitea#16 and Gitea#11 put on each other: continuing must not cost you the results
* screen, so it is a button that reopens rather than a screen you get one look at.
*/
function renderEnding(el: HTMLElement, f: Frame): void {
const o = f.official?.outcome ?? f.outcome;
const won = o?.result === 'win';
/**
* Put the results up once per ending, unasked.
*
* "Once per ENDING" rather than once per game is the extended-play case: an extended game ends,
* is played on, and ends again, and each of those is a moment worth reading. `renderActions`
* clears the flag whenever the game is running again, so the next ending gets its own showing —
* while a redraw during the same ending does not reopen a dialog the player has dismissed.
*/
if (!resultsShown) {
resultsShown = true;
showResults(f);
}
if (f.status === 'awaitingExtension') {
const mine = f.extensionVotes[f.viewer];
// Only seats that exist are counted; `extensionVotes` is per PLAYER and so is `players`.
const waiting = f.players.filter((p) => f.extensionVotes[p.index] === null);
const tally = f.players.length > 1
? '<div class="vote-tally">' +
f.players
.map((p) => {
const v = f.extensionVotes[p.index];
const mark = v === true ? '✓' : v === false ? '✗' : '·';
return `<span class="vote ${v === true ? 'yes' : v === false ? 'no' : 'wait'}">` +
`${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}</span>`;
})
.join('') +
'</div>'
: '';
el.innerHTML =
`<div class="over ${won ? 'win' : 'loss'}">` +
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.<br>` +
'The result above is final. Play one more Day?</div>' +
tally +
(mine === null
? '<button id="extend-yes">play one more Day</button>' +
'<button id="extend-no">end the game here</button>'
: `<div class="dim">You voted ${mine ? 'to play on' : 'to end it'}. ` +
(waiting.length
? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.`
: 'Settling…') +
'</div>') +
'<button id="results">see the full results</button>';
if (mine === null) {
const vote = (agree: boolean) => () =>
void session.submit({ type: 'game.extend', player: f.viewer, agree });
$('extend-yes').onclick = vote(true);
$('extend-no').onclick = vote(false);
}
$('results').onclick = () => showResults(f);
return;
}
el.innerHTML =
`<div class="over ${won ? 'win' : 'loss'}">` +
`${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}<br>` +
`final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}</div>` +
'<button id="results">see the full results</button>' +
(session.capabilities.newGame ? '<button id="again">new game</button>' : '');
$('results').onclick = () => showResults(f);
if (session.capabilities.newGame) {
$('again').onclick = () => {
clearSave();
location.search = '';
};
}
}
/**
* Whether the results have been put up by themselves for the ending currently on screen.
*
* Cleared by `renderActions` the moment the game is running again, so an extended game gets a fresh
* showing at each of its endings while a redraw during one ending does not reopen a dialog the
* player has just dismissed.
*/
let resultsShown = false;
function showResults(f: Frame): void {
const dlg = document.getElementById('resultsdlg') as HTMLDialogElement | null;
const body = document.getElementById('resultsbody');
if (!dlg || !body) return;
body.innerHTML = resultsHtml(f);
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
// than doing nothing (the same trap `noteDayEnd` documents).
if (!dlg.open) dlg.showModal();
}
function renderActions(
menu: Menu,
f: Frame,
justSet: number | null,
): void {
const el = $('actions');
if (f.status !== 'active') {
renderEnding(el, f);
return;
}
// The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to
// put its results up unasked again (Gitea#11).
resultsShown = false;
if (menu.direct.length === 0 && menu.placeable.length === 0) {
el.innerHTML = '<div class="dim">nothing to decide — the engine is running the Division</div>';
return;
}
const apply = (index: number): void => {
const intent = menu.options[index];
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
render();
};
/**
* A long label is two things: the action, and why it is offered. Put the first on the button and
* the second on the tooltip, or the list crowds out the board.
*
* AND IF THE LABEL CARRIES NO EXPLANATION, ask the card. Tooltips used to depend entirely on a
* label happening to contain an em-dash, so an action naming a card could have none at all while
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
const actionButton = (a: {
index: number;
label: string;
tip?: string;
coord?: { row: number; col: number };
route?: { row: number; col: number }[];
}): string => {
const { label, index } = a;
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
// The menu resolved the card's description server-side, so the page never needs the state.
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return (
`<button class="act" data-i="${index}"${cellRef(a.coord, a.route)}${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
};
/**
* MOVES LEFT, WHERE THE MOVES ARE.
*
* §6.1 gives six Moves a turn and every switching decision is really "can I still get back?" — so
* the count belongs beside the buttons. It was reported only in the history panel, which is the
* one place a player is not looking while switching.
*/
/**
* WHAT THE DIE DID, said where the player is looking.
*
* Playing a train card rolls 1D12 for its departure Stage, and the card simply left the hand — the
* answer arrived only in the history panel, one line among many. The timetable now shows it and
* flashes the slot; this says it in words at the same moment.
*/
const scheduledNote =
justSet !== null && f.timetable[justSet] != null
? `<div class="scheduled" data-tip="The Stage is rolled on 1D12 when the card is played; if that Stage is taken the train works down the column to the next free one. From now on it runs at this time every Day.">` +
`Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable</div>`
: '';
const movesNote =
f.movesLeft !== null
? `<div class="moves${f.movesLeft === 0 ? ' spent' : ''}" data-tip="Six Moves a turn. A Move runs any distance in one direction; changing direction costs another, which is why a run-around has to be planned inside the count.">` +
`${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left</div>`
: '';
// A heading over the buttons, so the panel says what it is before it says what is in it. The
// list below is already phase-specific: it comes from `legalActions`, so in the Cargo phase with
// no worker able to act, the only thing offered is "End my Cargo phase".
let html = `<h3 class="actions-hd">Actions</h3>${scheduledNote}${movesNote}`;
/**
* WHICH TRAIN AM I SWITCHING? Only asked when there is more than one crew to be switching, so a
* solitaire opening — one crew, no ambiguity — is unchanged. The chosen one is the crew whose
* reachable squares the board draws, so this row and the highlights are the same statement.
*/
if (f.moves.length > 1) {
const chosen = pickedCrew(f);
html +=
`<div class="grp crewpick"><h3>Which train are you switching?</h3>` +
f.moves
.map(
(m) =>
`<button class="act crew${m.trayId === chosen?.trayId ? ' on' : ''}" data-crew="${esc(m.trayId)}"${cellRef(m.from)} ` +
`data-tip="Draw this crew's reachable squares on the board, and show its moves below. ${esc(m.label)} is standing at (${m.from.col}, ${m.from.row}) with ${m.to.length} square${m.to.length === 1 ? '' : 's'} it can reach.">` +
`${esc(m.label)} <span class="dim">(${m.from.col}, ${m.from.row})</span></button>`,
)
.join('') +
`</div>`;
}
/**
* WHAT IS LEFT IN THE ACTION LIST.
*
* Everything about a card in hand now lives on the card, and everything about making up a train
* lives on the yard chip. What remains is the rest of the turn: the Local Operations choice,
* drawing, switching moves, the Freight Agent, and finishing.
*
* EXCLUDE BY TITLE, NOT BY PREFIX. This used to be `!/^Making up /.test(g.title)`, on the
* assumption only the yard-chip panel's own group is ever titled that way. It is not: "choose
* where Extra X22 starts" is titled by `trainCardTitle`, which also begins "Making up Extra
* X22…", so the regex swallowed it too — and with no tray yet being filled, `menu.makeUp` is
* null, so nothing rendered it anywhere else. REPORTED as the game hanging with the Freight
* House mid-cycle: an Extra came due, the action panel had a legal decision and zero buttons,
* and nothing short of restarting looked like it would ever move again. Matching the exact
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
* however it is titled, on screen where a player can act on it.
*/
html += menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
.map(
(g) =>
`<div class="grp"><h3>${esc(g.title)}</h3>` +
g.actions
.map((a) => {
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
// the INTENT, not the label: matching button text would break the moment the wording
// changed, and would have caught `switch.end` too.
return actionButton(a);
})
.join('') +
`</div>`,
)
.join('');
/**
* MAKING UP A TRAIN.
*
* The cars are added from the Division Yard chips, but the panel still has to exist: it names the
* train and what its card calls for, and it carries the "no more cars" button, which is the ONLY
* way to finish when the yard holds nothing the train may take.
*
* Moving the cars onto the yard chips without this left a train being made up with no control on
* screen at all whenever no chip was addable — a hard softlock, reported at Stage 10 of seed
* 775569289 with Train 10 waiting at the West Division Point.
*/
if (menu.makeUp) {
const addable = menu.makeUp.cars.length;
html +=
`<div class="grp"><h3>${esc(menu.makeUp.title)}</h3>` +
`<div class="dim makeup-note">` +
(addable > 0
? `Click a car in the Division Yard below to add it — ${addable} kind${addable === 1 ? '' : 's'} it may take are highlighted there.`
: menu.makeUp.pass !== null
? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.'
: 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') +
`</div>` +
/**
* WHICH ORDER TO ADD THEM IN, when the order is what decides whether the train can work.
*
* Only ever present for a train the order can lock — 7/8 Local — so this is not a standing
* caption a player learns to skip. Reported from play as "I can't drop a car at all": the
* Local made up ENGINE, freight, coach cannot set anything out for the rest of the game, and
* nothing on screen said so until the crew was out on the district with no button to press.
*/
(menu.makeUp.advice
? `<div class="makeup-advice ${menu.makeUp.advice.tone}">${esc(menu.makeUp.advice.text)}</div>`
: '') +
(menu.makeUp.pass !== null
? `<button class="act" data-i="${menu.makeUp.pass}" data-tip="A train may depart with FEWER cars than its card lists, but never with the wrong ones. This sends it out as it stands.">no more cars — send it out</button>`
: '') +
`</div>`;
}
/**
* The card is picked in the hand and the square on the board, so all that is left here is the
* ROTATION — the one question neither of those can ask, and only for the card actually picked.
*/
const picked =
mode === 'play' && selected !== null ? menu.hand.find((h) => `hand:${h.cardId}` === selected) : undefined;
for (const group of menu.placeable) {
const relevant = group.items.filter((it) => picked && it.subjectKey === picked.placeKey);
if (relevant.length === 0) continue;
html += `<div class="grp"><h3>${esc(group.title)}</h3>`;
for (const item of relevant) {
{
// Once a square is picked, show only that square's rotations — the rest is noise.
const shown = pendingAt
? item.spots.filter((sp) => sp.coord !== null && `${sp.coord.row},${sp.coord.col}` === pendingAt)
: item.spots;
html +=
`<div class="spots"><div class="dim">` +
(pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') +
`</div>` +
shown
.map((sp) => {
// The picture rides on the button that would lay it, so hovering a rotation shows
// that rotation — which is the question "which of these two do I want?" answered.
const fig = sp.links.length
? ` data-tip-html="${esc(piecePreview(sp.links, item.subject))}"`
: '';
return (
`<button class="act" data-i="${sp.index}"${fig}${cellRef(sp.coord)}` +
` data-tip="${esc(`${item.subject} — ${sp.label}`)}">${esc(sp.label)}</button>`
);
})
.join('') +
`</div>`;
}
}
html += `</div>`;
}
if (
f.phaseKey === 'localOps' &&
f.option === 'draw' &&
!menu.options.some((i) => i.type === 'draw.end')
) {
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
const hand = f.handCount;
/**
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
*
* §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled
* train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but
* those has exactly one way forward: play one. The rule creates that corner deliberately and
* needs no machinery, but it must not be a corner the player has to infer from a discard button
* that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says
* what actually refused rather than assuming which of the two rules is in force.
*/
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
const tip = stuck
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
`cannot be thrown away. ${why} The only way on is to play one.`
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' +
'it up.';
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '&quot;')}">` +
(stuck
? `End Local Operations — play a train card first, they cannot be discarded (holding ${hand})`
: `End Local Operations — play or discard down to three first (holding ${hand})`) +
`</button></div>`;
}
el.innerHTML = html;
for (const b of Array.from(el.querySelectorAll('button.act'))) {
const node = b as HTMLElement;
// The crew buttons choose what the board draws; they submit nothing, so they must not fall
// through to `apply` with an undefined index.
const crewId = node.dataset['crew'];
node.onclick = crewId
? () => {
selectedCrew = crewId;
render();
}
: () => apply(Number(node.dataset['i']));
const square = node.dataset['square'];
const route = node.dataset['route'];
if (square) wirePointing(node, square, route ? route.split(' ') : []);
}
}
// ---------------------------------------------------------------------------
// Saving. localStorage only — nothing leaves the browser.
// ---------------------------------------------------------------------------
/**
* Write the game out as a file.
*
* The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few
* hundred bytes, so a finished game can be emailed or dropped on the site's replay directory —
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
// here rather than only trusted to the DOM.
if (!isLocal(session)) return;
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
a.click();
URL.revokeObjectURL(url);
}
function save(): void {
if (!isLocal(session)) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch {
// A full or disabled localStorage must not take the game down with it.
}
}
function load(): Save | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as Save) : null;
} catch {
return null;
}
}
function clearSave(): void {
try {
localStorage.removeItem(SAVE_KEY);
} catch {
/* nothing to do */
}
}
// BOTH stylesheets. The board is SVG built by the shared renderers, and every shape it draws is
// styled by class — without BOARD_CSS each rect falls back to a black fill on a near-black
// background, so the cards are drawn correctly and are simply invisible.
const pageStyle = document.createElement('style');
pageStyle.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(pageStyle);
installTooltips();
const saveBtn = document.getElementById('savefile');
if (saveBtn) saveBtn.onclick = downloadSave;
/**
* Forget the saved game and deal a fresh one.
*
* The only way out used to be finishing the game — `start()` restores from localStorage on every
* load, so a game you no longer wanted followed you across reloads, and the "new game" button
* appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo
* steps back one action at a time, but nothing brings back a game that has been dealt over, and the
* replay download is right beside it.
*
* Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would
* deal the same game again and look like the button had done nothing.
*/
const multiplayerBtn = document.getElementById('multiplayer');
if (multiplayerBtn) {
multiplayerBtn.onclick = () => {
// Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same
// reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session.
if (!isLocal(session)) return;
const f = session.view();
const started = f.status === 'active' && (f.day > 1 || f.stage > 1);
if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return;
showScreen('lobby');
runLobby(lobbyHandlers);
};
}
/**
* LEAVE A RUNNING GAME — back to the lobby, seat and token kept.
*
* Reported by Jesse 2026-08-23: a player who has to go had no way out at all. The page re-entered
* the same game on every load, and the only thing that ever let go of a session was the game itself
* being destroyed. Leaving does NOT give up the seat: `lobby-and-sessions.md` §5 keeps it and the
* table waits, which is the design — nothing moves on an absent player's behalf. The token is kept
* too, so the game can be re-entered from "Games you are in"; forgetting it is a separate, deliberate
* act on that list, because the token is the only proof of who you are.
*/
const leaveBtn = document.getElementById('leavegame');
if (leaveBtn) {
leaveBtn.onclick = () => {
if (isLocal(session)) return;
const f = session.view();
const code = gameCode === '' ? 'this game' : gameCode;
if (!confirm(`Leave ${code} (Day ${f.day}, Stage ${f.stage})? Your seat is kept and the game waits for you.`)) return;
// Stop listening before leaving the screen, so the table sees the seat go quiet rather than
// being told somebody is present who is not.
session.close?.();
closeHandoff();
showScreen('lobby');
$('presence').textContent = '';
runLobby(lobbyHandlers);
notice(`You left ${code}. It is still yours — rejoin it under "Games you are in" whenever you like.`);
};
}
/**
* ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small
* glue between them (which type is currently selected, what its note says, how Days feeds the
* floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea
* "let the user choose their options like the start of a multiplayer game", 2026-08-29) both need
* an identical copy of this — factored out once so the two cannot drift apart the way the rules
* block itself already had before `settings-form.ts` existed to stop it.
*
* PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY
* (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire
* defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the
* seam that lets each caller do its own version of "what do these fields show at first paint"
* without this function having to guess which one it is wiring.
*/
type WiredGameType = {
form: SettingsForm;
days(): number;
refresh(): void;
/** The common case: prefill straight from a named type's own defaults, then repaint. */
selectPreset(name: PresetName): void;
/** The dialog's case: the caller writes the form itself (from a live game), then calls `refresh`
* — this only sets which type that write should be compared against. */
setBase(name: PresetName, type: GameType): void;
};
function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(`${prefix}${id}`) as T;
const form = settingsForm(prefix);
let base: PresetName = 'solitaire';
let type: GameType = 'solitaire';
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
let floorTyped = false;
const days = (): number => {
const raw = Number(field<HTMLInputElement>('days').value);
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
};
const typeRadios = (): HTMLInputElement[] =>
Array.from(root.querySelectorAll<HTMLInputElement>(`input[name="${prefix}type"]`));
function refresh(): void {
const differing = form.mark(base, 1, days());
if (differing.length > 0) type = 'custom';
else if (type === 'custom') type = base;
for (const r of typeRadios()) r.checked = r.value === type;
const note = field<HTMLElement>('type-note');
note.textContent =
type === 'custom'
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
: preset(type as PresetName).blurb;
}
function selectPreset(name: PresetName): void {
base = name;
type = name;
floorTyped = false;
const values = presetSettings(name, 1, days());
form.write(values, values);
refresh();
}
function setBase(name: PresetName, t: GameType): void {
base = name;
type = t;
floorTyped = false;
}
for (const r of typeRadios()) {
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
if (r.value !== 'solitaire' && r.value !== 'custom') {
r.disabled = true;
const row = r.closest('label');
if (row && !row.querySelector('.lb-why')) {
row.classList.add('disabled');
const note = document.createElement('span');
note.className = 'lb-why';
note.textContent = ' — use the Multiplayer button; a table needs a server';
row.querySelector('span')?.appendChild(note);
}
}
r.onchange = () => {
if (!r.checked) return;
if (r.value === 'custom') {
type = 'custom';
refresh();
return;
}
selectPreset(r.value as PresetName);
};
}
form.onEdit((key) => {
if (key === 'minCombinedRevenue') floorTyped = true;
type = 'custom';
refresh();
});
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
field<HTMLInputElement>('days').oninput = () => {
if (!floorTyped) {
const values = form.read();
const want = presetSettings(base, 1, days());
form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
}
refresh();
};
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
// block still visible, so every screen that offers it reads the same.
form.setEmployeeRotationAvailable(false);
return { form, days, refresh, selectPreset, setBase };
}
/**
* THE COMMIT — reads a wired block's answers and turns them into a URL, the same path `?seed=`
* already took: `start()` reads it back out, so there is exactly one place that turns a URL into a
* game, whichever screen produced it.
*/
function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
const asked = seedFieldValue.trim();
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable both
// mean "surprise me", which is what leaving the box alone plainly asks for.
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
const settings = wired.form.read();
const rules = houseRules({
houseRules: {
startingHand: settings.startingHand,
extraStart: settings.extraStart,
discardTimetabled: settings.discardTimetabled,
revenue: {
passengerPerCoach: settings.passengerPerCoach,
freightPerLoad: settings.freightPerLoad,
trainPerTransit: settings.trainPerTransit,
},
},
});
const victory: NewGameOptions = {
days: Math.max(1, wired.days()),
minCombinedRevenue: settings.minCombinedRevenue,
maxCollisionsPerDay: settings.maxCollisionsPerDay,
maxCollisionsTotal: settings.maxCollisionsTotal,
optionalRules: {
reducedVisibility: settings.reducedVisibility,
// Never on at a table of one, whatever the box says — the control is disabled for the same
// reason, and this is the half that reaches the engine.
employeeRotation: false,
emergencyToolbox: settings.emergencyToolbox,
},
};
clearSave();
const next = rulesToUrl(rules, victory, seed);
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
if (next === location.search) location.reload();
else location.search = next;
}
const newBtn = document.getElementById('newgame');
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
if (newBtn && dlg) {
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
/**
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
*
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
* three optional rules that it did. Every screen now reads `presets.ts` and drives its block
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
* should read as one list, not two.
*/
const ng = wireGameTypeBlock('ng-', dlg);
/**
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
*
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
*
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
* compare against the first is the common case, and re-entering settings each time is how a
* comparison silently stops comparing. Which TYPE that is comes out of the comparison — a game
* dealt at the Solitaire defaults reopens on Solitaire, and one that was tuned reopens on Custom
* with every changed field marked.
*/
newBtn.onclick = () => {
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
if (!isLocal(session)) return;
const local = session;
const f = local.view();
const day = f.day;
const started = f.status === 'active' && (day > 1 || f.stage > 1);
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
field<HTMLInputElement>('ng-seed').value = '';
field<HTMLInputElement>('ng-days').value = String(f.days);
ng.setBase('solitaire', 'solitaire');
// The rules actually in play, then the comparison decides what to call them.
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
ng.refresh();
dlg.showModal();
};
/**
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
*/
dlg.addEventListener('close', () => {
if (dlg.returnValue !== 'deal') return;
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
});
}
/**
* THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is
* already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their
* options like the start of a multiplayer game"; "asking first is the only path").
*
* Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs,
* there is no saved game and no URL already carrying a deal's answers. It opens on the plain
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
*/
function runSolitaireSetup(params: URLSearchParams): void {
const screen = document.getElementById('solitairesetup');
const dealBtn = document.getElementById('ss-deal');
if (!screen || !dealBtn) return;
const ss = wireGameTypeBlock('ss-', screen);
// A `?seed=` with no other rules params still means SOMETHING — a shared or bookmarked link
// naming a specific deal — so it is honoured as a prefill rather than discarded because this
// visit happened to be routed through the screen that now asks first.
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
if (seedField) seedField.value = params.get('seed') ?? '';
ss.selectPreset('solitaire');
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
}
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
const zoomLabel = document.getElementById('zoomlabel');
if (zoomOutBtn && zoomInBtn && zoomLabel) {
const paintZoom = (): void => {
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
zoomOutBtn.disabled = zoom <= ZOOM_LEVELS[0]!;
zoomInBtn.disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!;
};
const setZoom = (level: number): void => {
zoom = level;
saveSettings({ zoom });
paintZoom();
// Re-apply to whichever boards are already on the page — no full re-render needed, this is
// display-only sizing, same as `render()`'s own calls after each innerHTML assignment.
applyZoom($('division'));
applyZoom($('grid'));
};
zoomOutBtn.onclick = () => {
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
if (i > 0) setZoom(ZOOM_LEVELS[i - 1]!);
};
zoomInBtn.onclick = () => {
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
if (i >= 0 && i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i + 1]!);
};
paintZoom();
}
const soundBtn = document.getElementById('sound');
if (soundBtn) {
const paint = (): void => {
soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
};
soundBtn.onclick = () => {
soundOn = !soundOn;
saveSettings({ soundOn });
paint();
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
// doubles as the user gesture the browser needs before any audio may start.
if (soundOn) playCue('stage');
};
paint();
}
start();