Three things off the first proper look at a live table.
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right inside —
it indexes seating, the seats array and every route, and none of that
changes — but nobody sitting at a table calls their chair "seat 0". There
were four of these rather than one: the lobby list, the topline's Seat N
for a remote session, the presence banner's fallback name, and the admin
summary's. All go through a single seatLabel now, and a test fails the
build if any "Seat ${...}" interpolates a raw seat again, since the
conversion has to happen in exactly one place or the two conventions drift
apart. Verified by mutation — putting the raw seat back fails the suite.
seatLabel lives in sim/view.ts, not web/game.ts. Putting it in game.ts was
the first attempt and test/session.test.ts caught it: the page may not
import values from that module, because they are the local engine by
another name and importing one reopens the Phase 1 boundary. The test was
right and the placement was wrong.
.bs-name.bs-turn carried font-weight:700 over a base of 600. At 11px a
monospace face has to be synthesised the rest of the way and the extra ink
lands as blur, so the one name you most need to read was the one you could
not. The bump is gone; amber against #e6e9ee was always doing the work, and
blue "(you)" and amber "their move" stay clearly distinct without it.
The west-to-east chain under the Division map now shows only during Day 1
Stage 1. It 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 Stage 2 the map has been answering it for a while and the line is
something to read past.
675 tests pass (673 + 2).
1507 lines
68 KiB
TypeScript
1507 lines
68 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, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
|
import { playCue } from './sound.ts';
|
|
import {
|
|
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
|
DEFAULT_MAX_COLLISIONS_TOTAL,
|
|
MOVES_PER_LOCAL_OPS,
|
|
STARTING_HAND_LABELS,
|
|
collectiveRevenueFloor,
|
|
houseRules,
|
|
} from '../engine/content.ts';
|
|
import type { 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 { runLobby } from './lobby.ts';
|
|
import type { LobbyReady } from './lobby.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;
|
|
/**
|
|
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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);
|
|
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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 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)));
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string {
|
|
const params = new URLSearchParams();
|
|
if (seed !== '') params.set('seed', seed);
|
|
params.set('hand', rules.startingHand);
|
|
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));
|
|
}
|
|
return `?${params}`;
|
|
}
|
|
|
|
function loadRemote(): LobbyReady | null {
|
|
try {
|
|
const raw = localStorage.getItem(REMOTE_KEY);
|
|
return raw ? (JSON.parse(raw) as LobbyReady) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** 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 {
|
|
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
|
|
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.
|
|
$('presence').textContent = '… connecting to the game';
|
|
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 {
|
|
localStorage.removeItem(REMOTE_KEY);
|
|
showScreen('lobby');
|
|
$('presence').textContent = '';
|
|
runLobby(beginRemote);
|
|
const note = document.getElementById('lb-create-err');
|
|
if (note) {
|
|
note.textContent =
|
|
'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);
|
|
|
|
// 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) {
|
|
beginRemote(remembered);
|
|
return;
|
|
}
|
|
|
|
// The splash's "Play multiplayer" door (index.html) lands here — straight into the lobby,
|
|
// rather than dealing a solitaire game first and leaving the player to find the in-game
|
|
// Multiplayer button themselves.
|
|
if (params.get('lobby') !== null) {
|
|
showScreen('lobby');
|
|
runLobby(beginRemote);
|
|
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, 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 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 away = session
|
|
.presence()
|
|
.filter((p) => !p.connected)
|
|
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${seatLabel(p.seat)}`);
|
|
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
|
|
}
|
|
|
|
function render(): void {
|
|
const f = session.view();
|
|
const menu = session.menu();
|
|
|
|
// 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())}`;
|
|
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');
|
|
log.innerHTML = session.lines()
|
|
.slice(-60)
|
|
.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;
|
|
|
|
/**
|
|
* 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) {
|
|
const el = $('announce');
|
|
el.textContent = announcement;
|
|
el.className = 'shown';
|
|
window.setTimeout(() => {
|
|
if (el.textContent === announcement) el.className = '';
|
|
}, 4200);
|
|
}
|
|
|
|
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;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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="§7 — 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="§6.1 — 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 §7 does not allow passing 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="§8.2 — 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;
|
|
html +=
|
|
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — 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.">` +
|
|
`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(beginRemote);
|
|
};
|
|
}
|
|
|
|
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;
|
|
type Mode = 'solitaire' | 'competitive' | 'coop';
|
|
|
|
/**
|
|
* PICKING A TYPE JUST SETS THE FIELDS BELOW TO THAT TYPE'S DEFAULTS (Jesse's design, 2026-08-20) —
|
|
* every number stays editable afterward, so "Competitive" isn't a fixed ruleset, it's a starting
|
|
* point. `players = 4` for Competitive/Co-op is a nominal stand-in: there's no lobby yet to ask who
|
|
* is actually seated (Phase 4), so this is a suggestion a real seat count will replace.
|
|
*
|
|
* Only Solitaire can be dealt today — Deal disables itself for the other two, with a note, rather
|
|
* than pretending a click would do something (`RemoteSession` is Phase 2).
|
|
*/
|
|
function applyModePreset(mode: Mode): void {
|
|
const days = 5;
|
|
const players = mode === 'solitaire' ? 1 : 4;
|
|
field<HTMLInputElement>('ng-days').value = String(days);
|
|
field<HTMLInputElement>('ng-minrev').value = String(collectiveRevenueFloor(players, days));
|
|
field<HTMLInputElement>('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY);
|
|
field<HTMLInputElement>('ng-coltotal').value = String(DEFAULT_MAX_COLLISIONS_TOTAL);
|
|
|
|
// No valid target for these cards in Solitaire or Co-op — forced off, not merely defaulted off.
|
|
const pvp = field<HTMLInputElement>('ng-pvp');
|
|
pvp.checked = mode === 'competitive';
|
|
pvp.disabled = mode !== 'competitive';
|
|
|
|
field<HTMLButtonElement>('ng-deal').disabled = mode !== 'solitaire';
|
|
field<HTMLElement>('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible';
|
|
}
|
|
|
|
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
|
input.onchange = () => applyModePreset(input.value as Mode);
|
|
}
|
|
|
|
/**
|
|
* 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. Mode always reopens on Solitaire — it's the only one a
|
|
* previous session could actually have been, since Deal is disabled for the other two.
|
|
*/
|
|
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;
|
|
|
|
const current = f.houseRules;
|
|
field<HTMLInputElement>('ng-seed').value = '';
|
|
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
|
input.checked = input.value === 'solitaire';
|
|
}
|
|
applyModePreset('solitaire');
|
|
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
|
input.checked = input.value === current.startingHand;
|
|
}
|
|
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
|
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
|
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
|
// Overwrite the preset with the actual rules in play — solitaire is the only real session today.
|
|
field<HTMLInputElement>('ng-days').value = String(f.days);
|
|
field<HTMLInputElement>('ng-minrev').value = String(f.minCombinedRevenue);
|
|
field<HTMLInputElement>('ng-colday').value = String(f.maxCollisionsPerDay);
|
|
field<HTMLInputElement>('ng-coltotal').value = String(f.maxCollisionsTotal);
|
|
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.
|
|
* Deal is disabled whenever the mode radio isn't Solitaire, so this never actually runs for the
|
|
* other two — nothing here needs to branch on mode.
|
|
*/
|
|
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 picked = dlg.querySelector<HTMLInputElement>('input[name="ng-hand"]:checked')?.value;
|
|
const rules = houseRules({
|
|
houseRules: {
|
|
...(STARTING_HAND_LABELS.some((o) => o.value === picked) ? { startingHand: picked as StartingHand } : {}),
|
|
revenue: {
|
|
passengerPerCoach: Number(field<HTMLInputElement>('ng-passenger').value),
|
|
freightPerLoad: Number(field<HTMLInputElement>('ng-freight').value),
|
|
trainPerTransit: Number(field<HTMLInputElement>('ng-transit').value),
|
|
},
|
|
},
|
|
});
|
|
const victory: NewGameOptions = {
|
|
days: Math.max(1, Math.round(Number(field<HTMLInputElement>('ng-days').value)) || 5),
|
|
minCombinedRevenue: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-minrev').value)) || 0),
|
|
maxCollisionsPerDay: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-colday').value)) || 0),
|
|
maxCollisionsTotal: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-coltotal').value)) || 0),
|
|
};
|
|
|
|
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();
|