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:
+88
-77
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user