v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
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.
This commit is contained in:
+263
-37
@@ -12,21 +12,89 @@ 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 { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.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 { LocalSession } from './session.ts';
|
||||
import { createLocalSession } from './session.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;
|
||||
|
||||
/**
|
||||
* The game, behind the Session boundary.
|
||||
*
|
||||
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
|
||||
* new-game — all of which are local-only. The parts that draw and submit go through the plain
|
||||
* `Session` surface, which is what a remote client will provide unchanged.
|
||||
* 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.
|
||||
*/
|
||||
let session: LocalSession;
|
||||
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;
|
||||
/**
|
||||
@@ -49,18 +117,20 @@ let pendingAt: string | null = null;
|
||||
*
|
||||
* '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.
|
||||
* 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' = 'auto';
|
||||
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
||||
/**
|
||||
* Sound, OFF until asked for.
|
||||
* 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 — and a playtester who did not ask for noise should not get any.
|
||||
* One click in the title bar turns it on, and that click is also the gesture browsers require
|
||||
* before any audio may start.
|
||||
* 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 = false;
|
||||
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.
|
||||
*
|
||||
@@ -99,6 +169,23 @@ const $ = (id: string): 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.
|
||||
*
|
||||
@@ -153,19 +240,27 @@ function renderHouseRules(rules: HouseRules): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
|
||||
* 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. 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()`.
|
||||
* 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 rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
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;
|
||||
@@ -178,29 +273,62 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
|
||||
}
|
||||
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
|
||||
return rules;
|
||||
|
||||
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, seed: string): string {
|
||||
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 requested = params.get('seed');
|
||||
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);
|
||||
session = createLocalSession(seed, rulesFromUrl(params));
|
||||
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) session.restore(saved);
|
||||
if (saved && requested === null) local.restore(saved);
|
||||
|
||||
applyCapabilities();
|
||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||
@@ -263,11 +391,14 @@ function render(): void {
|
||||
const obj = $('objective');
|
||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
obj.className = 'pace';
|
||||
$('seed').textContent = String(session.seed());
|
||||
// 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.
|
||||
@@ -302,6 +433,7 @@ function render(): void {
|
||||
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.
|
||||
@@ -593,14 +725,18 @@ function render(): void {
|
||||
*/
|
||||
function renderUndo(): void {
|
||||
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||
if (!btn || !session.capabilities.undo) return;
|
||||
const n = session.steps();
|
||||
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 (!session.undo()) return;
|
||||
if (!local.undo()) return;
|
||||
selected = null;
|
||||
mode = null;
|
||||
pendingAt = null;
|
||||
@@ -657,6 +793,7 @@ function renderDistrict(f: Frame): void {
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -980,6 +1117,10 @@ function renderActions(
|
||||
* 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);
|
||||
@@ -991,7 +1132,7 @@ function downloadSave(): void {
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!session.capabilities.saveLocal) return;
|
||||
if (!isLocal(session)) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
|
||||
} catch {
|
||||
@@ -1043,32 +1184,79 @@ 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';
|
||||
|
||||
/**
|
||||
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
|
||||
* 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 four settings each time is how a
|
||||
* comparison silently stops comparing.
|
||||
* 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 = () => {
|
||||
const f = session.view();
|
||||
// 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 ${session.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
|
||||
const current = session.view().houseRules;
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -1078,6 +1266,8 @@ if (newBtn && dlg) {
|
||||
*
|
||||
* 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;
|
||||
@@ -1097,9 +1287,15 @@ if (newBtn && dlg) {
|
||||
},
|
||||
},
|
||||
});
|
||||
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, seed);
|
||||
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.
|
||||
@@ -1108,6 +1304,35 @@ if (newBtn && dlg) {
|
||||
});
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -1115,6 +1340,7 @@ if (soundBtn) {
|
||||
};
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user