Files
station-master/src/web/main.ts
T
Jesse.MarkowitzandClaude Opus 5 441447648d v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away
Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for
every item, and what was verified how: CHANGELOG.md.

- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it
  out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded,
  white is empty" doubles as a piece count in the supply table; one read of the flag took that
  literally. A caboose carries the crew, not freight, so it is never a load.
- Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click
  and the next — and both transient signals fade before a player reading the board notices. A modal
  stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the
  first frame, on Undo stepping back across a rollover, and on the Day the game ends.
- Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up
  to a Department slot, where a rival may pick it up — the second half of the ruling needed no code,
  since that is where every discard already goes. An Extra still may not. A New Game setting on this
  line (discardTimetabled, on by default), the plain rule on the 0.4.9 line.
- Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is
  Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A
  blocked platform now gives its reason, from the engine's own predicate, including how many coaches
  are stranded in Classification and what brings them back.

The same four ship as v0.4.9g on the playtest line.

Closes #2
Closes #8
Closes #9
Closes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
2026-08-25 10:46:22 -04:00

2018 lines
91 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, 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';
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 two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
* ever flashes before `start()` decides which one this load actually needs. */
function showScreen(which: 'lobby' | 'gameui'): void {
document.getElementById('lobby')!.hidden = which !== 'lobby';
document.getElementById('gameui')!.hidden = which !== 'gameui';
}
/**
* 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;
}
// 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 = 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;
}
showScreen('gameui');
const requested = params.get('seed');
// 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;
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
const saved = load();
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');
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} 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;
}
function renderActions(
menu: Menu,
f: Frame,
justSet: number | null,
): void {
const el = $('actions');
if (f.status !== 'active') {
const o = f.outcome;
el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
`<button id="again">new game</button>`;
$('again').onclick = () => {
clearSave();
location.search = '';
};
return;
}
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.`);
};
}
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;
const ngForm = settingsForm('ng-');
/**
* 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. Both screens now read `presets.ts` and drive their 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.
*/
let ngBase: PresetName = 'solitaire';
let ngType: GameType = 'solitaire';
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
let ngFloorTyped = false;
const ngDays = (): number => {
const raw = Number(field<HTMLInputElement>('ng-days').value);
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
};
const ngTypeRadios = (): HTMLInputElement[] =>
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
function ngRefresh(): void {
const differing = ngForm.mark(ngBase, 1, ngDays());
if (differing.length > 0) ngType = 'custom';
else if (ngType === 'custom') ngType = ngBase;
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
const note = field<HTMLElement>('ng-type-note');
note.textContent =
ngType === 'custom'
? `${gameTypeLabel('custom', preset(ngBase).scoring)} · ${differing.length} ` +
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(ngBase).label}.`
: preset(ngType as PresetName).blurb;
}
function ngSelectPreset(name: PresetName): void {
ngBase = name;
ngType = name;
ngFloorTyped = false;
const values = presetSettings(name, 1, ngDays());
ngForm.write(values, values);
ngRefresh();
}
for (const r of ngTypeRadios()) {
// 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') {
ngType = 'custom';
ngRefresh();
return;
}
ngSelectPreset(r.value as PresetName);
};
}
ngForm.onEdit((key) => {
if (key === 'minCombinedRevenue') ngFloorTyped = true;
ngType = 'custom';
ngRefresh();
});
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
field<HTMLInputElement>('ng-days').oninput = () => {
if (!ngFloorTyped) {
const values = ngForm.read();
const want = presetSettings(ngBase, 1, ngDays());
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
}
ngRefresh();
};
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
// block still visible, so the two screens read the same.
ngForm.setEmployeeRotationAvailable(false);
/**
* 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);
ngBase = 'solitaire';
ngType = 'solitaire';
ngFloorTyped = false;
// The rules actually in play, then the comparison decides what to call them.
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
ngForm.setEmployeeRotationAvailable(false);
ngRefresh();
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.
*
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
*/
dlg.addEventListener('close', () => {
if (dlg.returnValue !== 'deal') return;
const asked = field<HTMLInputElement>('ng-seed').value.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 = ngForm.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, ngDays()),
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 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();