v0.8.0 — the board replays what everyone else did, instead of arriving rearranged
TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
This commit is contained in:
co-authored by
Claude Opus 5
parent
312e0301e0
commit
02289e94b8
+260
-60
@@ -24,6 +24,8 @@ 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';
|
||||
import type { PublicDistrict } from '../sim/view.ts';
|
||||
import { createStepQueue } from './step-queue.ts';
|
||||
import { notice, prefillCode, runLobby } from './lobby.ts';
|
||||
import type { LobbyReady } from './lobby.ts';
|
||||
import {
|
||||
@@ -67,9 +69,43 @@ type Settings = {
|
||||
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||
*/
|
||||
gameCardOpen: boolean;
|
||||
/**
|
||||
* HOW FAST OTHER PEOPLE'S TURNS PLAY BACK — v0.8.0, TODO #13/#18. A multiplier over the dwell
|
||||
* table in `sim/pacing.ts`: 1 is as tabled, 0.5 is twice as fast, and **0 turns animation off**,
|
||||
* which is TODO #18's "a player who has seen it a hundred times will want it off" without a second
|
||||
* mechanism for it.
|
||||
*
|
||||
* Here rather than in the game's config, on Jesse's call 2026-09-09: dwell is presentation, not a
|
||||
* rule, and a `GameConfig` rides along in saves and replays. It is also per-viewer for the reason
|
||||
* this whole object exists — two players at one table may reasonably want different speeds.
|
||||
*/
|
||||
pace: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false, pace: 1 };
|
||||
|
||||
/**
|
||||
* `?pace=` — a per-session override that persists nothing.
|
||||
*
|
||||
* The third of the three tuning levels the design calls for (`docs/plans/jitsi-common-board.md`
|
||||
* § v0.8.0 § 5): the committed table needs a rebuild, the setting needs a click, and this needs a
|
||||
* link — which is what makes it the one that is actually useful at a playtest, where two testers can
|
||||
* be handed different speeds and compared. Follows `?seed=`, which is already the convention here.
|
||||
*
|
||||
* Read ONCE, at load. The queue asks for the pace on every step it measures, and `behind()` asks for
|
||||
* every step still queued — so parsing the query string in there meant building a `URLSearchParams`
|
||||
* a hundred times to render one row. It cannot change without a reload anyway.
|
||||
*/
|
||||
const PACE_OVERRIDE: number | null = (() => {
|
||||
try {
|
||||
const raw = new URLSearchParams(location.search).get('pace');
|
||||
if (raw === null) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
@@ -87,6 +123,11 @@ function loadSettings(): Settings {
|
||||
: DEFAULT_SETTINGS.zoom,
|
||||
gameCardOpen:
|
||||
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||
// A negative or non-finite saved value is corrupt, not a request to run time backwards.
|
||||
pace:
|
||||
typeof parsed.pace === 'number' && Number.isFinite(parsed.pace) && parsed.pace >= 0
|
||||
? parsed.pace
|
||||
: DEFAULT_SETTINGS.pace,
|
||||
};
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||
@@ -113,6 +154,147 @@ function saveSettings(patch: Partial<Settings>): void {
|
||||
*/
|
||||
let session: Session;
|
||||
|
||||
/**
|
||||
* THE ANIMATION QUEUE — v0.8.0, TODO #13/#15/#18.
|
||||
*
|
||||
* Holds the board the screen is showing, which is not always the board the game is on. One queue
|
||||
* for both session kinds: solitaire drains its own collector and a remote session reads the same
|
||||
* steps off the wire, and this cannot tell which it has (`web/step-queue.ts`).
|
||||
*
|
||||
* Reads `pace` through a function rather than a captured value, so changing the setting takes effect
|
||||
* on the next step instead of the next game. `?pace=` wins over the saved setting for this session
|
||||
* only.
|
||||
*/
|
||||
const stepQueue = createStepQueue(() => PACE_OVERRIDE ?? settings.pace);
|
||||
|
||||
/**
|
||||
* Pulls whatever the session has for us into the queue. Called on every push, before rendering.
|
||||
*
|
||||
* A RESET IS TAKEN FIRST AND SEPARATELY: it means "start over from this board", so applying it after
|
||||
* the steps that arrived with it would draw them onto a baseline they do not chain from.
|
||||
*/
|
||||
function drainIntoQueue(): void {
|
||||
const reset = session.takeDisplayReset();
|
||||
if (reset) stepQueue.reset(reset);
|
||||
stepQueue.push(session.takeDisplaySteps());
|
||||
if (stepQueue.busy()) startAnimationLoop();
|
||||
}
|
||||
|
||||
/**
|
||||
* WHOSE DISTRICT THE BOARD IS SHOWING — v0.8.0, TODO #13. Null means "your own", drawn exactly as
|
||||
* it always was.
|
||||
*
|
||||
* FOLLOW THE ACTOR (Jesse, 2026-09-09). While the queue is animating, follow the step being shown,
|
||||
* so a bot's switching turn is watched on the bot's board. At rest, follow whoever the game is
|
||||
* waiting on — which is how you watch a human opponent work in something close to real time, since
|
||||
* their steps trickle in as they click rather than arriving in a burst.
|
||||
*
|
||||
* `Frame.cells` is the VIEWER'S district and nobody else's, which is the whole reason a step stream
|
||||
* alone could not answer #13: the data would arrive with nowhere to be drawn. This is where it gets
|
||||
* drawn — from `PublicDistrict`, which `officeSvg` can render as-is because it takes board data and
|
||||
* has never needed a private viewer.
|
||||
*/
|
||||
function renderWatching(): void {
|
||||
const behind = stepQueue.behind();
|
||||
const row = $('watching');
|
||||
// Collapsed whenever the board is level with the game — which in solitaire is nearly always, and
|
||||
// between turns in multiplayer too. A row that is always there would be a row nobody reads.
|
||||
if (behind === 0) {
|
||||
row.hidden = true;
|
||||
return;
|
||||
}
|
||||
row.hidden = false;
|
||||
$('watching-behind').textContent = `${behind} behind`;
|
||||
/**
|
||||
* THE CAPTION IS #15, and this is where that item lands rather than as a line of its own.
|
||||
*
|
||||
* TODO Reference · #15 could not decide the unit — "most recent action" is right in solitaire and
|
||||
* wrong in multiplayer, where what you missed is everything that happened while you were waiting.
|
||||
* The queue IS that, so the caption simply names the step being shown, and the counter beside it
|
||||
* says how much of the wait is left.
|
||||
*/
|
||||
const showing = stepQueue.showing();
|
||||
$('watching-what').textContent = showing?.lines[0]?.text ?? '';
|
||||
/**
|
||||
* SKIP COSTS THE ANIMATION AND NEVER THE INFORMATION.
|
||||
*
|
||||
* Every line skipped is already in the History panel — the queue animates a board, it does not
|
||||
* carry the record — which is what makes this safe to press without weighing it up. Assigned each
|
||||
* render rather than once, matching how every other button on this page is wired.
|
||||
*/
|
||||
$('watching-skip').onclick = () => {
|
||||
if (stepQueue.skip()) render();
|
||||
};
|
||||
}
|
||||
|
||||
function watchedDistrict(f: Frame): PublicDistrict | null {
|
||||
const pub = stepQueue.current();
|
||||
if (!pub) return null;
|
||||
/**
|
||||
* FOLLOW WHOEVER IS ACTING. While animating that is the step on screen; at rest it is whoever the
|
||||
* game is waiting on.
|
||||
*
|
||||
* A PHASE STEP NAMES NOBODY — the Mainline advances itself — so it falls through to the actor,
|
||||
* which keeps the board where it was instead of snapping home mid-sequence.
|
||||
*/
|
||||
let player: PlayerIndex | null = f.actor;
|
||||
if (stepQueue.busy()) {
|
||||
const acting = stepQueue.showing()?.player;
|
||||
if (acting !== undefined && acting !== null) player = acting;
|
||||
}
|
||||
if (player === null || player === f.viewer) return null;
|
||||
return pub.districts.find((d) => d.player === player) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the queue from the browser's own frame clock, ON DEMAND.
|
||||
*
|
||||
* The queue owns no timer of its own — that is what makes it testable without faking one — so
|
||||
* something has to advance it. This runs only while there is a backlog and stops itself when the
|
||||
* board catches up, for two reasons beyond tidiness:
|
||||
*
|
||||
* - **Loops must not accumulate.** `startAnimationLoop` is reachable from both session kinds, and
|
||||
* a player can go lobby → game → lobby → game in one page load. A loop started per game and
|
||||
* never stopped would leave one running per visit, each calling `render()` forever.
|
||||
* - An idle table should do nothing at all. Solitaire between clicks, and multiplayer between
|
||||
* turns, is the common case.
|
||||
*
|
||||
* `requestAnimationFrame` may be absent — the static build is loaded head-first by `test/web.test.ts`
|
||||
* against a DOM stub. Nothing here is required for correctness; without it the board simply arrives
|
||||
* without being animated, which is exactly what `pace = 0` does on purpose.
|
||||
*/
|
||||
let animating = false;
|
||||
function startAnimationLoop(): void {
|
||||
if (animating || typeof requestAnimationFrame !== 'function') return;
|
||||
animating = true;
|
||||
const tick = (now: number): void => {
|
||||
try {
|
||||
if (stepQueue.advance(now)) render();
|
||||
} catch (err) {
|
||||
/**
|
||||
* A BROKEN QUEUE MUST NOT TAKE THE GAME WITH IT, or wedge itself on.
|
||||
*
|
||||
* `applyPublicDelta` throws when a delta says "unchanged" and there is nothing to merge onto
|
||||
* — a sender/receiver disagreement about what has been delivered. The board is still correct
|
||||
* (the authoritative Frame comes down the same push and is drawn from `session.view()`); only
|
||||
* the animation is lost. Without the flag being cleared here, one throw would leave `animating`
|
||||
* true forever and no later burst would ever play.
|
||||
*/
|
||||
console.error('display queue stopped:', err);
|
||||
animating = false;
|
||||
return;
|
||||
}
|
||||
if (!stepQueue.busy()) {
|
||||
animating = false;
|
||||
// One last render so the "N behind" row collapses the moment the board is level.
|
||||
renderWatching();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -763,7 +945,8 @@ function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
// 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);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -907,7 +1090,8 @@ function start(): void {
|
||||
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);
|
||||
// Steps are pulled in BEFORE the redraw, so a push and the animation it starts land together.
|
||||
session.subscribe(() => { drainIntoQueue(); render(); });
|
||||
render();
|
||||
// Coming back to a game is not the same event as being dealt one, and the board looks identical
|
||||
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
|
||||
@@ -1073,6 +1257,7 @@ function render(): void {
|
||||
|
||||
renderTurnChart(f);
|
||||
renderPresence(f);
|
||||
renderWatching();
|
||||
$('revenue').textContent = String(f.revenue);
|
||||
/**
|
||||
* THE OBJECTIVE, WITHOUT THE COMMENTARY.
|
||||
@@ -1139,68 +1324,83 @@ function render(): void {
|
||||
: 'ATTACH TO THIS CARD';
|
||||
return [{ row: cell.row, col: cell.col, label }];
|
||||
});
|
||||
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
|
||||
/**
|
||||
* SOMEBODY ELSE'S BOARD IS READ-ONLY, and that is not a cosmetic distinction.
|
||||
*
|
||||
* No ghosts, no legal caps and no selected crew: all three are answers to "what could YOU do
|
||||
* here", computed from this seat's own menu, and drawing them over another player's district
|
||||
* would offer moves on a board you cannot play. Every click handler below is skipped for the same
|
||||
* reason — `spotsAt` holds coordinates in YOUR district, and the same coordinates exist in theirs,
|
||||
* so wiring them up would silently attach your moves to their squares.
|
||||
*/
|
||||
const watched = watchedDistrict(f);
|
||||
$('districtwho').textContent = watched ? `${watched.name}'s Office Area` : 'Your Office Area';
|
||||
grid.innerHTML = watched
|
||||
? officeSvg(watched.cells, watched.runningRow, [], [], watched.limits, null)
|
||||
: 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.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
if (!watched) {
|
||||
// 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.
|
||||
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
|
||||
const trayId = (g as HTMLElement).dataset['crew'];
|
||||
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
|
||||
}
|
||||
}
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
|
||||
for (const [key, list] of spotsAt) {
|
||||
const [gr, gc] = key.split(',').map(Number);
|
||||
const g = grid.querySelector(`g[data-cell="${gr},${gc}"]`);
|
||||
if (g) {
|
||||
g.classList.add('bs-legal');
|
||||
(g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
// Wire the targets. They are already in the SVG, so nothing is re-serialised here.
|
||||
for (const [key, list] of spotsAt) {
|
||||
if (f.cells.some((c) => `${c.row},${c.col}` === key)) continue;
|
||||
const g = grid.querySelector(`g[data-ghost="${key}"]`);
|
||||
if (g) (g as unknown as HTMLElement).onclick = () => pick(key, list);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE SWITCHING MOVE, ON THE BOARD.
|
||||
*
|
||||
* Every switching decision is about geography — which card the crew can reach, what it will couple
|
||||
* on the way, whether it can get back — and none of it was drawn: the moves were text buttons
|
||||
* reading "move to (0, -2)". The classes and the replay viewer have highlighted a position for
|
||||
* months; the play page simply never used them.
|
||||
*
|
||||
* WHERE IT IS, WHERE IT MAY GO, AND WHY NOT THE REST. A blocked card carries its reason in its own
|
||||
* tooltip, so "why can I not get into that industry?" is answered by hovering the industry.
|
||||
*/
|
||||
/**
|
||||
* ONE CREW'S SQUARES AT A TIME. Drawing every crew's reachable squares at once is worse than
|
||||
* drawing one crew's: the highlights merge into a single blob and stop meaning "here is where
|
||||
* THIS train can go", which is the whole reason they are on the board.
|
||||
*/
|
||||
const crew = pickedCrew(f);
|
||||
if (crew && !forPlay) {
|
||||
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
|
||||
// share, and outlining the card would claim it belongs to both.
|
||||
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
|
||||
if (strip) strip.classList.add('bs-from');
|
||||
for (const c of crew.to) {
|
||||
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
|
||||
if (g) g.classList.add('bs-focus');
|
||||
}
|
||||
for (const b of crew.blocked) {
|
||||
const g = grid.querySelector(`g[data-cell="${b.coord.row},${b.coord.col}"]`);
|
||||
if (!g) continue;
|
||||
// Two different things wearing two different marks. A turnout you cannot STOP on is not in
|
||||
// your way — you run through it — so it must not be drawn like an industry that is locked.
|
||||
const passable = b.kind === 'noStopping';
|
||||
g.classList.add(passable ? 'bs-nostop' : 'bs-blocked');
|
||||
const own = g.getAttribute('data-tip') ?? '';
|
||||
const head = passable ? 'NO STOPPING HERE' : 'THE CREW CANNOT MOVE HERE';
|
||||
g.setAttribute('data-tip', `${own}\n\n${head}: ${b.why}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user