/** * Browser entry point — wires the DOM to a `Session`. * * Presentation only. Every question of what is legal, what it means, or what the board looks like * is answered by the engine or by the shared view helpers. */ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import type { Frame } from '../sim/view.ts'; import { seatLabel } from '../sim/view.ts'; import type { Menu, Save } from './game.ts'; import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { playCue } from './sound.ts'; import { MOVES_PER_LOCAL_OPS, EXTRA_START_LABELS, STARTING_HAND_LABELS, houseRules, } from '../engine/content.ts'; import type { ExtraStartRule, HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts'; import type { NewGameOptions } from './game.ts'; import type { LocalSession, Session } from './session.ts'; import { createLocalSession, createRemoteSession } from './session.ts'; import type { PlayerIndex } from '../engine/state.ts'; import type { PublicDistrict } from '../sim/view.ts'; import { actorOnScreen, createStepQueue } from './step-queue.ts'; import { PACE_LEVELS } from '../sim/pacing.ts'; import { notice, postJson, prefillCode, runLobby } from './lobby.ts'; import type { LobbyReady } from './lobby.ts'; import { closestPreset, configFromFrame, gameTypeLabel, preset, presetOf, presetSettings, settingsOf, } from './presets.ts'; import type { GameType, PresetName } from './presets.ts'; import { rulesListHtml, settingsForm } from './settings-form.ts'; import type { SettingsForm } from './settings-form.ts'; const SAVE_KEY = 'station-master.save.v1'; const SETTINGS_KEY = 'station-master.settings.v1'; /** * The multiplayer session — `lobby-and-sessions.md` §1's token, plus the `gameId`/`seat` a fresh * `createRemoteSession` needs without waiting on a push to learn its own seat. Separate from * `SAVE_KEY`: a solitaire save is the seed plus intents and is meant to be portable between * browsers; this is a credential for THIS origin's server and must never be treated as one. */ const REMOTE_KEY = 'station-master.remote.v1'; /** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */ const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const; /** * Small persisted preferences, kept in a `localStorage` key of their own — separate from * `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs * in it, and in a multiplayer game two players may reasonably want these set differently. Grows as * more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the * first three. */ type Settings = { districtMode: 'auto' | 'open' | 'closed'; soundOn: boolean; zoom: number; /** * Is the This Game card open? (TODO #28.) Folded by default: it answers "what did we set that * to?", which Jesse's own framing says is "not something they're likely to need all the time". */ gameCardOpen: boolean; /** * HOW FAST OTHER PEOPLE'S TURNS PLAY BACK — v0.8.0, TODO #13/#18. A multiplier over the dwell * table in `sim/pacing.ts`: 1 is as tabled, 0.5 is twice as fast, and **0 turns animation off**, * which is TODO #18's "a player who has seen it a hundred times will want it off" without a second * mechanism for it. * * Here rather than in the game's config, on Jesse's call 2026-09-09: dwell is presentation, not a * rule, and a `GameConfig` rides along in saves and replays. It is also per-viewer for the reason * this whole object exists — two players at one table may reasonably want different speeds. */ pace: number; }; const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false, pace: 1 }; /** * `?pace=` — a per-session override that persists nothing. * * The third of the three tuning levels the design calls for (`docs/plans/jitsi-common-board.md` * § v0.8.0 § 5): the committed table needs a rebuild, the setting needs a click, and this needs a * link — which is what makes it the one that is actually useful at a playtest, where two testers can * be handed different speeds and compared. Follows `?seed=`, which is already the convention here. * * Read ONCE, at load. The queue asks for the pace on every step it measures, and `behind()` asks for * every step still queued — so parsing the query string in there meant building a `URLSearchParams` * a hundred times to render one row. It cannot change without a reload anyway. */ const PACE_OVERRIDE: number | null = (() => { try { const raw = new URLSearchParams(location.search).get('pace'); if (raw === null) return null; const n = Number(raw); return Number.isFinite(n) && n >= 0 ? n : null; } catch { return null; } })(); function loadSettings(): Settings { try { const raw = localStorage.getItem(SETTINGS_KEY); const parsed = raw ? (JSON.parse(raw) as Partial) : {}; // 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, gameCardOpen: typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen, // A negative or non-finite saved value is corrupt, not a request to run time backwards. pace: typeof parsed.pace === 'number' && Number.isFinite(parsed.pace) && parsed.pace >= 0 ? parsed.pace : DEFAULT_SETTINGS.pace, }; } 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): 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 ANIMATION QUEUE — v0.8.0, TODO #13/#15/#18. * * Holds the board the screen is showing, which is not always the board the game is on. One queue * for both session kinds: solitaire drains its own collector and a remote session reads the same * steps off the wire, and this cannot tell which it has (`web/step-queue.ts`). * * Reads `pace` through a function rather than a captured value, so changing the setting takes effect * on the next step instead of the next game. `?pace=` wins over the saved setting for this session * only. */ const stepQueue = createStepQueue( () => PACE_OVERRIDE ?? settings.pace, // Whose moves not to bother replaying — this client's own. Read lazily: `session` is assigned when // a game starts, long after this queue is built. () => (session ? session.seat() : null), ); /** * Pulls whatever the session has for us into the queue. Called on every push, before rendering. * * A RESET IS TAKEN FIRST AND SEPARATELY: it means "start over from this board", so applying it after * the steps that arrived with it would draw them onto a baseline they do not chain from. */ function drainIntoQueue(): void { const reset = session.takeDisplayReset(); if (reset) stepQueue.reset(reset); stepQueue.push(session.takeDisplaySteps()); /** * NOWHERE TO ANIMATE MEANS DO NOT QUEUE AT ALL. * * Without `requestAnimationFrame` nothing ever advances the queue, so `busy()` would stay true for * good — and since "Your Move" is now put away while the board is catching up, that would hide a * player's own actions permanently, leaving Skip as the only way to play the game. Drawing * everything at once is exactly what `pace = 0` does deliberately, so that is the honest fallback * rather than a broken page. Caught by `test/web.test.ts`, whose DOM stub has no `rAF` — the same * stub that has been proving this page still starts since long before any of this existed. */ if (typeof requestAnimationFrame !== 'function') { stepQueue.skip(); return; } if (stepQueue.busy()) startAnimationLoop(); } /** * WHOSE DISTRICT THE BOARD IS SHOWING — v0.8.0, TODO #13. Null means "your own", drawn exactly as * it always was. * * FOLLOW THE ACTOR (Jesse, 2026-09-09). While the queue is animating, follow the step being shown, * so a bot's switching turn is watched on the bot's board. At rest, follow whoever the game is * waiting on — which is how you watch a human opponent work in something close to real time, since * their steps trickle in as they click rather than arriving in a burst. * * `Frame.cells` is the VIEWER'S district and nobody else's, which is the whole reason a step stream * alone could not answer #13: the data would arrive with nowhere to be drawn. This is where it gets * drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and * has never needed a private viewer. */ function renderWatching(f?: Frame): void { const behind = stepQueue.behind(); const row = $('watching'); /** * VISIBLE WHILE THE BOARD IS BEHIND **OR** STILL SHOWING SOMETHING. * * It used to hide the moment `behind` hit zero — which is the moment the LAST step of a burst goes * up, so the one step a player was most likely to be reading about lost its caption. Collapsed * otherwise: in solitaire that is nearly always, and between turns in multiplayer too, and a row * that is always there is a row nobody reads. */ if (behind === 0 && !stepQueue.busy()) { row.hidden = true; return; } row.hidden = false; // A held queue stops counting down, so the counter has to say why rather than look stuck. $('watching-behind').textContent = (behind === 0 ? 'catching up' : `${behind} behind`) + (stepQueue.paused() ? ' · paused' : ''); /** * THE CAPTION IS #15, and this is where that item lands rather than as a line of its own. * * TODO Reference · #15 could not decide the unit — "most recent action" is right in solitaire and * wrong in multiplayer, where what you missed is everything that happened while you were waiting. * The queue IS that, so the caption simply names the step being shown, and the counter beside it * says how much of the wait is left. */ /** * WHO, THEN WHAT — Jesse, 2026-09-09: *"it didn't tell me what the actual action was, like who I * was waiting on or what they were doing. I knew I was behind, but I wasn't sure what I was * supposed to be looking for."* * * The caption was there; it was the wrong half of the sentence. Half the waiting is automatic * phases, whose narration reads "Mainline" — accurate, and no answer at all to "who am I waiting * on". So the name goes first, and a phase says so in as many words rather than leaving the reader * to infer that nobody is acting. * * The narrated line is used as it stands otherwise, because `record()` already prefixes it with the * player — "Player Bot 1 moved Train 3 (−1,−2) → (−1,1)" — so a second name would stutter. */ const showing = stepQueue.showing(); const said = showing?.lines[0]?.text ?? ''; const who = showing === null || showing === undefined ? '' : showing.player === null ? 'The Division' : (f?.players[showing.player]?.name ?? `Seat ${seatLabel(showing.player)}`); // A player action already names its actor; a phase does not, so it is introduced. $('watching-what').textContent = showing?.player === null && said !== '' ? `${who}: ${said}` : said; /** * SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION. * * Every line skipped is already in the History panel — the queue animates a board, it does not * carry the record — which is what makes this safe to press without weighing it up. Assigned each * render rather than once, matching how every other button on this page is wired. */ $('watching-skip').onclick = () => { if (stepQueue.skip()) render(); }; /** * PAUSE IS SKIP'S OPPOSITE, and shares its row for that reason. * * The label says what pressing it DOES, so it flips to Resume while held — the same rule the * district's three-mode control settled on, for the same reason: a label that reports state reads * as a status line and gets skipped over. * * `performance.now()` because that is the clock `requestAnimationFrame` hands `advance()`; mixing * in `Date.now()` would shift the deadline by the page's whole lifetime. Guarded because the * static build is loaded head-first against a DOM stub with no `performance`. */ const pauseBtn = $('watching-pause'); pauseBtn.textContent = stepQueue.paused() ? 'Resume' : 'Pause'; pauseBtn.onclick = () => { const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (stepQueue.paused()) { stepQueue.resume(now); // The loop exits whenever the queue stops being busy; restart it rather than assume it survived. startAnimationLoop(); } else { stepQueue.pause(now); } render(); }; } /** * THE TABLE AS IT IS ON SCREEN — the Day, the Stage, the clock, the phase and the Fedora. * * "WHEN PLAYER JESSE IS 5 BEHIND, IT SHOULD ALWAYS LOOK LIKE HE'S 5 BEHIND" (playtest, 2026-09-16). * The board, the district panel and the caption row have followed the queue since v0.8.0; the turn * chart never did. So a player watching three bots play out a Stage saw their cards moving under a * chart that had already ticked over to the next phase — the one part of the screen quietly * insisting the game was somewhere else. Being behind is fine and is stated plainly by the counter; * being behind on some of the screen and level on the rest is what makes it unreadable. * * Only while the queue is actually behind. At rest this IS the live frame, so nothing downstream * needs to know which of the two it was handed. */ function shownTable(f: Frame): Pick { const pub = stepQueue.current(); if (!pub || !stepQueue.busy()) return f; return { day: pub.day, stage: pub.stage, clock: pub.clock, phase: pub.phase, phaseKey: pub.phaseKey, superintendent: pub.superintendent, }; } function watchedDistrict(f: Frame): PublicDistrict | null { const pub = stepQueue.current(); if (!pub) return null; /** * FOLLOW WHOEVER IS ACTING. While animating that is the step on screen; at rest it is whoever the * game is waiting on. * * A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor, * which keeps the board where it was instead of snapping home mid-sequence. */ // A deliberate look wins over whoever happens to be acting, for this one render (see `peekPlayer`). if (peekPlayer !== null && peekPlayer !== f.viewer) { return pub.districts.find((d) => d.player === peekPlayer) ?? null; } let player: PlayerIndex | null = f.actor; if (stepQueue.busy()) { const acting = stepQueue.showing()?.player; if (acting !== undefined && acting !== null) player = acting; } if (player === null || player === f.viewer) return null; return pub.districts.find((d) => d.player === player) ?? null; } /** * Drives the queue from the browser's own frame clock, ON DEMAND. * * The queue owns no timer of its own — that is what makes it testable without faking one — so * something has to advance it. This runs only while there is a backlog and stops itself when the * board catches up, for two reasons beyond tidiness: * * - **Loops must not accumulate.** `startAnimationLoop` is reachable from both session kinds, and * a player can go lobby → game → lobby → game in one page load. A loop started per game and * never stopped would leave one running per visit, each calling `render()` forever. * - An idle table should do nothing at all. Solitaire between clicks, and multiplayer between * turns, is the common case. * * `requestAnimationFrame` may be absent — the static build is loaded head-first by `test/web.test.ts` * against a DOM stub. Nothing here is required for correctness; without it the board simply arrives * without being animated, which is exactly what `pace = 0` does on purpose. */ let animating = false; function startAnimationLoop(): void { if (animating || typeof requestAnimationFrame !== 'function') return; animating = true; const tick = (now: number): void => { try { if (stepQueue.advance(now)) render(); } catch (err) { /** * A BROKEN QUEUE MUST NOT TAKE THE GAME WITH IT, or wedge itself on. * * `applyPublicDelta` throws when a delta says "unchanged" and there is nothing to merge onto * — a sender/receiver disagreement about what has been delivered. The board is still correct * (the authoritative Frame comes down the same push and is drawn from `session.view()`); only * the animation is lost. Without the flag being cleared here, one throw would leave `animating` * true forever and no later burst would ever play. */ console.error('display queue stopped:', err); animating = false; // Do not strand the player behind a queue that can no longer advance: jump the board to the // live position, which brings "Your Move" back with it. try { stepQueue.skip(); } catch { /* nothing further to try — the authoritative Frame is still what the rest of the page draws */ } render(); return; } if (!stepQueue.busy()) { animating = false; /** * A FULL RENDER, not just the row. The board being level again is what brings "Your Move" * back and clears the last lit pile, so redrawing only the catching-up row would leave the * action list hidden until something else happened to trigger a render — which, when the game * is waiting on this player, is nothing at all. */ render(); return; } requestAnimationFrame(tick); }; requestAnimationFrame(tick); } /** * 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; let gameCardOpen = settings.gameCardOpen; /** * Sound, OFF by default until a player asks for it once — then remembered via `settings`. * * Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio * rather than the finished thing. One click in the title bar turns it on, and that click is also * the gesture browsers require before any audio may start. */ let soundOn = settings.soundOn; /** Preset board zoom (see `ZOOM_LEVELS`), persisted in `settings`. */ let zoom = settings.zoom; /** * The phase the page last drew, so a change of phase can be announced. * * Local Operations ends the moment the last Move is spent, and New Train and Mainline then run * themselves — so a player looking at the board finds themselves in Cargo with no idea their turn * ended or what happened in between. Reported exactly that way. */ let lastPhase: string | null = null; /** * The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10). * * Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page * reloaded mid-game would otherwise announce a rollover that happened before it was watching. */ let lastDay: number | null = null; /** * WHICH CREW THE BOARD IS DRAWING, when the district holds more than one. * * Reported from play: "make it clear which train you are switching — it is possible to have more * than one train available." A train standing on an A/D track while a local shunts is ordinary, and * the board used to highlight whichever crew came first out of the map while the action list offered * every crew's moves under one heading. * * Display state, deliberately not in the game: which train a player is looking at is not a fact * about the railroad, and in a multiplayer game two players may reasonably be looking at different * ones. Cleared whenever the named crew stops being one of the choices. */ let selectedCrew: string | null = null; /** * A ONE-RENDER LOOK AT SOMEBODY ELSE'S OFFICE AREA — Jesse, playtest 2026-09-16. * * Deliberately NOT a mode. It survives exactly the render its own click causes and is cleared at the * end of `renderDistrict`, so the panel is back to following whoever is acting the next time * anything redraws. That is the whole design, in his words: *"if you want to study someone else's * office area, you should do it while it's your turn to move, or put the backlog on pause, then look * at their area, and when you're done looking, resume."* * * A sticky pin would have to answer what happens when the game moves on beneath it — and the honest * answers are all bad: silently snap home, or leave a player staring at a stale board with the game * waiting on them. Pause already means "hold everything", so it is the right lever for a long look, * and this stays a glance. */ let peekPlayer: PlayerIndex | 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>/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 { // The move on screen, not the live one, while the board is still catching up (Gitea#25). const { actor, replaying } = actorOnScreen(stepQueue, f.actor); const actorName = actor === null ? null : (f.players[actor]?.name ?? null); // The Day, Stage and phase of the step being shown, so the whole screen reports one moment. const table = shownTable(f); // Named only at a table with more than one seat: in solitaire the Fedora is always yours, and a // chip that can never change is a chip to read past. const superName = f.players.length > 1 ? (f.players.find((p) => p.index === table.superintendent)?.name ?? null) : null; $('turnchart').innerHTML = turnChartHtml( replaying ? { ...f, ...table, awaiting: null } : f, actorName, superName, ); } /** * The settings this game was dealt under, beside the seed, because the seed alone does not name it. * * Abbreviated to fit a header that must not wrap — `3 cards · 1/1/0` — with the whole of it in the * tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one * thing at 1 Revenue per transit and another at 5. */ function gameCardSummary(f: Frame): string { const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = f.houseRules.revenue; const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' }; const config = configFromFrame(f); const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode); const floor = f.minCombinedRevenue === 0 ? 'no floor' : `floor ${f.minCombinedRevenue}`; return `${type} · ${f.days} Days · ${floor} · ${short[f.houseRules.startingHand]} · ${pax}/${frt}/${trn}`; } /** * THIS GAME — every setting it was dealt under, in a card rather than along the top line (TODO #28). * * Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card * like Facilities, timetable or blocked. Off on the side, we can give complete information about all * the game options and not take up valuable real estate at the top of the screen." And on when it is * read: "To go, 'Oh wait, what did we set that to?' They should be able to look that up, but it does * not need to be at the top every moment." * * NOTHING NEW TRAVELS FOR THIS. `configFromFrame` already turns the Frame's copy of the config back * into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating * screen already draw — so what a player agreed to before the deal and what they can read mid-game * come from ONE implementation and cannot drift. The identity block above it is the half * `rulesListHtml` has no notion of: which seed or seat this is, and what the game is called. * * THE SEED IS SOLITAIRE-ONLY, and that is a redaction rule rather than a layout one: it is never * sent to a remote client at all, because it would leak every future shuffle and roll * (`multiplayer.md` §7). `RemoteSession` has no `.seed()` to call. A seated player gets their seat * instead, which is the thing they actually need to know. */ function renderGameCard(f: Frame): void { const sec = $('gamecard'); sec.classList.toggle('folded', !gameCardOpen); $('gamecardsummary').textContent = gameCardSummary(f); const btn = $('gamecardtoggle'); btn.textContent = gameCardOpen ? 'hide' : 'show'; btn.onclick = () => { gameCardOpen = !gameCardOpen; saveSettings({ gameCardOpen }); render(); }; if (!gameCardOpen) { // Folded: the body is display:none anyway, and rebuilding it every frame is work nobody sees. $('gamecardbody').innerHTML = ''; return; } const config = configFromFrame(f); const players = f.players.length; const type = presetOf(config, players, f.days); const who = isLocal(session) ? `
Seed
${esc(String(session.seed()))}
` : `
Seat
${esc(String(seatLabel(session.seat())))}
`; const code = gameCode === '' ? '' : `
Game code
${esc(gameCode)}
`; $('gamecardbody').innerHTML = `
${who}${code}
Type
${esc(gameTypeLabel(type, f.mode))}
` + rulesListHtml(config, players, f.days); } /** * THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30). * * They stay on the top line while the limits themselves move into the card, because the two are * different kinds of thing: `maxCollisionsPerDay` is a setting you agreed to once, and "2 of 3 * today" is a number that changes how you play the next Stage. The Frame has carried both counts * since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran * invisibly — v0.7.9 made it reachable in solitaire too, which is what made this worth having. * * `0` means the limit is off (the engine's convention), and a half that is off is left out rather * than shown as "1 of 0". With both off the chip is empty, and an empty span collapses. */ function renderCollisions(f: Frame): void { const parts: string[] = []; if (f.maxCollisionsPerDay > 0) parts.push(`${f.collisionsToday} of ${f.maxCollisionsPerDay} today`); if (f.maxCollisionsTotal > 0) parts.push(`${f.collisionsTotal} of ${f.maxCollisionsTotal} total`); const el = $('collisions'); el.textContent = parts.length === 0 ? '' : `collisions ${parts.join(' · ')}`; el.title = parts.length === 0 ? '' : 'Reaching either limit ends the game immediately and results in a loss. Both limits are in ' + 'the This Game card; these are the running counts.'; } /** * THE HOUSE RULES AND VICTORY DIALS TRAVEL IN THE URL, BESIDE THE SEED. * * A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different * railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a * different economy again — and now a game with `minrev=15` is a different game from one with * `minrev=0`. The link has to carry all of it or "same link, same deal" stops being true — and the * New Game dialog navigates by URL, so this is also how its answers reach `start()`. * * Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at * today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`). */ const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const; /** The four victory-condition dials added 2026-08-20 (`GameConfig`), same URL-round-trip convention. */ const VICTORY_PARAMS = { days: 'days', minrev: 'minCombinedRevenue', colday: 'maxCollisionsPerDay', coltotal: 'maxCollisionsTotal', } as const; /** * Appendix B's three, in the URL like everything else the dialog asks (2026-08-23). * * The dialog navigates to a URL and `start()` reads the game back out of it, so a setting missing * from here is a setting the dialog silently discards — which is exactly what happened to these * three for as long as only the lobby offered them. */ const OPTIONAL_PARAMS = { vis: 'reducedVisibility', rot: 'employeeRotation', tool: 'emergencyToolbox', } as const; function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions { const rules: HouseRuleOverrides = {}; const hand = params.get('hand'); if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand; const extra = params.get('extra'); if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule; // §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`. // Read the same way the optional rules are: present and not "0" means on. const toss = params.get('toss'); if (toss !== null) rules.discardTimetabled = toss !== '0'; const revenue: Partial = {}; for (const [param, key] of Object.entries(RULE_PARAMS)) { const raw = params.get(param); // `houseRules()` clamps and rounds, so anything hand-edited into the URL lands in range rather // than dealing a game at 900 Revenue a coach. if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw); } if (Object.keys(revenue).length > 0) rules.revenue = revenue; const options: NewGameOptions = { houseRules: rules }; for (const [param, key] of Object.entries(VICTORY_PARAMS)) { const raw = params.get(param); // Negative or fractional values from a hand-edited URL are clamped the same way `configWith`'s // defaults are — a stray `minrev=-5` should mean "off-ish", not a config the engine never sees. if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) { options[key] = Math.max(0, Math.round(Number(raw))); } } const optional: Partial> = {}; for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) { const raw = params.get(param); // Present and not "0" means on: `?rot=1` and `?rot` alike, since a bare flag reads as ''. if (raw !== null) optional[key] = raw !== '0'; } if (Object.keys(optional).length > 0) options.optionalRules = optional; return options; } /** * THE SOLITAIRE GAME TYPE'S RULES, under whatever the URL actually named. * * `SOLO_CONFIG` is the ENGINE's fallback and stays where it is — every engine test and every sim run * is measured against it, and moving it would silently re-deal all of them. What a PLAYER is dealt * when they open the page is a different question, and its answer is the Solitaire game type * (`presets.ts`) — which since 2026-08-23 opens with six cards, like every other type, so that the * New Game dialog and the lobby agree about what "Solitaire" means. */ function solitaireDefaults(options: NewGameOptions): NewGameOptions { const days = options.days ?? 5; const p = presetSettings('solitaire', 1, days); const revenue = options.houseRules?.revenue ?? {}; return { days, minCombinedRevenue: options.minCombinedRevenue ?? p.minCombinedRevenue, maxCollisionsPerDay: options.maxCollisionsPerDay ?? p.maxCollisionsPerDay, maxCollisionsTotal: options.maxCollisionsTotal ?? p.maxCollisionsTotal, optionalRules: { reducedVisibility: options.optionalRules?.reducedVisibility ?? p.reducedVisibility, // One player, so there is nobody to rotate with whatever a hand-edited URL says. employeeRotation: false, emergencyToolbox: options.optionalRules?.emergencyToolbox ?? p.emergencyToolbox, }, houseRules: { startingHand: options.houseRules?.startingHand ?? p.startingHand, extraStart: options.houseRules?.extraStart ?? p.extraStart, discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled, revenue: { passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach, freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad, trainPerTransit: revenue.trainPerTransit ?? p.trainPerTransit, }, }, }; } function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string { const params = new URLSearchParams(); if (seed !== '') params.set('seed', seed); params.set('hand', rules.startingHand); // The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from // here is a setting the dialog silently discards. params.set('extra', rules.extraStart); // Written only when OFF, for the reason the optional rules are written only when on: this one // defaults to on, so `toss=1` on every link would say nothing and cost a parameter. if (!rules.discardTimetabled) params.set('toss', '0'); for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key])); for (const [param, key] of Object.entries(VICTORY_PARAMS)) { const value = options[key]; if (value !== undefined) params.set(param, String(value)); } for (const [param, key] of Object.entries(OPTIONAL_PARAMS)) { // Only the ones that are ON: a URL that spells out three `=0`s says nothing extra and is three // parameters longer. if (options.optionalRules?.[key] === true) params.set(param, '1'); } return `?${params}`; } /** * WHAT THIS BROWSER REMEMBERS ABOUT A MULTIPLAYER GAME, and when. * * It used to be written only at `Lobby.Start`, which meant a refresh while SEATED — before the host * started — orphaned the chair: the token existed nowhere else, so the player could not return and * the seat could not be freed, and a table that needs every chair filled could no longer start. * The record is written the moment a seat is taken, and `stage` says how far it got. */ type RemoteRecord = { token: string; gameId: string; gameCode: string; /** Only known once the game exists; a lobby-stage record has no seat yet. */ seat?: PlayerIndex; stage: 'lobby' | 'game'; }; /** * EVERY multiplayer game this browser holds a seat in, and which was last played. * * It used to be ONE record under one key, so joining a second game overwrote the first — and since * the token IS the identity (`lobby-and-sessions.md` §1), that seat was then locked out for good. * `TODO.md` had it as "a second, nearer limit" under the lost-token item; Jesse hit it from the * other side, asking how to leave a game and play a different one later. */ type RemoteStore = { games: Record; last: string | null }; function readStore(): RemoteStore { try { const raw = localStorage.getItem(REMOTE_KEY); if (!raw) return { games: {}, last: null }; const parsed = JSON.parse(raw) as Partial & Partial; // The single-record shape written before 2026-08-23 — carried across rather than dropped, so an // update does not throw away the game somebody is in the middle of. if (typeof parsed.token === 'string' && typeof parsed.gameId === 'string') { const one: RemoteRecord = { token: parsed.token, gameId: parsed.gameId, gameCode: parsed.gameCode ?? '', ...(parsed.seat === undefined ? {} : { seat: parsed.seat }), stage: parsed.stage ?? 'game', }; return { games: { [one.gameId]: one }, last: one.gameId }; } const games = parsed.games ?? {}; return { games, last: parsed.last ?? null }; } catch { return { games: {}, last: null }; } } function writeStore(store: RemoteStore): void { try { localStorage.setItem(REMOTE_KEY, JSON.stringify(store)); } catch { // A full or disabled localStorage must not take the game down with it — the session in memory // keeps working, it simply will not survive a reload. } } /** The game to re-enter on a bare page load: the one most recently played. */ function loadRemote(): RemoteRecord | null { const store = readStore(); return store.last === null ? null : (store.games[store.last] ?? null); } function saveRemote(record: RemoteRecord): void { const store = readStore(); store.games[record.gameId] = record; store.last = record.gameId; writeStore(store); } /** Deliberate, and the one irreversible thing on the lobby screen: the token is the only proof of * who you are, so forgetting it gives up the seat with no way back from this browser. */ function forgetRemote(gameId: string): void { const store = readStore(); delete store.games[gameId]; if (store.last === gameId) store.last = null; writeStore(store); } function knownRemote(): RemoteRecord[] { return Object.values(readStore().games); } /** The handlers the lobby drives this page through — one place, since four callers open a lobby. * Storage lives here rather than in `lobby.ts`, which owns the screen and not the browser. */ const lobbyHandlers = { onReady: (ready: LobbyReady): void => beginRemote(ready), onSeated: (s: { token: string; gameId: string; gameCode: string }): void => saveRemote({ ...s, stage: 'lobby' }), onLeft: (gameId?: string): void => { const target = gameId ?? readStore().last; if (target !== null && target !== undefined) forgetRemote(target); }, known: (): { gameId: string; gameCode: string; stage: 'lobby' | 'game' }[] => knownRemote().map((r) => ({ gameId: r.gameId, gameCode: r.gameCode, stage: r.stage })), forget: (gameId: string): void => forgetRemote(gameId), rejoin: (gameId: string): void => { const record = readStore().games[gameId]; if (!record) return; if (record.stage === 'game' && record.seat !== undefined) { beginRemote({ ...record, seat: record.seat }, true); return; } // Still seated in a lobby that had not started: the stream puts us back on the seating screen, // and its own probe handles a game that began while we were away. showScreen('lobby'); runLobby(lobbyHandlers, { token: record.token, gameId: record.gameId, gameCode: record.gameCode }); }, }; /** * THE GAME CODE THIS PAGE IS IN, once it is in one. * * It ended at the lobby door before 2026-08-23 — `LobbyReady` carried the token, the game id and the * seat, and the code (the only one of the four a person can read out) was dropped. A seated player * could not say which game they were in, match it against the administrator's Games in Progress * list, or pass it to a latecomer. Empty in solitaire, where there is no code. */ let gameCode = ''; /** How long the handoff curtain holds, so the start of a game is a moment rather than a snap. */ const HANDOFF_BEAT_MS = 1500; /** How long to wait before saying the board has not arrived. */ const HANDOFF_STALL_MS = 8000; let handoffOpenedAt = 0; let handoffStall: number | null = null; let firstFrameSeen = false; /** Set when this page entered a game it was already seated in, so the first frame says so. */ let rejoiningRemote = false; function setText(id: string, text: string): void { const el = document.getElementById(id); if (el) el.textContent = text; } /** The curtain between `Lobby.Start` and the first Frame — see `#handoff` in `play.html`. */ function openHandoff(): void { const el = document.getElementById('handoff'); if (!el) return; firstFrameSeen = false; handoffOpenedAt = Date.now(); el.classList.add('shown'); setText('handoff-title', 'Dealing the railroad…'); setText('handoff-note', 'Laying out the Division and rolling for seats.'); if (handoffStall !== null) clearTimeout(handoffStall); handoffStall = window.setTimeout(() => { if (firstFrameSeen) return; setText('handoff-title', 'Still waiting for the server'); setText( 'handoff-note', 'The game exists, but its board has not arrived yet. It will appear as soon as the server sends it.', ); }, HANDOFF_STALL_MS); } function closeHandoff(): void { document.getElementById('handoff')?.classList.remove('shown'); if (handoffStall !== null) clearTimeout(handoffStall); handoffStall = null; } /** Flash a one-line announcement over the board. Shared by the session's own announcements and by * the "the game has begun" line, which comes from the page rather than from an event. */ function flashAnnounce(text: string): void { const el = document.getElementById('announce'); if (!el) return; el.textContent = text; el.className = 'shown'; window.setTimeout(() => { if (el.textContent === text) el.className = ''; }, 4200); } /** * THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10). * * "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of * time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which * Day it was — but a Day turns over inside the phases that run themselves, so it happens while the * player is looking at the board waiting for their next turn. The two transient signals the page * already had are both gone in under five seconds. * * Not shown when: * - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending; * - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing; * - the game finished on that rollover, when the outcome panel is the thing to read instead. */ function noteDayEnd(f: Frame): void { const previous = lastDay; lastDay = f.day; if (previous === null || f.day <= previous) return; if (f.status !== 'active') return; const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null; const body = document.getElementById('dayendbody'); if (!dlg || !body) return; body.innerHTML = dayEndHtml(f); // A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws // on an already-open dialog rather than doing nothing. if (!dlg.open) dlg.showModal(); } /** * THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn. * * The board simply appeared, mid-Local-Operations, with a log already several bot turns deep and * nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of * phase, and there is no previous phase to have changed from. */ function noteFirstFrame(f: Frame, rejoining = false): void { if (firstFrameSeen || isLocal(session)) return; firstFrameSeen = true; const held = Date.now() - handoffOpenedAt; const config = configFromFrame(f); const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode); window.setTimeout( () => { closeHandoff(); /** * "BEGUN" IS ONLY TRUE ONCE. `firstFrameSeen` is per page-load, so re-entering a game this * browser already holds a seat in — a reload mid-game, or picking it out of the lobby's list * — announced that the game had begun, to a player who had been playing it for an hour. * Reported for the solitaire side by Jesse, 2026-08-30; the same line was wrong here. */ flashAnnounce( `The game has ${rejoining ? 'resumed' : 'begun'} — ${type} · ${f.players.length} players · ` + `Day ${f.day}, Stage ${f.stage}`, ); }, Math.max(0, HANDOFF_BEAT_MS - held), ); } /** * COMING BACK TO A GAME, said out loud — the solitaire counterpart to `noteFirstFrame`. * * A restored game draws exactly like a dealt one: mid-Day, mid-phase, with a log already several * turns deep. Nothing distinguished "this is the game you left" from "this is a game that has just * started", and the multiplayer path had the opposite problem — it announced that the game had * BEGUN to a player rejoining one (Jesse, 2026-08-30: "do not post a message that says 'The game * has begun.' … it needs to say 'The game has resumed.'"). */ function announceResumed(f: Frame): void { flashAnnounce(`The game has resumed — Day ${f.day}, Stage ${f.stage}`); } /** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4), * `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first * solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse, * 2026-08-29). All three start `hidden` in the markup so none ever flashes before `start()` decides * which one this load actually needs. */ function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void { document.getElementById('lobby')!.hidden = which !== 'lobby'; document.getElementById('gameui')!.hidden = which !== 'gameui'; document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup'; } /** * The one place a `RemoteSession` gets built — from a fresh `Lobby.Start` push (`lobby.ts`'s * `runLobby` callback) or from a `{token, gameId, seat}` already sitting in `localStorage` from an * earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1), * so it is always written back here before anything else happens. */ function beginRemote(ready: LobbyReady, rejoining = false): void { saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' }); gameCode = ready.gameCode; showScreen('gameui'); // Nothing can be drawn until the first push arrives, and a page showing nothing at all is // indistinguishable from a page that is broken — which is exactly what a dead session used to // look like, forever. This is its own state now rather than a borrowed line in the DISCONNECT // banner (`#presence`), and it holds a beat so the game visibly begins. openHandoff(); session = createRemoteSession(ready.token, ready.seat, abandonRemote); remoteToken = ready.token; rejoiningRemote = rejoining; 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). // Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together. session.subscribe(() => { drainIntoQueue(); render(); }); } /** * The game this browser remembered is gone, so stop waiting for it and go somewhere useful. * * Two things legitimately destroy a game under a seated player, and both are by design: an * engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under * the new ones), and an administrator ends it. Neither used to be survivable here. The remembered * token sent `start()` straight past the lobby into a game that no longer existed, `EventSource` * retried the 404 in silence, and the player sat on a blank page with no controls and no way back * short of clearing site data. * * Forgetting the token is what makes the next load land in the lobby instead of repeating it. */ function abandonRemote(): void { // That one game is gone; any OTHER game this browser is in is untouched. const store = readStore(); if (store.last !== null) forgetRemote(store.last); closeHandoff(); showScreen('lobby'); $('presence').textContent = ''; runLobby(lobbyHandlers); // The lobby's own notice slot, not the create form's error line: the player may well have been a // joiner, and with the two doors that line is behind a panel they are not looking at. notice( 'That game is no longer on this server — it was either ended by whoever runs it, or the ' + 'service was updated, which does not carry games in progress across. Create or join a new one.', ); } /** * NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through * `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL * cannot produce one. `start()`'s job is only to decide which of three screens this load is: back * into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the * common case and the only one a bare page load has ever needed a decision for), or the lobby. */ /** * A SEAT RECOVERY LINK — Gitea#33. * * The token is the only identity this game has, and it lives in one browser's `localStorage`. Lose * that and the seat is unreachable: nothing else on the server will accept a claim to it. This is the * supported way back — an administrator mints a short-lived, single-use code (`server/claims.ts`) and * the player opens a link carrying it. * * THE LINK CARRIES A CODE, NEVER THE TOKEN. `lobby-and-sessions.md` §1 says to keep the token out of * URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is precisely the sort * of thing that ends up in a chat. So the code is traded for the token here, over the connection the * page was going to open anyway, and is dead the moment it is spent. * * THE CODE IS STRIPPED FROM THE URL EITHER WAY, so a reload does not re-spend a code that is already * gone and the address bar stops carrying a credential-shaped string. `replaceState` rather than * assigning `location.search`, which everywhere else on this page means "navigate" — it reloads, and * reloading is exactly what must not happen to the session we have just been handed. Guarded like * `requestAnimationFrame` and `performance` are, because the static build is imported head-first by * `test/web.test.ts` against a DOM stub that provides neither. */ async function claimSeat(code: string): Promise { showScreen('lobby'); const { status, body } = await postJson('/api/claim', { code }); if (typeof history !== 'undefined' && typeof history.replaceState === 'function') { history.replaceState(null, '', location.pathname); } if (status !== 200) { runLobby(lobbyHandlers); notice( 'That restore link has already been used, or it has expired. Ask whoever runs the server for a ' + 'fresh one — each link works once.', ); return; } // `beginRemote` writes the seat into this browser's storage itself, which is the whole point of // the exercise: the next ordinary reload finds it and goes straight back into the game. beginRemote( { token: body['token'] as string, gameId: body['gameId'] as string, gameCode: (body['gameCode'] as string | undefined) ?? '', seat: body['player'] as PlayerIndex, }, true, ); } function start(): void { const params = new URLSearchParams(location.search); /** * A RECOVERY LINK OUTRANKS EVERYTHING, including a game this browser already remembers: someone * arriving on one is being handed a seat deliberately, and that is never the load to second-guess. */ const claimCode = params.get('claim'); if (claimCode !== null && claimCode !== '') { void claimSeat(claimCode); return; } /** * ASKING FOR THE LOBBY BEATS RESUMING A GAME. * * The splash's "Play multiplayer" door and an invite link both land here with `?lobby`, and both * mean "I want to pick a game" — but a remembered session used to be checked first, so anyone * already in a game was dropped straight back into it and could never reach the lobby from the * door at all. A BARE load still resumes, which is the common case and the one D11 is about. */ const invited = params.get('code'); if (params.get('lobby') !== null || invited !== null) { showScreen('lobby'); if (invited !== null && invited !== '') prefillCode(invited); runLobby(lobbyHandlers); return; } /** * ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby` * above, for the door on the other side. A browser that has ever held a multiplayer seat carries * `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell * "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door * always lost to whatever multiplayer game or lobby this browser last touched, and could never * actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed * back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new * setup screen. The door now marks its intent explicitly, the same way `?lobby` already does — * and so does everything else that already means "this is a solitaire navigation": an explicit * `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes * it on every commit, so landing back here with it set is that navigation, not a bare reload). * Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would * work once and then bounce the very next load into whatever multiplayer game this browser last * touched, since its URL carries `hand=` but not `solitaire=`. */ const wantsSolitaire = params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand'); // Entered without checking it still exists — deliberately. Verifying up front would mean an // await before anything renders on the common path, where the game IS still there; instead the // session reports a dead game through `abandonRemote`, which lands in the lobby. const remembered = wantsSolitaire ? null : loadRemote(); if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) { beginRemote({ ...remembered, seat: remembered.seat }, true); return; } /** * A SEAT TAKEN BUT NOT YET PLAYING — this browser reloaded while the lobby was still seating. * * The lobby stream answers all three cases from here without another route: it pushes the seating * screen if the lobby is still open, and its `onerror` probe finds either a game that started * while we were away (straight in) or a lobby that is gone (back to the doors, with a reason). */ if (remembered) { showScreen('lobby'); runLobby(lobbyHandlers, { token: remembered.token, gameId: remembered.gameId, gameCode: remembered.gameCode, }); return; } // A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see // `configFor`. Read once, here, so the same answer decides both whether to ask before dealing and // (below) whether to restore. const saved = load(); const requested = params.get('seed'); /** * ASK BEFORE THE FIRST DEAL, THE SAME WAY THE LOBBY ASKS BEFORE THE FIRST MULTIPLAYER GAME * (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game"; * "asking first is the only path"). * * Three things answer the question and so skip the screen, in this order of precedence: * `hand` (every `commitNewGame` write sets it, so this navigation IS the Deal button landing back * here to deal), `seed` (a specific deal someone chose to share or bookmark), and — only when the * player did not explicitly ask to set one up — an existing save, which is a game to resume. * * THE DOOR OUTRANKS A SAVED GAME, and getting that wrong is what made this feature unreachable * for three releases. v0.7.5 skipped the screen whenever `load()` found ANYTHING, reasoned as "a * saved game is a game to resume" — but a browser that has ever played solitaire always has one, * so the door could never reach the screen again. Reported three times (Jesse, 2026-08-29 twice * and 2026-08-30); a private window appeared to absolve it only because it had never played and * so had no save. Clicking "Play solitaire" is a request to set a game up, not to resume one — a * BARE reload is the resume case, and still is. `#ss-resume` is what keeps the save reachable, so * this costs nobody the game they were playing. */ const askedToSetUp = params.get('solitaire') !== null; if (requested === null && !params.has('hand') && (askedToSetUp || !saved)) { showScreen('solitairesetup'); runSolitaireSetup(params, saved !== null); return; } showScreen('gameui'); // A seed in the URL makes a game shareable and reproducible: same link, same deal. const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9); const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params))); session = local; const restored = Boolean(saved) && requested === null; if (saved && restored) 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. // Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together. session.subscribe(() => { drainIntoQueue(); render(); }); render(); // Coming back to a game is not the same event as being dealt one, and the board looks identical // either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30). if (restored) announceResumed(session.view()); } /** * 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. */ /** * The session token of a server-backed game, or null in solitaire (playtest, 2026-09-15: "most of the * time, I want to go ahead and just save it as a JSON file"). It is the seat's proof of identity to * `/api/save`, exactly as it is to `/api/stream` — a save is the seed and the moves, every one of which * is already on this player's screen. */ let remoteToken: string | null = null; 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 || remoteToken !== null); hide('newgame', c.newGame); // Creating or joining ANOTHER multiplayer game from inside a running one is not a thing this // page offers — same reasoning as `newgame`, and the same capability answers both. hide('multiplayer', c.newGame); // The mirror of the three above: leaving a game is the one control that only a REMOTE session has. hide('leavegame', !c.newGame); } /** * The west-to-east chain in words, with the D12 that decided it (§4.4). * * The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was * kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and * here is the roll. */ function renderSeatingChain(f: Frame): void { const el = document.getElementById('seating-chain'); if (!el) return; /** * ONLY WHILE THE GAME IS STILL OPENING. * * This answers "who is where, and why" — which is a question you have once, at the start, when * the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been * answering it for a while, and a permanent line restating it is a permanent line to read past. */ const opening = f.day === 1 && f.stage === 1; if (f.players.length < 2 || !opening) { el.textContent = ''; return; } const bySeat = [...f.players].sort((a, b) => a.seat - b.seat); const chain = bySeat .map((p) => { const roll = f.openingRolls.division[p.index]; const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : ''] .filter(Boolean) .join(', '); return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`; }) .join(' → '); el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`; } /** * `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table * has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never * has anything to report), and empty again the moment everyone reports back in — `#presence:empty` * collapses the banner rather than leaving a reassuring "all connected" line nobody needs to read. */ function renderPresence(f: Frame): void { const name = (seat: PlayerIndex): string => f.players.find((pl) => pl.index === seat)?.name ?? `Seat ${seatLabel(seat)}`; const presence = session.presence(); /** * TWO DIFFERENT ABSENCES, and they call for two different things from the table. * * A seat the server has never heard from has not opened the game yet — somebody needs to send them * the link. A seat that WAS here and dropped will probably be back. The server reports both from * `/api/stream`'s connect push (2026-08-23); before that a client learnt of a seat only when it * disconnected AFTER you connected, so a table where two people had not shown up yet said nothing * at all. */ const dropped = presence.filter((p) => p.seen && !p.connected).map((p) => name(p.seat)); const never = presence.filter((p) => !p.seen).map((p) => name(p.seat)); const parts: string[] = []; if (dropped.length > 0) parts.push(`${dropped.join(', ')} — disconnected`); if (never.length > 0) parts.push(`${never.join(', ')} — not here yet`); $('presence').textContent = parts.length === 0 ? '' : `⚠ waiting on ${parts.join(' · ')}`; } /** * WHICH GAME THIS IS, in the header: its code and its type. * * Neither used to be anywhere on the board. The code ended at the lobby door, and the type was never * on the Frame at all — so a player could read what a load paid but not whether they were in a Co-op * game or a Cutthroat one, which is the difference between helping the table and racing it. */ function renderGameIdentity(f: Frame): void { const codeEl = document.getElementById('gamecode'); if (!codeEl) return; codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`; /** * THE CODE KEEPS ITS TOOLTIP, AND THE TOOLTIP KEEPS THE RULES. The game type and the house rules * moved into the This Game card (TODO #28), but the code is the thing a player reads out to say * WHICH game they are in — so it is worth being able to hover it and get the whole answer without * opening the card. * * IN SOLITAIRE THERE IS NO CODE, so the span is empty and this tooltip is unreachable. That is not * a hole: the card's summary line is always on screen whether the card is folded or not, and it * opens with the type — "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". A lone player has no * game to name to anybody, and the one thing this tooltip adds over that line is the blurb. */ const config = configFromFrame(f); const players = f.players.length; const type = presetOf(config, players, f.days); const near = closestPreset(config, players, f.days); const blurb = type === 'custom' ? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` + `${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` + `from ${preset(near.name).label}.` : preset(type).blurb; codeEl.title = `${gameTypeLabel(type, f.mode)}. ${blurb}\n\n${rulesSummary(f)}\n\n` + 'The full settings are in the This Game card, at the foot of the right-hand column.'; } /** The victory conditions in force, spelled out for the header's tooltip. */ function rulesSummary(f: Frame): string { const off = (n: number): string => (n === 0 ? 'off' : String(n)); return ( `Days: ${f.days}\n` + `Combined Revenue floor: ${off(f.minCombinedRevenue)}\n` + `Collisions in one Day that end the game: ${off(f.maxCollisionsPerDay)}\n` + `Collisions in the whole game that end it: ${off(f.maxCollisionsTotal)}` ); } function render(): void { const f = session.view(); const menu = session.menu(); noteFirstFrame(f, rejoiningRemote); // 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(); for (const sp of chosen?.spots ?? []) { if (sp.coord === null) continue; const key = `${sp.coord.row},${sp.coord.col}`; spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]); } renderTurnChart(f); renderPresence(f); renderWatching(f); $('revenue').textContent = String(f.revenue); /** * THE OBJECTIVE, WITHOUT THE COMMENTARY. * * It used to read "3 of 20 · 2 Days left · behind the pace (expected 8)". The score, the target and * the Days left are what a player steers by; the pace verdict and the engine's guess at what the * score ought to be were an opinion taking up the one line that must not wrap — and the expected * figure came from a target that is itself an open question. */ const obj = $('objective'); /** * Past the original timetable this has to stop saying "N Days left" of a Day count that no longer * applies (Gitea#11). `objective.daysLeft` already counts against the EXTENDED timetable; what it * cannot say on its own is that the Days being counted are borrowed ones. */ const left = `${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`; obj.textContent = f.extraDays > 0 ? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable` : `${f.revenue} of ${f.objective.target} · ${left}`; obj.className = 'pace'; renderCollisions(f); renderGameIdentity(f); renderGameCard(f); // -- division $('division').innerHTML = divisionSvg(f.division, { players: f.players, actor: actorOnScreen(stepQueue, f.actor).actor, viewer: f.viewer, // A Realignment changes the Division under everyone; flashed only while the step that did it is up. flash: stepQueue.busy() ? stepQueue.flashing() : [], }); renderSeatingChain(f); applyZoom($('division')); // -- board. Both renderers are shared with the replay so the two can never draw different // pictures of the same position. const grid = $('grid'); // The empty squares a legal placement would extend the district onto. Drawn by `officeSvg` in the // same pass so they share its origin and its canvas: a target outside the canvas is a legal move // the player cannot click. const ghostCoords = [...spotsAt.keys()] .filter((k) => !f.cells.some((c) => `${c.row},${c.col}` === k)) .map((k) => { const [gr, gc] = k.split(',').map(Number); return { row: gr!, col: gc! }; }); /** * WHAT PLAYING ON AN OCCUPIED SQUARE WOULD DO. * * Three different acts light up the same blue: building on empty ground, EXTENDING the Running * Track at a Limits sign (the sign moves outward with the card — the only way a district grows * along the main), and ATTACHING an Enhancement to a card already down. The first says "place * here" on a ghost card; the other two said nothing at all, which is why the Depot lighting up * read as a bug rather than as an offer. */ const legalCaps = [...spotsAt.keys()].flatMap((k) => { const cell = f.cells.find((c) => `${c.row},${c.col}` === k); if (!cell) return []; const label = cell.kind === 'limits' ? 'EXTEND THE RUNNING TRACK HERE' : cell.kind === 'office' ? 'ATTACH TO YOUR OFFICE' : 'ATTACH TO THIS CARD'; return [{ row: cell.row, col: cell.col, label }]; }); /** * SOMEBODY ELSE'S BOARD IS READ-ONLY, and that is not a cosmetic distinction. * * No ghosts, no legal caps and no selected crew: all three are answers to "what could YOU do * here", computed from this seat's own menu, and drawing them over another player's district * would offer moves on a board you cannot play. Every click handler below is skipped for the same * reason — `spotsAt` holds coordinates in YOUR district, and the same coordinates exist in theirs, * so wiring them up would silently attach your moves to their squares. */ const watched = watchedDistrict(f); $('districtwho').textContent = watched ? `${watched.name}'s Office Area` : 'Your Office Area'; grid.innerHTML = watched ? officeSvg(watched.cells, watched.runningRow, [], [], watched.limits, null) : officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew); applyZoom(grid); if (!watched) { // 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 ? `` : ''; // 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 ( `
` + `${esc(h.name)}` + `
` + verb( 'play', canPlay, canPlace ? `play ${h.spots}` : '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.') + `
` ); }) .join('') : 'empty'; /** * 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. */ // The pile the move being WATCHED just touched, lit for as long as that step is on screen. Empty // whenever the board is level with the game, or when the move was this player's own. $('depts').innerHTML = pilesHtml(f, stepQueue.busy() ? stepQueue.lit() : []); 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'); /** * THE LOG IS HELD BACK WITH THE BOARD (playtest, 2026-09-15). * * A push carries its narration and its display steps together, so every line of a bot's turn was in * this panel before the board had drawn a single move of it — the history ran ahead of the "N behind" * counter it is meant to match. Those lines are the TAIL of the log, so exactly the ones belonging to * steps still queued are withheld, and each appears as its step goes up. */ const heldBack = stepQueue.pendingLines(); const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines(); const shownLines = allLines.slice(-60); /** * WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so * by the time the board paints the log already has several turns in it and nothing says which of * them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the * end of the panel is no longer the start of the game, and a marker claiming otherwise would lie. */ const startMarker = !isLocal(session) && allLines.length === shownLines.length ? '
— the game began —
' : ''; /** * NEWEST FIRST (TODO #23). Jesse, 2026-08-30: "it should be reversed so the top line is the most * recent and the further down you go, the older the entry." * * The panel used to run oldest-first and scroll itself to the bottom, so the thing that had just * happened was the one line you had to go and find. A glance at the top is now always the most * recent thing, and `scrollTop = 0` keeps it there as lines arrive rather than chasing the end. * * THE PHASE HEADINGS NOW TRAIL THEIR LINES, and that is accepted rather than overlooked. A * `t-phase` line reads forwards — it introduces what follows it — so reversing puts each one * BELOW the events it announced. Jesse ruled on it directly: "stage changes will be beneath * (prior to / older than) the following events. That is OK." Reading down the panel is reading * backwards in time, and a heading sitting under its own lines is what backwards looks like. * Grouping by phase and reversing the groups was the alternative, and it was declined as more * machinery than the complaint needs. * * The start marker moves with the same logic: it is the OLDEST thing on screen, so it goes last. * * `replays.ts` keeps its own oldest-first log deliberately — it is paired with a frame stepper, * where "what just happened" is the step you have this moment clicked, so newest-first would * fight the stepping rather than help it. */ log.innerHTML = shownLines .map((l) => `
${esc(l.text)}
`) .reverse() .join('') + startMarker; log.scrollTop = 0; /** * SAY WHEN THE PHASE TURNS OVER. * * The five phases run themselves once Local Operations ends, so the page can change out from under * a player between one click and the next. The turn chart already shows WHERE you are; this says * that it moved, which is the part you miss when you were looking at the board. */ if (lastPhase !== null && lastPhase !== f.phaseKey) { const el = $('phasenote'); el.textContent = `${f.phase} — Day ${f.day}, Stage ${f.stage}`; el.className = 'shown'; window.setTimeout(() => { if (el.textContent?.startsWith(f.phase)) el.className = ''; }, 2600); } lastPhase = f.phaseKey; noteDayEnd(f); /** * A train completing its run pays every player and nobody took a turn to cause it, so it is said * out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw. */ const announcement = session.takeAnnouncement(); if (announcement) flashAnnounce(announcement); renderDistrict(f); renderActions(menu, f, justSet); renderUndo(); // Drain whatever the last batch of events earned. Cleared either way, so turning sound on does // not then play a backlog of everything that happened while it was off. const cues = session.takeCues(); if (soundOn) for (const c of cues) playCue(c); save(); } /** * UNDO, as far back as you like. * * The game is a seed and a list of intents, so stepping back is replaying without the last one — * see `undo()` in game.ts. The button says how many moves are behind you, because "can I still get * back?" is the question it exists to answer; it goes quiet when there is nothing to take back. * * The whole page is re-rendered from the rebuilt game, including the history panel, so nothing on * screen is left describing a move that no longer happened. Any card picked up mid-choice is * dropped: the position it was going to be played into may not exist any more. */ function renderUndo(): void { const btn = document.getElementById('undo') as HTMLButtonElement | null; if (!btn || !isLocal(session)) return; // Captured as a `const` rather than read as `session` again inside the closure below: `session` is // a mutable module-level `let`, so TypeScript cannot carry the `isLocal` narrowing across a closure // boundary — a local const it can never see reassigned keeps the narrowed `LocalSession` type. const local = session; const n = local.steps(); btn.disabled = n === 0; btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`; btn.onclick = () => { // The session drops the rebuilt game's cues and draws — replaying the history re-records them, // and none of it is news to a player who just stepped back. if (!local.undo()) return; selected = null; mode = null; pendingAt = null; // A phase change is announced by comparing against the last frame drawn. Stepping BACK into a // different phase is not that event, so the banner is suppressed rather than fired backwards. lastPhase = null; // Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a // rollover and then replaying forward through it would announce the same Day ending twice. lastDay = null; }; } /** * The two yards, and how close the Division Yard is to running out. * * §2 — used Rolling Stock is set out in the Classification Yard, and it only returns when the * Division Yard is BARE. So the interesting number is not how much has been used but how little is * left, and the moment the Division Yard empties a whole pile comes back at once. */ function renderYards(f: Frame): void { $('divyard').innerHTML = yardHtml(f.yards.division); $('clsyard').innerHTML = yardHtml(f.yards.classification); $('divtot').textContent = `${f.yards.divisionTotal} cars`; $('clstot').textContent = `${f.yards.classificationTotal} cars`; // The one thing worth calling out: the yard about to turn over. const bare = f.yards.divisionTotal === 0; $('divyard').classList.toggle('bare', bare); $('yardnote').textContent = bare ? `The Division Yard is bare — the ${f.yards.classificationTotal} cars in Classification return to it now.` : 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.'; } function renderDistrict(f: Frame): void { // The phase ON SCREEN, so the panel opens for the Local Operations being WATCHED rather than for // one the game has already moved past — same rule as the turn chart, see `shownTable`. const open = districtMode === 'auto' ? FOCUS_PHASES.has(shownTable(f).phaseKey) : districtMode === 'open'; const sec = $('district'); if (open) sec.classList.remove('folded'); else sec.classList.add('folded'); /** * THE SUMMARY COUNTS THE BOARD ON SCREEN, WHICH IS NOT ALWAYS YOUR OWN. * * The panel has drawn somebody else's district since v0.8.0 — `watchedDistrict` follows whoever is * acting — and the heading beside this line says whose it is. The counts were read from `f`, the * viewer's own Frame, every time: so while a bot's turn played out, the header read "Bot 2's Office * Area" over a board of Bot 2's cards, with a summary counting YOUR cards, facilities and trains * (Jesse, playtest 2026-09-16 — "the hidden office summary line describes my district, not the one * being shown"). One source for the drawing and the counting, so the two cannot disagree again. */ const watched = watchedDistrict(f); const cells = watched?.cells ?? f.cells; const facilityCount = watched ? watched.facilities.length : f.facilities.length; const cars = 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 = cells.reduce((n, c) => n + c.trains.length, 0); $('districtsummary').textContent = `${cells.length} cards · ${facilityCount} facilities · ${cars} cars standing` + (crew > 0 ? ` · ${crew} crew on the board` : ''); /** * THREE CONTROLS, ONE PER MODE (TODO #16) — not one control that cycles. * * The cycle was `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing * AT THAT MOMENT — `FOCUS_PHASES.has(f.phaseKey)`. So which pin a press reached depended on the * phase: during Local Operations or Cargo it offered "always hidden", and in every other phase * "always showing". Getting from one pin to the other meant clicking back to auto, waiting for * the phase to turn over, and clicking again — which is why it never read as a setting. * * The labels still say what pressing DOES rather than what the panel is doing. That was a * deliberate earlier fix ("auto · folded" read as a status line and was missed entirely) and it * survives the change; what the CYCLE could not do was be honest about the state it was in, which * is now carried by `aria-pressed` and the lit button instead of by the label. */ /** * Addressed by id, one lookup each, rather than by querying the container's children. Everything * else on this page is reached with `$('...')`, and it is what makes the control testable at all: * the page never writes this markup, so a child query finds nothing in a stubbed DOM and the * whole control would ship green and unexercised. */ for (const mode of ['auto', 'open', 'closed'] as const) { const b = $(`dm-${mode}`); b.setAttribute('aria-pressed', String(mode === districtMode)); b.onclick = () => { districtMode = mode; saveSettings({ districtMode }); render(); }; } /** * ONE BUTTON PER OPPONENT, and nothing at a table of one. * * Built with `createElement` and `textContent` rather than interpolated into `innerHTML`, because * a player's NAME is whatever they typed in the lobby — the one string on this page that comes * from another person, and so the one that must never be pasted into markup. */ const peek = $('districtpeek'); const others = f.players.filter((p) => p.index !== f.viewer); peek.innerHTML = ''; peek.hidden = others.length === 0; for (const p of others) { const b = document.createElement('button'); b.type = 'button'; b.className = 'ghost'; b.textContent = p.name; b.title = `Look at ${p.name}'s Office Area. It is read-only, and it reverts as soon as the board next ` + `redraws — press Pause first if you want to study it.`; b.onclick = () => { peekPlayer = p.index; render(); }; peek.appendChild(b); } // SPENT. The look lasted the render it asked for; the next one follows the game again. peekPlayer = null; } /** * The square an action button acts on, as an attribute the board can be matched against. * * `null` for the actions that act on no square at all — ending a turn, drawing a card, a train card * that goes to the timetable — so those buttons carry nothing and highlight nothing. */ function cellRef( coord: { row: number; col: number } | null | undefined, route?: { row: number; col: number }[], ): string { const square = coord ? ` data-square="${coord.row},${coord.col}"` : ''; // Only a `switch.move` with more than one legal route carries this (docs/plans/switching-paths.md) // — every intermediate square the chosen route runs over, so hovering lights the whole road, not // just where it ends. const path = route && route.length > 0 ? ` data-route="${route.map((c) => `${c.row},${c.col}`).join(' ')}"` : ''; return square + path; } /** * POINT AT THE SQUARE THE BUTTON MEANS. * * The action list is a column of sentences that differ by a coordinate — "(1,3)" against "(-1,3)" — * and the board is right beside it saying nothing about which one is which. Hovering a button now * lights its square up, so the check happens with the eye rather than by reading two numbers off a * button and finding them on a map. Reported by Jesse: recoverable in solitaire, where Undo is a * click; a disaster in multiplayer, where it is not. * * ON FOCUS AS WELL AS HOVER, so tabbing through the list works the same way as pointing at it — the * keyboard route is not a lesser one. * * BOTH KINDS OF SQUARE. A played card is a `data-cell`; a square being placed ONTO is an empty * `data-ghost` target, which is precisely the case where the player is choosing between coordinates. * Matching only one of the two would leave the most mistake-prone moment unhelped. */ function wirePointing(node: HTMLElement, key: string, routeKeys: readonly string[] = []): void { const grid = $('grid'); const keys = [key, ...routeKeys]; const marks = (): Element[] => keys .flatMap((k) => [grid.querySelector(`g[data-cell="${k}"]`), grid.querySelector(`g[data-ghost="${k}"]`)]) .filter((g): g is Element => g !== null); const on = (): void => { for (const g of marks()) g.classList.add('bs-point'); }; const off = (): void => { for (const g of marks()) g.classList.remove('bs-point'); }; node.onmouseenter = on; node.onmouseleave = off; node.onfocus = on; node.onblur = off; } /** * THE END OF THE GAME — the action area once there is nothing left to decide, or only one thing. * * Two states share this, and they are genuinely different (Gitea#11): * * - `awaitingExtension` — the timetable ran out on an ending the table MAY play past. The result * is already recorded and already readable; the only question open is whether to run one more * Day. In multiplayer that is a unanimous vote, so this also has to show who is still to answer. * - `finished` — over for good. The results screen, and a new game. * * The results are reachable in BOTH, and stay reachable after play continues, which is the * constraint Gitea#16 and Gitea#11 put on each other: continuing must not cost you the results * screen, so it is a button that reopens rather than a screen you get one look at. */ function renderEnding(el: HTMLElement, f: Frame): void { const o = f.official?.outcome ?? f.outcome; const won = o?.result === 'win'; /** * Put the results up once per ending, unasked. * * "Once per ENDING" rather than once per game is the extended-play case: an extended game ends, * is played on, and ends again, and each of those is a moment worth reading. `renderActions` * clears the flag whenever the game is running again, so the next ending gets its own showing — * while a redraw during the same ending does not reopen a dialog the player has dismissed. */ if (!resultsShown) { resultsShown = true; showResults(f); } if (f.status === 'awaitingExtension') { const mine = f.extensionVotes[f.viewer]; // Only seats that exist are counted; `extensionVotes` is per PLAYER and so is `players`. const waiting = f.players.filter((p) => f.extensionVotes[p.index] === null); const tally = f.players.length > 1 ? '
' + f.players .map((p) => { const v = f.extensionVotes[p.index]; const mark = v === true ? '✓' : v === false ? '✗' : '·'; return `` + `${mark} ${esc(p.name)}${p.index === f.viewer ? ' (you)' : ''}`; }) .join('') + '
' : ''; el.innerHTML = `
` + `${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'} — Day ${f.official?.day ?? f.days} is scored.
` + 'The result above is final. Play one more Day?
' + tally + (mine === null ? '' + '' : `
You voted ${mine ? 'to play on' : 'to end it'}. ` + (waiting.length ? `Waiting on ${waiting.map((p) => esc(p.name)).join(', ')}.` : 'Settling…') + '
') + ''; if (mine === null) { const vote = (agree: boolean) => () => void session.submit({ type: 'game.extend', player: f.viewer, agree }); $('extend-yes').onclick = vote(true); $('extend-no').onclick = vote(false); } $('results').onclick = () => showResults(f); return; } el.innerHTML = `
` + `${won ? 'THE DIVISION RAN' : 'THE DIVISION FAILED'}
` + `final Revenue ${f.revenue}${f.objective.target > 0 ? ` against a target of ${f.objective.target}` : ''}
` + '' + (session.capabilities.newGame ? '' : ''); $('results').onclick = () => showResults(f); if (session.capabilities.newGame) { $('again').onclick = () => { clearSave(); location.search = ''; }; } } /** * Whether the results have been put up by themselves for the ending currently on screen. * * Cleared by `renderActions` the moment the game is running again, so an extended game gets a fresh * showing at each of its endings while a redraw during one ending does not reopen a dialog the * player has just dismissed. */ let resultsShown = false; function showResults(f: Frame): void { const dlg = document.getElementById('resultsdlg') as HTMLDialogElement | null; const body = document.getElementById('resultsbody'); if (!dlg || !body) return; body.innerHTML = resultsHtml(f); /** * ASK THE EXTENSION QUESTION ON THE THING THAT IS ACTUALLY IN FRONT OF THE PLAYER. * * This dialog opens itself at every ending and it is MODAL, so `renderEnding`'s own "play one more * Day" buttons — written into `#actions` — are behind it. The player read a results screen offering * nothing but Close and concluded the game was over, which is exactly what it looked like (Jesse, * 2026-08-30). Gitea#11 was verified over the HTTP API, where there is no dialog to be behind. * * The buttons in `#actions` stay, and are still correct: they are what remains after this is * closed, and what a player who reopened the results with "see the full results" comes back to. * Voting from either place submits the same intent. */ const yes = document.getElementById('rs-extend-yes') as HTMLButtonElement | null; const no = document.getElementById('rs-extend-no') as HTMLButtonElement | null; const asking = f.status === 'awaitingExtension' && f.extensionVotes[f.viewer] === null; if (yes && no) { yes.hidden = !asking; no.hidden = !asking; if (asking) { // `method="dialog"` closes it on click; the vote rides along. Assigned every time rather than // once, because `f` is a fresh Frame on each ending. yes.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: true }); no.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: false }); } } // A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather // than doing nothing (the same trap `noteDayEnd` documents). if (!dlg.open) dlg.showModal(); } function renderActions( menu: Menu, f: Frame, justSet: number | null, ): void { const el = $('actions'); if (f.status !== 'active') { renderEnding(el, f); return; } /** * YOUR MOVE IS PUT AWAY WHILE THE BOARD IS CATCHING UP — Jesse, 2026-09-10: *"your actions should * be hidden while catching up."* * * Two reasons, and the second is the one that changed my mind about it. The board on screen is * behind the game, so a move offered here is a move against a position that has already moved on — * the menu is computed from the CURRENT state and would be acted on while looking at an older one. * And the display had grown to four things demanding attention at once — the district, the history, * the catching-up row and now a lit pile — which is what made the pile highlight so easy to miss. * Taking the action list out of that competition while there is nothing to decide anyway is the * cheapest way to quieten it. * * NOT A BLOCK. Skip is one click away and sits at the left of the row, so the wait is always * voluntary; this replaces the buttons with the reason they are gone, rather than leaving a live * menu over a stale board. */ if (stepQueue.busy()) { el.innerHTML = '
Catching up on what everyone else did — your move is here when the board is ' + 'level with the game. Skip jumps straight to it.
'; return; } // The game is running, so the next ending — an extended Day's, or a fresh game's — is entitled to // put its results up unasked again (Gitea#11). resultsShown = false; if (menu.direct.length === 0 && menu.placeable.length === 0) { el.innerHTML = '
nothing to decide — the engine is running the Division
'; 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 ( `` ); }; /** * 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 ? `
` + `Train ${f.timetable[justSet]} is scheduled to depart at Stage ${justSet + 1} — see the Timetable
` : ''; const movesNote = f.movesLeft !== null ? `
` + `${f.movesLeft} of ${MOVES_PER_LOCAL_OPS} Moves left
` : ''; // 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 = `

Actions

${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 += `

Which train are you switching?

` + f.moves .map( (m) => ``, ) .join('') + `
`; } /** * 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) => `

${esc(g.title)}

` + 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('') + `
`, ) .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 += `

${esc(menu.makeUp.title)}

` + /** * WHAT IS STILL WANTED, which is not what the heading says. * * The heading names what the card CALLS FOR and goes on saying it unchanged as cars go on, so * the one question a player has while clicking — what is left? — was the only thing on screen * that had to be worked out by eye, against a consist drawn in the other column (Jesse, * playtest 2026-09-16). `consistNeeds` counts by the same categories `acceptsCar` does, so it * can never ask for a car the engine would then refuse. */ (menu.makeUp.needs !== null ? `
Still needs ${esc(menu.makeUp.needs)}
` : `
Its card's consist is complete — nothing further may be added.
`) + `
` + (addable > 0 ? `Click a car in the Division Yard below to add it — the ${addable} kind${addable === 1 ? '' : 's'} it may take ` + `${addable === 1 ? 'is' : 'are'} highlighted in amber there. ` + /** * WHY YOUR TURN ENDS AFTER ONE CAR — §7's round, said where the clicking happens. * * It was written down only in the turn chart's New Train chip tooltip: hovered once, early * on, and never again. "Click a car" then reads as "build this train", so a player adds one * and the turn moves on with no explanation (Jesse, playtest 2026-09-16). */ `One car each: you add a single car, then the round passes to the next player — ` + `starting from the Superintendent and working left, coming round again until the train is ` + `full or the Division Yard holds nothing it can take.` : menu.makeUp.pass !== null ? 'The Division Yard is bare, so there is nothing to add. Send the train out as it stands.' : 'Nothing in the Division Yard may join this train, and passing is not allowed while the yard holds cars.') + `
` + /** * 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 ? `
${esc(menu.makeUp.advice.text)}
` : '') + (menu.makeUp.pass !== null ? `` : '') + `
`; } /** * 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 += `

${esc(group.title)}

`; 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 += `
` + (pendingAt ? `choose a rotation for (${esc(pendingAt.replace(',', ', '))}):` : 'click a highlighted square, or:') + `
` + 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 ( `` ); }) .join('') + `
`; } } html += `
`; } /** * WHY "END LOCAL OPERATIONS" IS NOT THERE (#45). * * This asked the question backwards: "the engine is offering no `draw.end`, so it must be the * hand limit." That was true only because `check('draw.end')` happens to refuse for exactly three * reasons and the two guards above rule out the other two — a fourth reason would have made this * block explain a refusal by describing something else entirely, which is #90 verbatim. * * `f.overHandLimit` IS the fact, and the Frame has carried it all along for precisely this: "the * same test the engine applies to `draw.end`, asked here so the page can disable the button with a * reason instead of hiding a move that has simply become illegal" (`web/game.ts`). It was computed, * serialised and sent to nobody. Behaviour is unchanged today; what changes is that the screen now * states the reason it is giving rather than inferring it from an absence. */ if (f.phaseKey === 'localOps' && f.option === 'draw' && f.overHandLimit) { // The hand being counted is the VIEWER's, like `f.option` and `f.handCount` beside it — and the // viewer is the actor whenever this menu is on screen at all. const hand = f.handCount; /** * WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD. * * §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled * train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but * those has exactly one way forward: play one. The rule creates that corner deliberately and * needs no machinery, but it must not be a corner the player has to infer from a discard button * that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says * what actually refused rather than assuming which of the two rules is in force. */ const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d); const why = f.handKeepWhy.find((w) => w !== null) ?? ''; const tip = stuck ? 'You may not end a turn holding more than three cards, and every card you hold is one that ' + `cannot be thrown away. ${why} The only way on is to play one.` : 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' + 'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' + 'it up.'; html += `
`; } 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 writeFile(name: string, data: string): void { const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); } /** * WHICH GAME, HOW FAR IN, AND WHEN — Jesse, playtest 2026-09-16. * * The name was `station-master-day1-stage5.json` for every server game at that point in every * Stage, so two saves off the same table collided in the downloads folder and neither said which * table it came from. The join code is the one thing a player already says out loud to identify a * game, so it leads: `whistle-6945.day1.stage5.2026.09.16.json`. * * Lowercased because a filename is not a thing you shout, and dotted because that is the shape * Jesse asked for. A solitaire game has no join code and falls back to its seed, which is the * equivalent identity for a game nobody else is sitting at. */ function saveFileName(prefix: string, f: { day: number; stage: number }): string { const d = new Date(); const pad = (n: number): string => String(n).padStart(2, '0'); const date = `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())}`; return `${prefix}.day${f.day}.stage${f.stage}.${date}.json`; } async function downloadSave(): Promise { const f = session.view(); /** * A SERVER-BACKED GAME HAS NO LOCAL SAVE TO HAND OVER, so it asks the server for its own — the seat's * token is the gate (`/api/save`), the same one the stream and every intent already use. The StartOS * Manage Game action cannot do this: an action result is text only, with no file member in the SDK. */ if (!isLocal(session)) { if (remoteToken === null) return; try { const res = await fetch(`/api/save?token=${encodeURIComponent(remoteToken)}`); if (!res.ok) return; const body = (await res.json()) as { save: unknown }; const code = gameCode === '' ? 'station-master' : gameCode.toLowerCase(); writeFile(saveFileName(code, f), JSON.stringify(body.save, null, 1)); } catch { // Offline, or the game has been ended under us: the button simply does nothing, which is the // same thing every other server call on this page does when the server is not there. } return; } const solo = gameCode === '' ? `station-master-seed${session.seed()}` : gameCode.toLowerCase(); writeFile(saveFileName(solo, f), JSON.stringify(session.save(), null, 1)); } 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 = () => void downloadSave(); /** * Forget the saved game and deal a fresh one. * * The only way out used to be finishing the game — `start()` restores from localStorage on every * load, so a game you no longer wanted followed you across reloads, and the "new game" button * appeared solely on the game-over screen. Confirmed because it throws the whole game away — Undo * steps back one action at a time, but nothing brings back a game that has been dealt over, and the * replay download is right beside it. * * Navigating rather than re-rendering, so a stale `?seed=` in the URL goes too — leaving it would * deal the same game again and look like the button had done nothing. */ const multiplayerBtn = document.getElementById('multiplayer'); if (multiplayerBtn) { multiplayerBtn.onclick = () => { // Hidden whenever `session` cannot deal (`applyCapabilities`), but repeated here for the same // reason `newBtn`'s handler repeats its own guard: the click handler outlives any one session. if (!isLocal(session)) return; const f = session.view(); const started = f.status === 'active' && (f.day > 1 || f.stage > 1); if (started && !confirm(`Leave this game (seed ${session.seed()}, Day ${f.day}) for multiplayer?`)) return; showScreen('lobby'); runLobby(lobbyHandlers); }; } /** * LEAVE A RUNNING GAME — back to the lobby, seat and token kept. * * Reported by Jesse 2026-08-23: a player who has to go had no way out at all. The page re-entered * the same game on every load, and the only thing that ever let go of a session was the game itself * being destroyed. Leaving does NOT give up the seat: `lobby-and-sessions.md` §5 keeps it and the * table waits, which is the design — nothing moves on an absent player's behalf. The token is kept * too, so the game can be re-entered from "Games you are in"; forgetting it is a separate, deliberate * act on that list, because the token is the only proof of who you are. */ const leaveBtn = document.getElementById('leavegame'); if (leaveBtn) { leaveBtn.onclick = () => { if (isLocal(session)) return; const f = session.view(); const code = gameCode === '' ? 'this game' : gameCode; if (!confirm(`Leave ${code} (Day ${f.day}, Stage ${f.stage})? Your seat is kept and the game waits for you.`)) return; // Stop listening before leaving the screen, so the table sees the seat go quiet rather than // being told somebody is present who is not. session.close?.(); closeHandoff(); showScreen('lobby'); $('presence').textContent = ''; runLobby(lobbyHandlers); notice(`You left ${code}. It is still yours — rejoin it under "Games you are in" whenever you like.`); }; } /** * ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small * glue between them (which type is currently selected, what its note says, how Days feeds the * floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea * "let the user choose their options like the start of a multiplayer game", 2026-08-29) both need * an identical copy of this — factored out once so the two cannot drift apart the way the rules * block itself already had before `settings-form.ts` existed to stop it. * * PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY * (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire * defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the * seam that lets each caller do its own version of "what do these fields show at first paint" * without this function having to guess which one it is wiring. */ type WiredGameType = { form: SettingsForm; days(): number; refresh(): void; /** The common case: prefill straight from a named type's own defaults, then repaint. */ selectPreset(name: PresetName): void; /** The dialog's case: the caller writes the form itself (from a live game), then calls `refresh` * — this only sets which type that write should be compared against. */ setBase(name: PresetName, type: GameType): void; }; function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType { const field = (id: string): T => document.getElementById(`${prefix}${id}`) as T; const form = settingsForm(prefix); let base: PresetName = 'solitaire'; let type: GameType = 'solitaire'; /** As in the lobby: the floor is derived from the length until the player sets one themselves. */ let floorTyped = false; const days = (): number => { const raw = Number(field('days').value); return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5; }; const typeRadios = (): HTMLInputElement[] => Array.from(root.querySelectorAll(`input[name="${prefix}type"]`)); function refresh(): void { const differing = form.mark(base, 1, days()); if (differing.length > 0) type = 'custom'; else if (type === 'custom') type = base; for (const r of typeRadios()) r.checked = r.value === type; /** * NO SENTENCE UNDER THE RADIOS. It restated the type just chosen — the row is already labelled * and already carries its own one-line description — so it was the choice read back to the * person who had just made it (Jesse, 2026-08-30: "It's obvious from what they selected above * what they're playing. There's no need to repeat it below."). * * The Custom case said something the radios do NOT — how many settings differ, and from which * type — and that is not lost: `form.mark` puts a hint on each row that actually differs, which * is where a reader can act on it rather than a count they would then have to go and find. */ } function selectPreset(name: PresetName): void { base = name; type = name; floorTyped = false; const values = presetSettings(name, 1, days()); form.write(values, values); refresh(); } function setBase(name: PresetName, t: GameType): void { base = name; type = t; floorTyped = false; } for (const r of typeRadios()) { /** * Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and * a table needs a server. Dimmed rather than hidden, so what this screen offers and what the * lobby offers read as one list (Jesse, 2026-08-23 — a disabled radio that looks enabled reads * as a broken one). * * NO REASON PRINTED BESIDE THEM since 2026-08-30. Each row used to gain "— use the Multiplayer * button; a table needs a server", which is three unreachable types each explaining the same * thing on a screen whose heading already says "Game type (solitaire)". Jesse: "grayed out with * no additional explanation. The explanation above… is sufficient." The lobby dims Solitaire the * same way and says nothing either, which is what lets one list serve both screens. */ if (r.value !== 'solitaire' && r.value !== 'custom') { r.disabled = true; r.closest('label')?.classList.add('disabled'); } r.onchange = () => { if (!r.checked) return; if (r.value === 'custom') { type = 'custom'; refresh(); return; } selectPreset(r.value as PresetName); }; } form.onEdit((key) => { if (key === 'minCombinedRevenue') floorTyped = true; type = 'custom'; refresh(); }); // Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself. field('days').oninput = () => { if (!floorTyped) { const values = form.read(); const want = presetSettings(base, 1, days()); form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want); } refresh(); }; // "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the // block still visible, so every screen that offers it reads the same. form.setEmployeeRotationAvailable(false); return { form, days, refresh, selectPreset, setBase }; } /** * THE COMMIT — reads a wired block's answers and turns them into a URL, the same path `?seed=` * already took: `start()` reads it back out, so there is exactly one place that turns a URL into a * game, whichever screen produced it. */ function commitNewGame(wired: WiredGameType, seedFieldValue: string): void { const asked = seedFieldValue.trim(); // A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable both // mean "surprise me", which is what leaving the box alone plainly asks for. const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked))); const settings = wired.form.read(); const rules = houseRules({ houseRules: { startingHand: settings.startingHand, extraStart: settings.extraStart, discardTimetabled: settings.discardTimetabled, revenue: { passengerPerCoach: settings.passengerPerCoach, freightPerLoad: settings.freightPerLoad, trainPerTransit: settings.trainPerTransit, }, }, }); const victory: NewGameOptions = { days: Math.max(1, wired.days()), minCombinedRevenue: settings.minCombinedRevenue, maxCollisionsPerDay: settings.maxCollisionsPerDay, maxCollisionsTotal: settings.maxCollisionsTotal, optionalRules: { reducedVisibility: settings.reducedVisibility, // Never on at a table of one, whatever the box says — the control is disabled for the same // reason, and this is the half that reaches the engine. employeeRotation: false, emergencyToolbox: settings.emergencyToolbox, }, }; clearSave(); const next = rulesToUrl(rules, victory, seed); // Assigning the search string the page ALREADY has does nothing at all, which reads as a button // that did not work — and it is the common case: deal a random seed, decide it was a bad deal, // deal another at the same settings. Reload instead, and `start()` rolls a fresh seed. if (next === location.search) location.reload(); else location.search = next; } /** * THE IN-GAME "NEW GAME" BUTTON GOES TO THE SETUP SCREEN (Jesse, 2026-08-30 — "it should not go to * a separate screen. We should reuse the Solitaire New Game Screen"). * * `#newgamedlg` used to be a third copy of the same questions and the one that drifted: it carried * multiplayer wording on a screen only a solitaire player ever sees. It is deleted; this navigates * to the screen that already asks these questions properly. * * Nothing is lost on the way: `render()` calls `save()` every frame, so the game in progress is * always on disk, and the setup screen offers "Continue saved game" to come back to it. */ const newBtn = document.getElementById('newgame'); if (newBtn) { newBtn.onclick = () => { showScreen('solitairesetup'); // IN PLACE, not a navigation: the live session stays in memory, so the fields can open on the // rules actually being played and "Continue saved game" is just showing the board // again rather than a reload and a replay. runSolitaireSetup(new URLSearchParams(), true, session.view()); }; } /** * THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is * already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their * options like the start of a multiplayer game"; "asking first is the only path"). * * Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs, * there is no saved game and no URL already carrying a deal's answers. It opens on the plain * Solitaire defaults, since there is no live game to compare against yet, and reuses the identical * `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two. */ function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame | null = null): void { const screen = document.getElementById('solitairesetup'); const dealBtn = document.getElementById('ss-deal'); if (!screen || !dealBtn) return; const ss = wireGameTypeBlock('ss-', screen); // A `?seed=` with no other rules params still means SOMETHING — a shared or bookmarked link // naming a specific deal — so it is honoured as a prefill rather than discarded because this // visit happened to be routed through the screen that now asks first. const seedField = document.getElementById('ss-seed') as HTMLInputElement | null; if (seedField) seedField.value = params.get('seed') ?? ''; /** * WHAT THE FIELDS OPEN ON, and it is not the same question in both directions. * * Reached mid-game from "New game", this opens on the rules CURRENTLY IN PLAY — that is what the * deleted dialog was good for, and losing it would make "change one dial and redeal to compare" * impossible. Reached from the splash, there is no game to read, so it opens on the plain * Solitaire defaults. */ if (live) { const daysField = document.getElementById('ss-days') as HTMLInputElement | null; if (daysField) daysField.value = String(live.days); ss.setBase('solitaire', 'solitaire'); ss.form.write(settingsOf(configFromFrame(live)), presetSettings('solitaire', 1, live.days)); ss.refresh(); } else { ss.selectPreset('solitaire'); } /** * THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all. * Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by * clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their * game one button away and needs to be told what Deal costs. * * Mid-game the game is still in memory, so going back is just showing it again. From the splash * there is nothing loaded yet, so it is a navigation to the bare URL and `start()` restores the * save — one place that turns a URL into a game, either way. */ const resumeBtn = document.getElementById('ss-resume'); const savedNote = document.getElementById('ss-saved-note'); const canResume = hasSave || live !== null; if (resumeBtn) { resumeBtn.hidden = !canResume; resumeBtn.onclick = live ? () => { showScreen('gameui'); render(); announceResumed(session.view()); } : () => void (location.search = ''); } if (savedNote) savedNote.hidden = !canResume; dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? ''); } /** * PLAYBACK SPEED — v0.8.0.3, TODO #13. * * Persisted per viewer in `Settings`, so it survives the navigation that was eating `?pace=`. The * queue reads `settings.pace` through a closure on every step, so a change here takes effect on the * very next move rather than the next game. */ const paceSlowerBtn = document.getElementById('paceslower') as HTMLButtonElement | null; const paceFasterBtn = document.getElementById('pacefaster') as HTMLButtonElement | null; const paceLabel = document.getElementById('pacelabel'); if (paceSlowerBtn && paceFasterBtn && paceLabel) { const nearestPace = (): number => { // A saved or URL value need not be on the ladder — `?pace=7` and a hand-edited setting are both // legitimate — so the buttons step from whichever preset is closest rather than refusing to move. const want = PACE_OVERRIDE ?? settings.pace; return PACE_LEVELS.reduce((best, p) => (Math.abs(p - want) < Math.abs(best - want) ? p : best), PACE_LEVELS[0]); }; const paintPace = (): void => { const p = PACE_OVERRIDE ?? settings.pace; paceLabel.textContent = p === 0 ? 'off' : `${p}×`; const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]); paceSlowerBtn.disabled = i >= PACE_LEVELS.length - 1; paceFasterBtn.disabled = i <= 0; // A `?pace=` in the URL wins over the setting, so say so rather than showing dead buttons. if (PACE_OVERRIDE !== null) { paceSlowerBtn.disabled = true; paceFasterBtn.disabled = true; paceLabel.textContent = `${PACE_OVERRIDE}× (URL)`; } }; const stepPace = (by: number): void => { const i = PACE_LEVELS.indexOf(nearestPace() as (typeof PACE_LEVELS)[number]); const next = PACE_LEVELS[Math.min(PACE_LEVELS.length - 1, Math.max(0, i + by))]; if (next === undefined) return; saveSettings({ pace: next }); paintPace(); // The row's countdown is measured in steps that will dwell, so a change to 0 empties it at once. renderWatching(); }; // Slower is a BIGGER multiplier, so "−" walks up the ladder. Labelled by what it does to the game, // not to the number: a player pressing "slower" wants to watch for longer. paceSlowerBtn.onclick = () => stepPace(1); paceFasterBtn.onclick = () => stepPace(-1); paintPace(); } 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();