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
+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.
*