v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
This commit is contained in:
+102
-5
@@ -44,9 +44,13 @@ import {
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import {
|
||||
DEFAULT_DAYS,
|
||||
DEFAULT_HOUSE_RULES,
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
HAND_LIMIT,
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
@@ -57,10 +61,19 @@ import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.t
|
||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
/**
|
||||
* The canonical "what does a fresh solitaire game look like" config — also what the New Game
|
||||
* dialog's solitaire defaults are drawn from (`main.ts`). One player, so `minCombinedRevenue` uses
|
||||
* `collectiveRevenueFloor(1, DEFAULT_DAYS)` — the same formula multiplayer configs use, just at
|
||||
* player count 1.
|
||||
*/
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
days: DEFAULT_DAYS,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, DEFAULT_DAYS),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
@@ -72,9 +85,29 @@ export const SOLO_CONFIG: GameConfig = {
|
||||
houseRules: DEFAULT_HOUSE_RULES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
|
||||
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
|
||||
* Solitaire's `pvpCardsAllowed` is not here: it is forced off (`SOLO_CONFIG`), never a player choice.
|
||||
*/
|
||||
export type NewGameOptions = {
|
||||
houseRules?: HouseRuleOverrides;
|
||||
days?: number;
|
||||
minCombinedRevenue?: number;
|
||||
maxCollisionsPerDay?: number;
|
||||
maxCollisionsTotal?: number;
|
||||
};
|
||||
|
||||
/** The same config with the New Game dialog's answers in it. */
|
||||
export function configWith(rules: HouseRuleOverrides): GameConfig {
|
||||
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
|
||||
export function configWith(opts: NewGameOptions): GameConfig {
|
||||
return {
|
||||
...SOLO_CONFIG,
|
||||
days: opts.days ?? SOLO_CONFIG.days,
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
||||
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||
houseRules: houseRules(opts.houseRules ? { houseRules: opts.houseRules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A group of legal actions of one kind, ready to put on screen. */
|
||||
@@ -237,6 +270,23 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* `newGame`'s multi-player sibling — the server session host (Phase 2) needs a `Game` wrapper for
|
||||
* more than one seat, and `newGame` hardcodes `[SOLO_PLAYER]`. Kept as a separate function rather
|
||||
* than a shared parametrized helper: `newGame` is exercised by every solitaire test and save, and
|
||||
* reordering its log-then-drain sequence to share code with this risks nothing for a marginal DRY
|
||||
* gain. `submit`/`drain`/`actionMenu`/`currentActor` all work on either unchanged, since none of them
|
||||
* know or care how many players a `Game` has.
|
||||
*/
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' });
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the engine forward until it needs a decision.
|
||||
*
|
||||
@@ -530,7 +580,16 @@ export type Menu = {
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
const { options, groups } = actionGroups(game);
|
||||
/**
|
||||
* SEAT-SAFETY. `actionGroups`/`legalActions` answer for `currentActor(game)`, not for `seat` — there
|
||||
* is exactly one acting player at a time, so a Menu built for anyone else must show no actions at
|
||||
* all, only their own hand (found the hard way: calling this for a non-acting seat used to hand
|
||||
* back the ACTOR's legal moves paired with the WRONG seat's cards). Emptying `options`/`groups` here
|
||||
* degrades every downstream computation (`direct`, `placeable`, `makeUp`) to empty for free — the
|
||||
* hand section below still reads `seat`'s own cards correctly either way.
|
||||
*/
|
||||
const isActor = seat === currentActor(game);
|
||||
const { options, groups } = isActor ? actionGroups(game) : { options: [] as Intent[], groups: [] as ActionGroup[] };
|
||||
const direct: ActionGroup[] = [];
|
||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||
|
||||
@@ -1079,3 +1138,41 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fromSave`'s multi-player sibling — Phase 3 (`docs/architecture/multiplayer.md`): "a mid-game
|
||||
* server restart is a replay rather than a recovery" (`lobby-and-sessions.md` §6). Built on
|
||||
* `newMultiplayerGame` instead of `newGame` since a saved multiplayer game did not deal to
|
||||
* `[SOLO_PLAYER]`. The engine-version check belongs to the caller (`src/server/persistence.ts`) —
|
||||
* this function only ever reconstructs from history that is already known to have been recorded
|
||||
* under the currently-running rules.
|
||||
*
|
||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
||||
* touch-in-passing.
|
||||
*/
|
||||
export function fromMultiplayerSave(
|
||||
seed: number,
|
||||
config: GameConfig,
|
||||
playerNames: string[],
|
||||
history: Intent[],
|
||||
): Game {
|
||||
const game = newMultiplayerGame(seed, config, playerNames);
|
||||
for (const intent of history) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
game.history.push(intent);
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
+263
-37
@@ -12,21 +12,89 @@ import type { Menu, Save } from './game.ts';
|
||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||
import { playCue } from './sound.ts';
|
||||
import { MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
STARTING_HAND_LABELS,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
} from '../engine/content.ts';
|
||||
import type { HouseRuleOverrides, HouseRules, RevenueRules, StartingHand } from '../engine/content.ts';
|
||||
import type { LocalSession } from './session.ts';
|
||||
import { createLocalSession } from './session.ts';
|
||||
import type { NewGameOptions } from './game.ts';
|
||||
import type { LocalSession, Session } from './session.ts';
|
||||
import { createLocalSession, createRemoteSession } from './session.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||
/** Preset board zoom levels — a fraction applied to the rendered SVG's own pixel dimensions. */
|
||||
const ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
/**
|
||||
* The game, behind the Session boundary.
|
||||
*
|
||||
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
|
||||
* new-game — all of which are local-only. The parts that draw and submit go through the plain
|
||||
* `Session` surface, which is what a remote client will provide unchanged.
|
||||
* Small persisted preferences, kept in a `localStorage` key of their own — separate from
|
||||
* `SAVE_KEY`. A save is the seed plus the intents and has to stay portable; none of this belongs
|
||||
* in it, and in a multiplayer game two players may reasonably want these set differently. Grows as
|
||||
* more of the page's display state earns a preference; `districtMode`/`soundOn`/`zoom` are the
|
||||
* first three.
|
||||
*/
|
||||
let session: LocalSession;
|
||||
type Settings = {
|
||||
districtMode: 'auto' | 'open' | 'closed';
|
||||
soundOn: boolean;
|
||||
zoom: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY);
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<Settings>) : {};
|
||||
// A missing key, a corrupt value, or a level dropped from `ZOOM_LEVELS` since it was saved all
|
||||
// fall back to the default for that one field, rather than rejecting the whole object.
|
||||
return {
|
||||
districtMode:
|
||||
parsed.districtMode === 'open' || parsed.districtMode === 'closed' ? parsed.districtMode : 'auto',
|
||||
soundOn: typeof parsed.soundOn === 'boolean' ? parsed.soundOn : DEFAULT_SETTINGS.soundOn,
|
||||
zoom:
|
||||
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
||||
? parsed.zoom
|
||||
: DEFAULT_SETTINGS.zoom,
|
||||
};
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
let settings = loadSettings();
|
||||
|
||||
function saveSettings(patch: Partial<Settings>): void {
|
||||
settings = { ...settings, ...patch };
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
/* nothing to do */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The game, behind the Session boundary — `LocalSession` (solitaire, `?seat=` absent from the URL)
|
||||
* or `RemoteSession` (`?seat=` present, Phase 2). Typed as the common `Session` surface; every
|
||||
* LocalSession-only touch (undo, local saves, dealing a new game) goes through `isLocal` below rather
|
||||
* than assuming, since `session` may now be either.
|
||||
*/
|
||||
let session: Session;
|
||||
|
||||
/**
|
||||
* The three `Capabilities` (`undo`/`saveLocal`/`newGame`) travel together — all `true` for a
|
||||
* `LocalSession`, all `false` for a `RemoteSession` (`session.ts`) — so any one of them is a safe
|
||||
* discriminant. `newGame` is used here since it reads clearly at every call site: "only if this
|
||||
* session can deal locally."
|
||||
*/
|
||||
function isLocal(s: Session): s is LocalSession {
|
||||
return s.capabilities.newGame;
|
||||
}
|
||||
/** Which card or track piece is picked, waiting for a location. */
|
||||
let selected: string | null = null;
|
||||
/**
|
||||
@@ -49,18 +117,20 @@ let pendingAt: string | null = null;
|
||||
*
|
||||
* 'auto' follows the phase; 'open' and 'closed' are the player overriding it and stay put until
|
||||
* they change it again. Display only — in a multiplayer game two players may reasonably want it
|
||||
* set differently, so this must never become part of game state.
|
||||
* set differently, so this must never become part of game state. Persisted in `settings`, not the
|
||||
* save, for exactly that reason.
|
||||
*/
|
||||
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
|
||||
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
||||
/**
|
||||
* Sound, OFF until asked for.
|
||||
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
||||
*
|
||||
* Everything it plays is synthesised rather than recorded, so it is a placeholder for real audio
|
||||
* rather than the finished thing — and a playtester who did not ask for noise should not get any.
|
||||
* One click in the title bar turns it on, and that click is also the gesture browsers require
|
||||
* before any audio may start.
|
||||
* rather than the finished thing. One click in the title bar turns it on, and that click is also
|
||||
* the gesture browsers require before any audio may start.
|
||||
*/
|
||||
let soundOn = false;
|
||||
let soundOn = settings.soundOn;
|
||||
/** Preset board zoom (see `ZOOM_LEVELS`), persisted in `settings`. */
|
||||
let zoom = settings.zoom;
|
||||
/**
|
||||
* The phase the page last drew, so a change of phase can be announced.
|
||||
*
|
||||
@@ -99,6 +169,23 @@ const $ = (id: string): HTMLElement => {
|
||||
const esc = (s: string): string =>
|
||||
s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
|
||||
/**
|
||||
* Size a freshly-rendered board SVG off its own `viewBox`, at the current `zoom`.
|
||||
*
|
||||
* `#grid` and `#division` already scroll horizontally when their content is wider than the column
|
||||
* (`overflow-x:auto` in `play.html`) — that mechanism is untouched. This only changes how big the
|
||||
* SVG itself renders, in real pixels rather than a CSS `transform` (which would leave the container's
|
||||
* scrollable area the wrong size), so zooming in genuinely grows the scrollable area and zooming out
|
||||
* genuinely shrinks it.
|
||||
*/
|
||||
function applyZoom(container: HTMLElement): void {
|
||||
const svg = container.querySelector('svg');
|
||||
const box = svg?.viewBox.baseVal;
|
||||
if (!svg || !box || box.width === 0) return;
|
||||
svg.style.width = `${box.width * zoom}px`;
|
||||
svg.style.height = `${box.height * zoom}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A DRAWING OF THE PIECE A PLACEMENT WOULD LAY.
|
||||
*
|
||||
@@ -153,19 +240,27 @@ function renderHouseRules(rules: HouseRules): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* THE HOUSE RULES TRAVEL IN THE URL, BESIDE THE SEED.
|
||||
* THE HOUSE RULES AND VICTORY DIALS TRAVEL IN THE URL, BESIDE THE SEED.
|
||||
*
|
||||
* A seed on its own no longer names a game: `?seed=430` dealt three random cards is a different
|
||||
* railroad from `?seed=430` dealt three track and three other, and at 0 Revenue per transit it is a
|
||||
* different economy again. The link has to carry all of it or "same link, same deal" stops being
|
||||
* true — and the New Game dialog navigates by URL, so this is also how its answers reach `start()`.
|
||||
* different economy again — and now a game with `minrev=15` is a different game from one with
|
||||
* `minrev=0`. The link has to carry all of it or "same link, same deal" stops being true — and the
|
||||
* New Game dialog navigates by URL, so this is also how its answers reach `start()`.
|
||||
*
|
||||
* Absent parameters mean the DEFAULTS, not the legacy rules: a bare `?seed=430` is a new game at
|
||||
* today's settings. It is a save with no rules in it that is old (`game.ts`, `configFor`).
|
||||
*/
|
||||
const RULE_PARAMS = { passenger: 'passengerPerCoach', freight: 'freightPerLoad', transit: 'trainPerTransit' } as const;
|
||||
/** The four victory-condition dials added 2026-08-20 (`GameConfig`), same URL-round-trip convention. */
|
||||
const VICTORY_PARAMS = {
|
||||
days: 'days',
|
||||
minrev: 'minCombinedRevenue',
|
||||
colday: 'maxCollisionsPerDay',
|
||||
coltotal: 'maxCollisionsTotal',
|
||||
} as const;
|
||||
|
||||
function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
||||
const rules: HouseRuleOverrides = {};
|
||||
const hand = params.get('hand');
|
||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||
@@ -178,29 +273,62 @@ function rulesFromUrl(params: URLSearchParams): HouseRuleOverrides {
|
||||
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) revenue[key] = Number(raw);
|
||||
}
|
||||
if (Object.keys(revenue).length > 0) rules.revenue = revenue;
|
||||
return rules;
|
||||
|
||||
const options: NewGameOptions = { houseRules: rules };
|
||||
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
||||
const raw = params.get(param);
|
||||
// Negative or fractional values from a hand-edited URL are clamped the same way `configWith`'s
|
||||
// defaults are — a stray `minrev=-5` should mean "off-ish", not a config the engine never sees.
|
||||
if (raw !== null && raw.trim() !== '' && Number.isFinite(Number(raw))) {
|
||||
options[key] = Math.max(0, Math.round(Number(raw)));
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function rulesToUrl(rules: HouseRules, seed: string): string {
|
||||
function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (seed !== '') params.set('seed', seed);
|
||||
params.set('hand', rules.startingHand);
|
||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
||||
const value = options[key];
|
||||
if (value !== undefined) params.set(param, String(value));
|
||||
}
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `?seat=` PRESENT means multiplayer (D4 — one bundle, runtime switch). The seed, house rules and
|
||||
* URL-carried save/restore logic below are all solitaire concepts: a remote game's rules come from
|
||||
* whatever the server was configured with, not from this browser's URL or `localStorage`.
|
||||
*/
|
||||
function start(): void {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const requested = params.get('seed');
|
||||
const seatParam = params.get('seat');
|
||||
|
||||
if (seatParam !== null) {
|
||||
const seat = Number(seatParam) as PlayerIndex;
|
||||
session = createRemoteSession(seat, params.get('secret') ?? '');
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
// for `subscribe`'s callback rather than firing immediately (`session.ts`'s own doc comment on
|
||||
// `createRemoteSession` explains why `view()` would otherwise throw).
|
||||
session.subscribe(render);
|
||||
return;
|
||||
}
|
||||
|
||||
const requested = params.get('seed');
|
||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
session = createLocalSession(seed, rulesFromUrl(params));
|
||||
const local = createLocalSession(seed, gameOptionsFromUrl(params));
|
||||
session = local;
|
||||
|
||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
||||
const saved = load();
|
||||
if (saved && requested === null) session.restore(saved);
|
||||
if (saved && requested === null) local.restore(saved);
|
||||
|
||||
applyCapabilities();
|
||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||
@@ -263,11 +391,14 @@ function render(): void {
|
||||
const obj = $('objective');
|
||||
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
|
||||
obj.className = 'pace';
|
||||
$('seed').textContent = String(session.seed());
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`;
|
||||
renderHouseRules(f.houseRules);
|
||||
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division);
|
||||
applyZoom($('division'));
|
||||
|
||||
// -- board. Both renderers are shared with the replay so the two can never draw different
|
||||
// pictures of the same position.
|
||||
@@ -302,6 +433,7 @@ function render(): void {
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
applyZoom(grid);
|
||||
|
||||
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
|
||||
// you switching?" picker writes, so the board and the action panel drive one value either way.
|
||||
@@ -593,14 +725,18 @@ function render(): void {
|
||||
*/
|
||||
function renderUndo(): void {
|
||||
const btn = document.getElementById('undo') as HTMLButtonElement | null;
|
||||
if (!btn || !session.capabilities.undo) return;
|
||||
const n = session.steps();
|
||||
if (!btn || !isLocal(session)) return;
|
||||
// Captured as a `const` rather than read as `session` again inside the closure below: `session` is
|
||||
// a mutable module-level `let`, so TypeScript cannot carry the `isLocal` narrowing across a closure
|
||||
// boundary — a local const it can never see reassigned keeps the narrowed `LocalSession` type.
|
||||
const local = session;
|
||||
const n = local.steps();
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
|
||||
btn.onclick = () => {
|
||||
// The session drops the rebuilt game's cues and draws — replaying the history re-records them,
|
||||
// and none of it is news to a player who just stepped back.
|
||||
if (!session.undo()) return;
|
||||
if (!local.undo()) return;
|
||||
selected = null;
|
||||
mode = null;
|
||||
pendingAt = null;
|
||||
@@ -657,6 +793,7 @@ function renderDistrict(f: Frame): void {
|
||||
btn.onclick = () => {
|
||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||
saveSettings({ districtMode });
|
||||
render();
|
||||
};
|
||||
}
|
||||
@@ -980,6 +1117,10 @@ function renderActions(
|
||||
* where a rendered page would have been megabytes.
|
||||
*/
|
||||
function downloadSave(): void {
|
||||
// The button this fires from is hidden by `applyCapabilities()` for any session that cannot save
|
||||
// (`#savefile`), but nothing stops this function being called directly, so the guard is repeated
|
||||
// here rather than only trusted to the DOM.
|
||||
if (!isLocal(session)) return;
|
||||
const data = JSON.stringify(session.save(), null, 1);
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -991,7 +1132,7 @@ function downloadSave(): void {
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!session.capabilities.saveLocal) return;
|
||||
if (!isLocal(session)) return;
|
||||
try {
|
||||
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
|
||||
} catch {
|
||||
@@ -1043,32 +1184,79 @@ const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
type Mode = 'solitaire' | 'competitive' | 'coop';
|
||||
|
||||
/**
|
||||
* ASK FOR ALL THREE, rather than documenting URL parameters in the title bar.
|
||||
* PICKING A TYPE JUST SETS THE FIELDS BELOW TO THAT TYPE'S DEFAULTS (Jesse's design, 2026-08-20) —
|
||||
* every number stays editable afterward, so "Competitive" isn't a fixed ruleset, it's a starting
|
||||
* point. `players = 4` for Competitive/Co-op is a nominal stand-in: there's no lobby yet to ask who
|
||||
* is actually seated (Phase 4), so this is a suggestion a real seat count will replace.
|
||||
*
|
||||
* Only Solitaire can be dealt today — Deal disables itself for the other two, with a note, rather
|
||||
* than pretending a click would do something (`RemoteSession` is Phase 2).
|
||||
*/
|
||||
function applyModePreset(mode: Mode): void {
|
||||
const days = 5;
|
||||
const players = mode === 'solitaire' ? 1 : 4;
|
||||
field<HTMLInputElement>('ng-days').value = String(days);
|
||||
field<HTMLInputElement>('ng-minrev').value = String(collectiveRevenueFloor(players, days));
|
||||
field<HTMLInputElement>('ng-colday').value = String(DEFAULT_MAX_COLLISIONS_PER_DAY);
|
||||
field<HTMLInputElement>('ng-coltotal').value = String(DEFAULT_MAX_COLLISIONS_TOTAL);
|
||||
|
||||
// No valid target for these cards in Solitaire or Co-op — forced off, not merely defaulted off.
|
||||
const pvp = field<HTMLInputElement>('ng-pvp');
|
||||
pvp.checked = mode === 'competitive';
|
||||
pvp.disabled = mode !== 'competitive';
|
||||
|
||||
field<HTMLButtonElement>('ng-deal').disabled = mode !== 'solitaire';
|
||||
field<HTMLElement>('ng-multiplayer-note').style.visibility = mode === 'solitaire' ? 'hidden' : 'visible';
|
||||
}
|
||||
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
||||
input.onchange = () => applyModePreset(input.value as Mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
|
||||
*
|
||||
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
|
||||
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
|
||||
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
|
||||
*
|
||||
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
|
||||
* compare against the first is the common case, and re-entering four settings each time is how a
|
||||
* comparison silently stops comparing.
|
||||
* compare against the first is the common case, and re-entering settings each time is how a
|
||||
* comparison silently stops comparing. Mode always reopens on Solitaire — it's the only one a
|
||||
* previous session could actually have been, since Deal is disabled for the other two.
|
||||
*/
|
||||
newBtn.onclick = () => {
|
||||
const f = session.view();
|
||||
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
|
||||
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
|
||||
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
|
||||
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
|
||||
if (!isLocal(session)) return;
|
||||
const local = session;
|
||||
const f = local.view();
|
||||
const day = f.day;
|
||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
|
||||
const current = session.view().houseRules;
|
||||
const current = f.houseRules;
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-mode"]')) {
|
||||
input.checked = input.value === 'solitaire';
|
||||
}
|
||||
applyModePreset('solitaire');
|
||||
for (const input of dlg.querySelectorAll<HTMLInputElement>('input[name="ng-hand"]')) {
|
||||
input.checked = input.value === current.startingHand;
|
||||
}
|
||||
field<HTMLInputElement>('ng-passenger').value = String(current.revenue.passengerPerCoach);
|
||||
field<HTMLInputElement>('ng-freight').value = String(current.revenue.freightPerLoad);
|
||||
field<HTMLInputElement>('ng-transit').value = String(current.revenue.trainPerTransit);
|
||||
// Overwrite the preset with the actual rules in play — solitaire is the only real session today.
|
||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||
field<HTMLInputElement>('ng-minrev').value = String(f.minCombinedRevenue);
|
||||
field<HTMLInputElement>('ng-colday').value = String(f.maxCollisionsPerDay);
|
||||
field<HTMLInputElement>('ng-coltotal').value = String(f.maxCollisionsTotal);
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
@@ -1078,6 +1266,8 @@ if (newBtn && dlg) {
|
||||
*
|
||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
||||
* Deal is disabled whenever the mode radio isn't Solitaire, so this never actually runs for the
|
||||
* other two — nothing here needs to branch on mode.
|
||||
*/
|
||||
dlg.addEventListener('close', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
@@ -1097,9 +1287,15 @@ if (newBtn && dlg) {
|
||||
},
|
||||
},
|
||||
});
|
||||
const victory: NewGameOptions = {
|
||||
days: Math.max(1, Math.round(Number(field<HTMLInputElement>('ng-days').value)) || 5),
|
||||
minCombinedRevenue: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-minrev').value)) || 0),
|
||||
maxCollisionsPerDay: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-colday').value)) || 0),
|
||||
maxCollisionsTotal: Math.max(0, Math.round(Number(field<HTMLInputElement>('ng-coltotal').value)) || 0),
|
||||
};
|
||||
|
||||
clearSave();
|
||||
const next = rulesToUrl(rules, seed);
|
||||
const next = rulesToUrl(rules, victory, seed);
|
||||
// Assigning the search string the page ALREADY has does nothing at all, which reads as a button
|
||||
// that did not work — and it is the common case: deal a random seed, decide it was a bad deal,
|
||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||
@@ -1108,6 +1304,35 @@ if (newBtn && dlg) {
|
||||
});
|
||||
}
|
||||
|
||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||
const zoomInBtn = document.getElementById('zoomin') as HTMLButtonElement | null;
|
||||
const zoomLabel = document.getElementById('zoomlabel');
|
||||
if (zoomOutBtn && zoomInBtn && zoomLabel) {
|
||||
const paintZoom = (): void => {
|
||||
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
|
||||
zoomOutBtn.disabled = zoom <= ZOOM_LEVELS[0]!;
|
||||
zoomInBtn.disabled = zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]!;
|
||||
};
|
||||
const setZoom = (level: number): void => {
|
||||
zoom = level;
|
||||
saveSettings({ zoom });
|
||||
paintZoom();
|
||||
// Re-apply to whichever boards are already on the page — no full re-render needed, this is
|
||||
// display-only sizing, same as `render()`'s own calls after each innerHTML assignment.
|
||||
applyZoom($('division'));
|
||||
applyZoom($('grid'));
|
||||
};
|
||||
zoomOutBtn.onclick = () => {
|
||||
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
|
||||
if (i > 0) setZoom(ZOOM_LEVELS[i - 1]!);
|
||||
};
|
||||
zoomInBtn.onclick = () => {
|
||||
const i = ZOOM_LEVELS.indexOf(zoom as (typeof ZOOM_LEVELS)[number]);
|
||||
if (i >= 0 && i < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[i + 1]!);
|
||||
};
|
||||
paintZoom();
|
||||
}
|
||||
|
||||
const soundBtn = document.getElementById('sound');
|
||||
if (soundBtn) {
|
||||
const paint = (): void => {
|
||||
@@ -1115,6 +1340,7 @@ if (soundBtn) {
|
||||
};
|
||||
soundBtn.onclick = () => {
|
||||
soundOn = !soundOn;
|
||||
saveSettings({ soundOn });
|
||||
paint();
|
||||
// Confirm the change audibly — the one press where a sound is unambiguously wanted, and it
|
||||
// doubles as the user gesture the browser needs before any audio may start.
|
||||
|
||||
@@ -33,6 +33,9 @@ header button:hover{border-color:#4d6fa8}
|
||||
stops inviting the press. */
|
||||
header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
|
||||
header button:disabled:hover{border-color:#2c333d}
|
||||
.zoom{display:inline-flex;align-items:center;gap:4px}
|
||||
.zoom button{padding:3px 9px;line-height:1}
|
||||
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
|
||||
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
|
||||
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
|
||||
.home:hover{color:#5aa9e6}
|
||||
@@ -221,6 +224,12 @@ ul.blocked li{padding:2px 0}
|
||||
to keep the header on one line; the tooltip spells it out. -->
|
||||
<span class="dim" id="houserules" title="">—</span>
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||
rendered SVG's own pixel dimensions; the existing scrollbar keeps doing the panning. -->
|
||||
<span class="zoom" title="Zoom the Division map and your Office Area. Both already scroll — this only changes their size.">
|
||||
<button id="zoomout" aria-label="Zoom out">−</button><span id="zoomlabel">100%</span><button id="zoomin" aria-label="Zoom in">+</button>
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
@@ -309,6 +318,15 @@ ul.blocked li{padding:2px 0}
|
||||
<form method="dialog" id="newgameform">
|
||||
<h2 class="big" id="ng-title">New game</h2>
|
||||
|
||||
<h3>Game type</h3>
|
||||
<p class="ng-note">Picking a type just sets the fields below to that type's defaults — every number stays yours to change afterward. Solitaire is the only type that can be dealt today; Competitive and Co-op need a server (coming soon).</p>
|
||||
<label class="ng-radio"><input type="radio" name="ng-mode" value="solitaire" checked>
|
||||
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. Everything below is real today.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-mode" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins, unless the table misses the combined minimum — then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-mode" value="coop">
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone's Revenue counts as one table score, against the same kind of combined minimum.</span></span></label>
|
||||
|
||||
<h3>Seed</h3>
|
||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed">
|
||||
@@ -332,7 +350,22 @@ ul.blocked li{padding:2px 0}
|
||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the Division — the one thing nobody has to work for. It defaults to 0 for that reason.</p>
|
||||
|
||||
<h3>Victory conditions</h3>
|
||||
<p class="ng-note">How long the game runs, and the ways it can end. 0 turns any of these off. The combined-Revenue suggestion updates for Competitive/Co-op — it assumes a 4-player table until there's a lobby to ask who's actually seated.</p>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Minimum combined Revenue to avoid a loss</span>
|
||||
<input id="ng-minrev" type="number" min="0" step="1" value="15"></label>
|
||||
<label class="ng-num"><span>Collisions in one Day that end the game</span>
|
||||
<input id="ng-colday" type="number" min="0" step="1" value="3"></label>
|
||||
<label class="ng-num"><span>Collisions across the whole game that end it</span>
|
||||
<input id="ng-coltotal" type="number" min="0" step="1" value="5"></label>
|
||||
<label class="ng-num"><span>Allow the opponent-directed cards</span>
|
||||
<input id="ng-pvp" type="checkbox"></label>
|
||||
<p class="ng-note" id="ng-pvp-note">Not yet built (<code>TODO.md</code>) — this has no effect either way until then.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Multiplayer needs a server — coming soon.</span>
|
||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||
</menu>
|
||||
|
||||
+83
-9
@@ -18,8 +18,9 @@
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
import type { PlayerIndex } from '../engine/state.ts';
|
||||
import type { HouseRuleOverrides } from '../engine/content.ts';
|
||||
import type { Game, Menu, Save } from './game.ts';
|
||||
import { applyDelta } from '../sim/frame-delta.ts';
|
||||
import type { FrameDelta } from '../sim/frame-delta.ts';
|
||||
import type { Game, Menu, NewGameOptions, Save } from './game.ts';
|
||||
import {
|
||||
actionMenu,
|
||||
configWith,
|
||||
@@ -115,14 +116,14 @@ export type LocalSession = Session & {
|
||||
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
|
||||
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
|
||||
*
|
||||
* It takes `rules` rather than a whole `GameConfig` because DEALING is the only thing on the far
|
||||
* side of this that the page is allowed to decide. A config carries the mode, the victory condition
|
||||
* and the optional rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build
|
||||
* meant importing the engine's own defaults into the page, which is the boundary `session.test.ts`
|
||||
* guards. The seed and the house rules are the two things a player picks when they press New game.
|
||||
* It takes `NewGameOptions` rather than a whole `GameConfig` because DEALING is the only thing on the
|
||||
* far side of this that the page is allowed to decide. A config carries the mode and the optional
|
||||
* rules — table settings a lobby owns — and handing main.ts a `GameConfig` to build meant importing
|
||||
* the engine's own defaults into the page, which is the boundary `session.test.ts` guards. The seed,
|
||||
* the house rules and the victory-condition dials are what a player picks when they press New game.
|
||||
*/
|
||||
export function createLocalSession(seed: number, rules?: HouseRuleOverrides): LocalSession {
|
||||
let game: Game = rules ? newGame(seed, configWith(rules)) : newGame(seed);
|
||||
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
|
||||
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
@@ -187,3 +188,76 @@ export function createLocalSession(seed: number, rules?: HouseRuleOverrides): Lo
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The push envelope `src/server/session.ts` sends over SSE — mirrored here rather than imported,
|
||||
* so this file never depends on anything under `src/server/` even at the type level. */
|
||||
type Push = { frame: FrameDelta; menu: Menu | null; lines: { text: string; tone: string }[] };
|
||||
|
||||
/**
|
||||
* A session backed by a server (Phase 2). Holds no authoritative state — no deck order, no other
|
||||
* seat's hand — only the last `Frame`/`Menu` a push actually told it. `capabilities` are all `false`:
|
||||
* undo would have to un-see what other players already saw, a local save is meaningless when the
|
||||
* server is the store, and dealing a new game is the lobby's job (Phase 4).
|
||||
*
|
||||
* Construction is synchronous (the `Session` interface has no async surface), but the first real
|
||||
* `Frame` only exists once the SSE connection's first push arrives — `main.ts` accounts for this by
|
||||
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
|
||||
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
|
||||
*/
|
||||
export function createRemoteSession(seat: PlayerIndex, secret: string): Session {
|
||||
let frame: Frame | null = null;
|
||||
let menu: Menu | null = null;
|
||||
let lines: { text: string; tone: string }[] = [];
|
||||
let nextSeq = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
const changed = (): void => {
|
||||
for (const fn of [...listeners]) fn();
|
||||
};
|
||||
|
||||
const qs = `seat=${seat}&secret=${encodeURIComponent(secret)}`;
|
||||
const source = new EventSource(`/api/stream?${qs}`);
|
||||
source.onmessage = (ev: MessageEvent<string>) => {
|
||||
const push = JSON.parse(ev.data) as Push;
|
||||
frame = applyDelta(frame, push.frame);
|
||||
menu = push.menu;
|
||||
lines = [...lines, ...push.lines];
|
||||
changed();
|
||||
};
|
||||
|
||||
const need = (): Frame => {
|
||||
if (frame === null) throw new Error('RemoteSession.view() called before the first Frame arrived');
|
||||
return frame;
|
||||
};
|
||||
|
||||
return {
|
||||
view: need,
|
||||
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
||||
seat: () => seat,
|
||||
actor: () => need().actor,
|
||||
overHandLimit: () => need().overHandLimit,
|
||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||
async submit(intent: Intent): Promise<boolean> {
|
||||
const seq = nextSeq++;
|
||||
const res = await fetch(`/api/intent?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seq, intent }),
|
||||
});
|
||||
const result = (await res.json()) as { ok: boolean; code?: string };
|
||||
// The visible update arrives via the SSE push (broadcast to every seat, including this one),
|
||||
// not from this response — this only reports whether the rules accepted it.
|
||||
return result.ok;
|
||||
},
|
||||
subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
capabilities: { undo: false, saveLocal: false, newGame: false },
|
||||
|
||||
lines: () => lines,
|
||||
takeCues: () => [],
|
||||
takeScheduled: () => null,
|
||||
takeAnnouncement: () => null,
|
||||
justDrawn: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user