Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
1353 lines
62 KiB
TypeScript
1353 lines
62 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 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';
|
|
|
|
const SAVE_KEY = 'station-master.save.v1';
|
|
const SETTINGS_KEY = 'station-master.settings.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}`;
|
|
}
|
|
|
|
/**
|
|
* `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and
|
|
* URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from
|
|
* whatever the server was configured with, not from this browser's URL or `localStorage`.
|
|
*/
|
|
function start(): void {
|
|
const params = new URLSearchParams(location.search);
|
|
const seatParam = params.get('seat');
|
|
|
|
if (seatParam !== null) {
|
|
const seat = Number(seatParam) as PlayerIndex;
|
|
session = createRemoteSession(seat, params.get('secret') ?? '');
|
|
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);
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
$('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 ${session.seat()}`;
|
|
renderHouseRules(f.houseRules);
|
|
|
|
// -- division
|
|
$('division').innerHTML = divisionSvg(f.division);
|
|
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 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();
|