/** * Browser entry point — wires the DOM to `game.ts`. * * 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 { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { play } from './sound.ts'; import type { Game } from './game.ts'; import { actionMenu, handPlayable, currentActor, fromSave, newGame, submit, toSave, view, } from './game.ts'; const SAVE_KEY = 'station-master.save.v1'; let game: Game; /** Which card or track piece is picked, waiting for a location. */ let selected: string | 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. */ let districtMode: 'auto' | 'open' | 'closed' = 'auto'; /** * Sound, OFF until asked for. * * Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio * rather than the finished thing — and a playtester who did not ask for noise should not get any. * One click in the title bar turns it on, and that click is also the gesture browsers require * before any audio may start. */ let soundOn = false; const FOCUS_PHASES = new Set(['localOps', 'loadUnload']); 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); // --------------------------------------------------------------------------- /** * THE TURN CHART — the five phases of a Stage, in order, with the current one lit. * * The title bar used to say `phase: Mainline`, which names the moment and nothing around it: a * player could not see what had already happened this Stage or what was coming. The printed game * has a turn chart for exactly this reason. * * Each phase carries a tooltip describing what actually happens in it — the first three are the * rules text; Cargo and Supervisor Shift are written from what the engine does, since the recovered * sheet does not spell them out. */ const PHASES: { key: string; label: string; tip: string; icon: string }[] = [ { key: 'localOps', label: 'Local Operations', tip: 'Switch with a train, Freight Agent operations, or draw a card. One of the three, once per Stage — this is the whole of your turn.', // a turnout: the choice of three roads icon: '', }, { key: 'newTrain', label: 'New Train', tip: 'Timetabled trains for this Stage are built. New timetabled trains are randomly placed on the timetable. Held trains are built. Extra trains are built.', // a locomotive being made up icon: '', }, { key: 'mainline', label: 'Mainline', tip: 'All trains holding at an Office or Division Point must attempt to move. This phase is automatic — you make no decisions in it, except a clearance ruling if one is called for.', // open road icon: '', }, { key: 'loadUnload', label: 'Cargo', tip: 'Your workers act, once each per Stage. A Laborer moves a load one square across MEN | AT | WORK, or starts one, or begins an unload. A Porter boards or detrains passengers, which earns Revenue in a single action.', // a loaded box on a flat icon: '', }, { key: 'shiftChange', label: 'Supervisor Shift', tip: 'End of the Stage. Every Laborer and Porter is refreshed for the next one, and every third Stage — 3, 6, 9 and 12 — the Superintendent\'s hat passes to the next player.', // a pocket watch icon: '', }, ]; function renderTurnChart(f: ReturnType): void { $('tc-day').textContent = `Day ${f.day}`; $('tc-stage').textContent = `Stage ${f.stage} of 12`; $('tc-clock').textContent = f.clock; $('tc-phase').textContent = f.phase; // WHO THE TABLE IS WAITING ON. Solitaire has one seat, but the question is the same one a // multiplayer table asks constantly, and an automatic phase is waiting on nobody. const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null); $('tc-who').textContent = actorName ?? 'nobody — the Division is running itself'; const at = PHASES.findIndex((p) => p.key === f.phaseKey); $('tc-phases').innerHTML = PHASES.map((p, i) => { const state = p.key === f.phaseKey ? 'now' : i < at ? 'done' : ''; return ( `
  • ` + `` + `${esc(p.label)}
  • ` ); }).join(''); } function start(): void { const params = new URLSearchParams(location.search); const requested = params.get('seed'); const saved = load(); if (saved && requested === null) { game = fromSave(saved); } else { // 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); game = newGame(seed); } render(); } function render(): void { const f = view(game); const menu = actionMenu(game); // 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. const chosen = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === selected); const spotsAt = new Map(); for (const sp of chosen?.spots ?? []) { const key = `${sp.coord.row},${sp.coord.col}`; spotsAt.set(key, [...(spotsAt.get(key) ?? []), sp]); } renderTurnChart(f); $('revenue').textContent = String(f.revenue); const obj = $('objective'); obj.textContent = f.objective.note; obj.className = f.objective.onPace ? 'pace good' : 'pace behind'; $('seed').textContent = String(game.seed); // -- division $('division').innerHTML = divisionSvg(f.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! }; }); grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords); // 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); } function pick(key: string, list: { label: string; index: number }[]): void { if (list.length === 1) { const intent = menu.options[list[0]!.index]; if (intent) submit(game, intent); selected = null; pendingAt = null; } else { pendingAt = key; } render(); } // -- facilities $('facs').innerHTML = f.facilities.length === 0 ? '
    no facilities yet
    ' : f.facilities .map( (x) => `
    ` + `
    ${esc(x.name)} ${esc(x.commodity)}
    ` + `
    green${boxes(x.green, x.greenCap)}
    ` + `
    MEN AT WORK` + x.maw .map( (m) => `${m ? esc(m) : '·'}`, ) .join('') + `
    ` + `
    red${boxes(x.red, x.redCap)}
    ` + `
    siding${boxes(x.track, x.trackCap)}
    ` + `
    ` + (x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') + `
    `, ) .join(''); // 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 crosshatch says "not yet playable"; it used to be a `not yet` caption, which had to be read // rather than seen. The tooltip still explains WHY, which a hatch cannot. const cardRow = (name: string, why: string, playable: boolean | null): string => `
    ` + `${esc(name)}
    `; const canPlay = handPlayable(game); $('hand').innerHTML = f.hand.length ? f.hand.map((h, i) => cardRow(h, f.handWhat[i] ?? '', canPlay[i] ?? null)).join('') : 'empty'; // Reference, not choices: the Department cards are three FACE-UP CARDS, not three decks — there // is nothing underneath them. Drawn like the hand so they read as cards, dashed and unlit so they // do not read as clickable. $('depts').innerHTML = f.departments.length ? f.departments.map((d, i) => cardRow(d, f.departmentsWhat[i] ?? '', null)).join('') : 'none'; const total = f.trackSupply.reduce((n, t) => n + t.left, 0); $('supply').innerHTML = `
    ${total} pieces left · one may be laid per turn, during the DRAW option
    ` + f.trackSupply .map( (t) => `${esc(t.piece)} ${t.left}`, ) .join(' '); $('blocked').innerHTML = f.blocked.length === 0 ? '
  • nothing blocked
  • ' : f.blocked .map((b) => `
  • ${esc(b.where)} — ${esc(b.why)}
  • `) .join(''); // -- log const log = $('log'); log.innerHTML = game.log .slice(-60) .map((l) => `
    ${esc(l.text)}
    `) .join(''); log.scrollTop = log.scrollHeight; renderDistrict(f); renderActions(menu); // 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 = game.cues.splice(0, game.cues.length); if (soundOn) for (const c of cues) play(c); save(); } function renderDistrict(f: ReturnType): void { const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open'; const sec = $('district'); if (open) sec.classList.remove('folded'); else sec.classList.add('folded'); const cars = f.cells.reduce((n, c) => n + c.cars.length, 0); const crew = f.cells.filter((c) => c.tray).length; $('districtsummary').textContent = `${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` + (crew > 0 ? ` · ${crew} crew on the board` : ''); const btn = $('districttoggle'); btn.textContent = districtMode === 'auto' ? (open ? 'auto · open' : 'auto · folded') : districtMode; btn.onclick = () => { // auto -> the opposite of what it is doing now -> back to auto. districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto'; render(); }; } function boxes(items: string[], cap: number): string { let out = ''; for (let i = 0; i < Math.max(cap, items.length); i++) { const v = items[i]; out += `${v ? esc(v) : '·'}`; } return out || '—'; } function renderActions(menu: ReturnType): void { const el = $('actions'); if (game.state.status !== 'active') { const o = game.state.outcome; el.innerHTML = `
    ` + `${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}
    ` + `final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}
    ` + ``; $('again').onclick = () => { clearSave(); location.search = ''; }; return; } 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) submit(game, intent); selected = 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. const actionButton = (label: string, index: number): string => { const cut = label.indexOf(' — '); const head = cut > 0 ? label.slice(0, cut) : label; const rest = cut > 0 ? label.slice(cut + 3) : ''; return ( `` ); }; // 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

    '; html += menu.direct .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. const blocked = menu.options[a.index]?.type === 'draw.end' && game.mustPlayCard; return blocked ? `` : actionButton(a.label, a.index); }) .join('') + `
    `, ) .join(''); // Subject first, location second. Picking a card then a square is how the choice is actually made; // one flat list of every card-square-rotation combination was unreadable. for (const group of menu.placeable) { html += `

    ${esc(group.title)}

    `; for (const item of group.items) { const open = selected === item.subjectKey; html += ``; if (open) { // Once a square is picked, show only that square's rotations — the rest is noise. const shown = pendingAt ? item.spots.filter((sp) => `${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) => ``).join('') + `
    `; } } html += `
    `; } el.innerHTML = html; for (const b of Array.from(el.querySelectorAll('button.act'))) { const node = b as HTMLElement; node.onclick = () => apply(Number(node.dataset['i'])); } for (const b of Array.from(el.querySelectorAll('button.subj'))) { const node = b as HTMLElement; node.onclick = () => { const key = node.dataset['subj'] ?? null; selected = selected === key ? null : key; pendingAt = null; render(); }; } } // --------------------------------------------------------------------------- // Saving. localStorage only — nothing leaves the browser. // --------------------------------------------------------------------------- /** * Write the game out as a file. * * The save IS the replay: a seed and the moves made, which the engine can replay exactly. A few * hundred bytes, so a finished game can be emailed or dropped on the site's replay directory — * where a rendered page would have been megabytes. */ function downloadSave(): void { const data = JSON.stringify(toSave(game), null, 1); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`; a.click(); URL.revokeObjectURL(url); } function save(): void { try { localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game))); } catch { // A full or disabled localStorage must not take the game down with it. } } function load(): ReturnType | null { try { const raw = localStorage.getItem(SAVE_KEY); return raw ? (JSON.parse(raw) as ReturnType) : 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; document.head.appendChild(pageStyle); installTooltips(); const saveBtn = document.getElementById('savefile'); if (saveBtn) saveBtn.onclick = downloadSave; const soundBtn = document.getElementById('sound'); if (soundBtn) { const paint = (): void => { soundBtn.textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted'; }; soundBtn.onclick = () => { soundOn = !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) play('stage'); }; paint(); } start();