v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+12 -3
View File
@@ -64,7 +64,14 @@ export const SOLO_CONFIG: GameConfig = {
export type ActionGroup = {
kind: string;
title: string;
actions: { index: number; label: string }[];
/**
* `tip` is the card's own description, resolved HERE rather than by the page.
*
* The button label is short; the hover text used to be looked up with `cardDescription(state, id)`
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
* carries it. See `docs/architecture/multiplayer.md` §5.
*/
actions: { index: number; label: string; tip?: string }[];
};
export type Game = {
@@ -175,13 +182,15 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
if (actor === null) return { options: [], groups: [] };
const options = legalActions(game.state, actor);
const byKind = new Map<string, { index: number; label: string }[]>();
const byKind = new Map<string, { index: number; label: string; tip?: string }[]>();
options.forEach((intent, index) => {
const label = describeIntent(game.state, intent);
const list = byKind.get(intent.type) ?? [];
const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
const tip = cardId ? cardDescription(game.state, cardId) : undefined;
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
// list of forty identical rows hides the real choice rather than presenting it.
if (!list.some((a) => a.label === label)) list.push({ index, label });
if (!list.some((a) => a.label === label)) list.push({ index, label, ...(tip ? { tip } : {}) });
byKind.set(intent.type, list);
});
+88 -77
View File
@@ -1,5 +1,5 @@
/**
* Browser entry point — wires the DOM to `game.ts`.
* 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.
@@ -7,26 +7,25 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts';
import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
undo,
view,
} from './game.ts';
import type { LocalSession } from './session.ts';
import { createLocalSession } from './session.ts';
const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/**
* 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.
*/
let session: LocalSession;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
@@ -108,8 +107,8 @@ function piecePreview(links: string[], label: string): string {
* 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: ReturnType<typeof view>): void {
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
@@ -117,23 +116,42 @@ function start(): void {
const params = new URLSearchParams(location.search);
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);
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
// Restoring replays the whole history, which re-records every draw along the way. Nothing on
// this screen is news to the player who left it there, so the "new card" badge starts clear.
game.justDrawn = null;
} 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);
}
if (saved && requested === null) session.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
// which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render);
render();
}
/**
* Hide the controls this session does not offer.
*
* Undo, a local save and dealing a new game are all things only a local session can do — a server
* cannot un-see what the other players have already seen, the server is the store, and dealing is
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there.
*/
function applyCapabilities(): void {
const c = session.capabilities;
const hide = (id: string, on: boolean): void => {
const el = document.getElementById(id);
if (el) el.hidden = !on;
};
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('newgame', c.newGame);
}
function render(): void {
const f = view(game);
const menu = actionMenu(game);
const f = session.view();
const menu = session.menu();
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
@@ -166,7 +184,7 @@ 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(game.seed);
$('seed').textContent = String(session.seed());
// -- division
$('division').innerHTML = divisionSvg(f.division);
@@ -255,7 +273,7 @@ function render(): void {
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);
if (intent) void session.submit(intent);
selected = null;
pendingAt = null;
} else {
@@ -296,7 +314,7 @@ function render(): void {
// 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 === game.justDrawn;
const fresh = h.cardId === session.justDrawn();
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
@@ -341,7 +359,7 @@ function render(): void {
// 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) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
@@ -374,7 +392,7 @@ function render(): void {
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
render();
@@ -397,7 +415,7 @@ function render(): void {
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
render();
};
}
@@ -409,15 +427,16 @@ function render(): void {
* `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".
*/
const justSet = game.scheduled;
game.scheduled = null;
// Draining: the flash marks the moment the die was read, not a state, so it is gone by the next
// render. Taken once here and handed to both the timetable and the action panel.
const justSet = session.takeScheduled();
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
log.innerHTML = game.log
log.innerHTML = session.lines()
.slice(-60)
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.join('');
@@ -446,7 +465,7 @@ function render(): void {
// 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);
const cues = session.takeCues();
if (soundOn) for (const c of cues) playCue(c);
save();
@@ -465,27 +484,20 @@ function render(): void {
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn) return;
const n = game.history.length;
if (!btn || !session.capabilities.undo) return;
const n = session.steps();
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => {
const back = undo(game);
if (!back) return;
game = back;
// 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;
selected = null;
mode = null;
pendingAt = null;
// The rebuilt game replays its own cues from the beginning; none of them are news. `justDrawn`
// goes with them: replaying the history re-records every draw, so it would badge whichever card
// the replay happened to end on rather than one the player just turned over.
game.cues.length = 0;
game.scheduled = null;
game.justDrawn = 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;
render();
};
}
@@ -496,7 +508,7 @@ function renderUndo(): void {
* 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: ReturnType<typeof view>): void {
function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
@@ -510,7 +522,7 @@ function renderYards(f: ReturnType<typeof view>): void {
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
function renderDistrict(f: ReturnType<typeof view>): void {
function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
@@ -539,18 +551,18 @@ function renderDistrict(f: ReturnType<typeof view>): void {
}
function renderActions(
menu: ReturnType<typeof actionMenu>,
f: ReturnType<typeof view>,
menu: Menu,
f: Frame,
justSet: number | null,
): void {
const el = $('actions');
if (game.state.status !== 'active') {
const o = game.state.outcome;
if (f.status !== 'active') {
const o = f.outcome;
el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
`<button id="again">new game</button>`;
$('again').onclick = () => {
clearSave();
@@ -566,7 +578,7 @@ function renderActions(
const apply = (index: number): void => {
const intent = menu.options[index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
@@ -582,15 +594,12 @@ function renderActions(
* 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 = (label: string, index: number): string => {
const actionButton = (a: { index: number; label: string; tip?: string }): string => {
const { label, index } = a;
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
// The menu resolved the card's description server-side, so the page never needs the state.
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
@@ -644,7 +653,7 @@ function renderActions(
// §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.label, a.index);
return actionButton(a);
})
.join('') +
`</div>`,
@@ -718,12 +727,12 @@ function renderActions(
html += `</div>`;
}
if (
game.state.clock.phase === 'localOps' &&
game.state.turn.option === 'draw' &&
f.phaseKey === 'localOps' &&
f.option === 'draw' &&
!menu.options.some((i) => i.type === 'draw.end')
) {
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
const hand = (game.state.decks.hands.get(currentActor(game) ?? 0) ?? []).length;
const hand = f.handCount;
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
@@ -749,28 +758,29 @@ function renderActions(
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
const data = JSON.stringify(toSave(game), null, 1);
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
a.click();
URL.revokeObjectURL(url);
}
function save(): void {
if (!session.capabilities.saveLocal) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch {
// A full or disabled localStorage must not take the game down with it.
}
}
function load(): ReturnType<typeof toSave> | null {
function load(): Save | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
return raw ? (JSON.parse(raw) as Save) : null;
} catch {
return null;
}
@@ -810,9 +820,10 @@ if (saveBtn) saveBtn.onclick = downloadSave;
const newBtn = document.getElementById('newgame');
if (newBtn) {
newBtn.onclick = () => {
const day = game.state.clock.day;
const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1);
if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return;
const f = session.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;
/**
* ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
*
+172
View File
@@ -0,0 +1,172 @@
/**
* The boundary between the page and the game.
*
* The page draws a `Frame` and offers a `Menu`, and submits intents. It does not care whether the
* rules are being applied a function call away or across a network — which is the whole point:
*
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
* with no server involved at any point.
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no
* authoritative state at all. It cannot: it has neither the deck order nor the other players'
* hands, and if it did the game would be cheatable.
*
* So this interface is deliberately the SMALLER of the two — everything a remote client could
* possibly offer, and nothing that only a local one can do. What a local session can do beyond it is
* declared in `capabilities`, and the page hides those controls rather than calling them and failing.
*/
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import type { Game, Menu, Save } from './game.ts';
import {
actionMenu,
currentActor,
fromSave,
handPlayable,
newGame,
overHandLimit,
submit,
toSave,
undo,
view,
} from './game.ts';
/**
* What this session can do beyond the common interface.
*
* None of these survive a server. Undo would have to un-see what other players have already seen;
* a local save is meaningless when the server is the store; and dealing a new game is the lobby's
* job. The page reads these rather than assuming, so the same code drives both.
*/
export type Capabilities = {
undo: boolean;
saveLocal: boolean;
newGame: boolean;
};
export type Session = {
/** The board as this seat sees it. */
view(): Frame;
/** What this seat may do right now. */
menu(): Menu;
/** Which seat this client is playing. */
seat(): PlayerIndex;
/** Whose turn it is, or null when the game is over or waiting on nothing. */
actor(): PlayerIndex | null;
/** True when the hand is over §6.2's limit and the turn cannot be ended. */
overHandLimit(): boolean;
/** Which cards in hand are playable right now, in hand order. */
handPlayable(): boolean[];
/**
* Propose an action. Resolves false if the rules refused it.
*
* Async because a remote session must be, even though the local one answers immediately — a page
* written against a synchronous `submit` would have to be rewritten for the server.
*/
submit(intent: Intent): Promise<boolean>;
/** Called whenever something changed and the page should redraw. Returns an unsubscribe. */
subscribe(fn: () => void): () => void;
capabilities: Capabilities;
/**
* WHAT JUST HAPPENED — three transient signals the page uses to draw a moment rather than a state.
*
* They are separate from `view()` because two of them are CONSUMED: a Stage flash and a sound play
* once and are then gone, whereas the Frame can be rebuilt any number of times per render. Putting
* them on the Frame would mean re-flashing on every redraw.
*
* A remote session fills these from the server's pushes rather than from a local event log; the
* page cannot tell the difference. Whether they eventually ride on the Frame as animation hints is
* a Phase 2 question (`docs/architecture/multiplayer.md` D3).
*/
/** The narrated history, newest last. */
lines(): { text: string; tone: string }[];
/** Sounds earned since the last call. Draining. */
takeCues(): string[];
/** The timetable slot the last 1D12 filled, once. Draining. */
takeScheduled(): number | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
};
/**
* Everything a LOCAL session can additionally do. Kept off `Session` so that reaching for one of
* these in shared page code is a type error rather than a runtime surprise against a server.
*/
export type LocalSession = Session & {
readonly game: Game;
seed(): number;
save(): Save;
/** How many intents have been submitted — what the Undo button counts down. */
steps(): number;
/** Steps back one intent by replaying the history without it. Returns false at the start. */
undo(): boolean;
restore(save: Save): void;
};
/**
* A session that owns the engine in this process.
*
* `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.
*/
export function createLocalSession(seed: number, config?: GameConfig): LocalSession {
let game: Game = config ? newGame(seed, config) : newGame(seed);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
return {
get game() {
return game;
},
view: () => view(game),
menu: () => actionMenu(game),
seat: () => 0,
actor: () => currentActor(game),
overHandLimit: () => overHandLimit(game),
handPlayable: () => handPlayable(game),
submit: async (intent: Intent) => {
const ok = submit(game, intent);
if (ok) changed();
return ok;
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
capabilities: { undo: true, saveLocal: true, newGame: true },
lines: () => game.log,
takeCues: () => game.cues.splice(0, game.cues.length),
takeScheduled: () => {
const slot = game.scheduled;
game.scheduled = null;
return slot;
},
justDrawn: () => game.justDrawn,
seed: () => game.seed,
save: () => toSave(game),
steps: () => game.history.length,
undo() {
const back = undo(game);
if (!back) return false;
game = back;
// The rebuilt game replays its own history, so every cue and draw in it is old news.
back.cues.length = 0;
back.scheduled = null;
back.justDrawn = null;
changed();
return true;
},
restore(save: Save) {
game = fromSave(save);
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
changed();
},
};
}