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:
Jesse.Markowitz
2026-09-09 15:31:46 -04:00
co-authored by Claude Opus 5
parent 312e0301e0
commit 02289e94b8
25 changed files with 2669 additions and 145 deletions
+74 -4
View File
@@ -23,7 +23,7 @@
* folding events does not rebuild a game — `protocol.md` §3.)
*/
import { pump } from '../engine/advance.ts';
import { advance, pump } from '../engine/advance.ts';
import { applyIntent } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
@@ -33,6 +33,8 @@ import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
import { playerAtSeat } from '../engine/state.ts';
import { cuesFor, narrate } from '../sim/narrate.ts';
import { collectStep, newCollector } from '../sim/display-step.ts';
import type { DisplayCollector } from '../sim/display-step.ts';
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
// which would pull node:fs into a browser bundle.
import {
@@ -276,6 +278,16 @@ export type Game = {
* having taken a turn to cause it.
*/
announced: string | null;
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. One per accepted intent, so a player can WATCH
* what everyone else did rather than find the board already rearranged.
*
* Accumulated here beside `log`, `cues` and `announced` and drained the same way, because that is
* how this file already hands things to whatever is displaying the game. Filled by `submit()`
* alone, which is what makes it identical for solitaire and multiplayer and inert during replay —
* see `sim/display-step.ts`.
*/
display: DisplayCollector;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -311,7 +323,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null };
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -330,7 +342,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
*/
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: [], cues: [], scheduled: null, justDrawn: null, announced: null };
const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null, display: newCollector() };
game.log.push({ text: 'Game Begins', tone: 'start' });
/**
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
@@ -1105,11 +1117,69 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
return false;
}
game.history.push(intent);
/**
* THE HIGH-WATER MARK FOR THIS STEP'S NARRATION (v0.8.0).
*
* Taken here rather than read from `session.ts`'s `sentLines`, which is per-seat and is MUTATED
* by `linesSince()` as a side effect of building a push — so it cannot answer "what did this one
* intent say?". `submit` brackets the whole thing, `record` and `drain` below are the only things
* that append, and the slice after them is exactly this intent's narration including whatever
* automatic phases it drained.
*/
const saidFrom = game.log.length;
record(game, result.events, actor);
drain(game);
collectStep(game.display, game.state, actor, intent.type, game.log.slice(saidFrom));
drainStepping(game);
return true;
}
/**
* `drain()`'s STEPPED TWIN — TODO #18, and the reason this is not just `drain(game)`.
*
* `pump()` runs every automatic phase between one click and the next and `drain()` records the whole
* batch at once, so New Train, the Mainline and the shift change are never drawn at all: trains
* cross the Division in a single jump. Stepping `advance()` one call at a time and collecting after
* each is what gives those phases a visible beat, which is exactly what TODO Reference · #18 says is
* needed — *"a minimum dwell time on its own therefore fixes nothing"*.
*
* IDENTICAL BEHAVIOUR TO `drain()`, deliberately. The same `advance()` calls in the same order
* produce the same state; `record()` is called per phase rather than per batch, which is equivalent
* because `cuesFor` is a pure per-event map with no cross-event state and `record`'s other outputs
* (`scheduled`, `justDrawn`, `announced`) are last-wins in event order either way.
*
* A PHASE THAT DID NOTHING PRODUCES NO STEP. Jesse, 2026-09-09: *"if nothing happens during a phase
* then we shouldn't lose time to it."* Narrating nothing is the test for that — an empty phase adds
* no lines, so it is skipped rather than given a dwell to sit through.
*
* `drain()` itself is untouched, and must stay that way: `fromSave`, `fromMultiplayerSave` and
* `undo` all use it, and the collector staying off those paths is what keeps a replay from
* re-emitting a whole game as steps.
*/
function drainStepping(game: Game): void {
for (let i = 0; i < 10_000; i++) {
const from = game.log.length;
const r = advance(game.state);
record(game, r.events);
/**
* THE TEST IS THE EVENT LIST, NOT THE LOG — and getting that wrong drifted the board.
*
* `record()` deliberately drops `actorChanged` before narrating, so a phase whose only effect is
* handing the turn to the next player grows no lines at all. Collecting only when the log grew
* therefore skipped those, and the last step's frame was then a position behind the real one:
* the animated board ended a turn out of step with the game (`actor: 2` where the game said 1).
*
* A step whose narration is empty still carries the board. It simply costs no time to show —
* `dwellForStep` gives a silent step a dwell of zero — which is the same rule that collapses an
* empty phase, arrived at from the other direction.
*/
if (r.events.length > 0) {
collectStep(game.display, game.state, null, 'phase', game.log.slice(from));
}
if (r.needsInput || game.state.status === 'finished') return;
}
throw new Error('phase driver failed to settle — probable infinite loop');
}
/**
* Which cards in hand can be played RIGHT NOW, in hand order.
*
+260 -60
View File
@@ -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}`);
}
}
}
+22 -1
View File
@@ -114,6 +114,17 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
.lb-seat:last-child{border-bottom:none}
.lb-seat .who{flex:1}
#presence{color:#e0b060;font-size:12px;padding:0 14px;empty-cells:hide}
/* WHAT YOU ARE WATCHING — v0.8.0, TODO #13/#15. An IN-FLOW row rather than a floating banner like
#phasenote and #announce: those announce a moment and fade, this one stands for as long as the
board is behind and has a button you have to be able to hit. Amber on the button because amber
already means clickable everywhere else on this page; the row itself stays quiet so it does not
compete with the three banners it sits under. */
#watching{display:flex;align-items:center;gap:10px;padding:4px 14px;font-size:12px;color:#9aa0b4}
#watching[hidden]{display:none}
#watching-what{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.wbehind{font-variant-numeric:tabular-nums;font-weight:700;color:#c9cee0;
background:#22263a;border:1px solid #343a52;border-radius:10px;padding:1px 8px;white-space:nowrap}
#watching-skip{font-size:11px;padding:2px 10px;border-color:#e0b060;color:#e0b060;flex:none}
#presence:empty{display:none}
/* division strip */
#division{display:flex;gap:7px;overflow-x:auto;padding-bottom:4px}
@@ -890,12 +901,22 @@ ul.blocked li{padding:2px 0}
collapsed whenever everyone connected is still connected — a `LocalSession` never fills it. -->
<div id="presence"></div>
<!-- WHAT YOU ARE WATCHING, and how far behind the board is — v0.8.0, TODO #13/#15.
One row rather than three additions: the countdown, the caption naming the action being shown,
and Skip. Empty and collapsed whenever the board is level with the game, which in solitaire is
almost always. -->
<div id="watching" hidden>
<span id="watching-behind" class="wbehind"></span>
<span id="watching-what"></span>
<button id="watching-skip" class="ghost" type="button" title="Stop animating and jump the board to where the game actually is. Nothing is lost — every line is already in the History panel.">Skip</button>
</div>
<main>
<div>
<section><h2>The Division — west to east</h2><div id="division"></div>
<p class="ng-note" id="seating-chain"></p></section>
<section id="district">
<h2>Your Office Area
<h2><span id="districtwho">Your Office Area</span>
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
</h2>
+70 -1
View File
@@ -16,7 +16,10 @@
*/
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { Frame, PublicFrame } from '../sim/view.ts';
import { publicSnapshot } from '../sim/view.ts';
import { takeSteps } from '../sim/display-step.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import type { PlayerIndex } from '../engine/state.ts';
import { applyDelta } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
@@ -105,6 +108,28 @@ export type Session = {
* starts — which is exactly when "is everyone here?" is the question.
*/
presence(): { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13. What everyone else did, in order, so it can be
* WATCHED rather than discovered.
*
* On the interface rather than on `LocalSession`, which is the whole point: solitaire drains its
* own collector and a remote session reads the same steps off `Push.steps`, so the page animates
* one queue and cannot tell which it has. That is what makes TODO #18 (solitaire's phases flying
* past) and TODO #13 (multiplayer's invisible turns) the same code path.
*
* NOT `steps()` — `LocalSession.steps()` already exists and counts submitted intents for the Undo
* button. Different thing entirely, hence the longer name.
*/
takeDisplaySteps(): DisplayStep[];
/**
* A public frame to start the queue from, once — draining, and non-null only when the queue must
* be RESET rather than advanced.
*
* Steps carry deltas against a chain, so a client with no baseline cannot merge the next one. That
* happens on a first connect, on a reconnect, and locally after an undo or a restore — all of
* which rebuild from scratch. A reset means "throw away what is queued and draw this".
*/
takeDisplayReset(): PublicFrame | null;
/**
* Stop listening, for good.
*
@@ -145,6 +170,12 @@ export type LocalSession = Session & {
*/
export function createLocalSession(seed: number, options?: NewGameOptions): LocalSession {
let game: Game = options ? newGame(seed, configWith(options)) : newGame(seed);
/**
* The baseline the step queue starts from. Set here, and again whenever the game is REPLACED —
* `undo` and `restore` rebuild by replaying history, which (by design) collects no steps, so the
* queue has to be told to start over rather than left holding a chain that no longer continues.
*/
let pendingReset: PublicFrame | null = publicSnapshot(game.state);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
@@ -186,6 +217,14 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
},
justDrawn: () => game.justDrawn,
presence: () => [],
// Solitaire's own steps, from the same collector `submit()` fills for every seat of a
// multiplayer game. No separate code path — see `sim/display-step.ts`.
takeDisplaySteps: () => takeSteps(game.display),
takeDisplayReset: () => {
const reset = pendingReset;
pendingReset = null;
return reset;
},
seed: () => game.seed,
save: () => toSave(game),
@@ -199,6 +238,8 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
back.scheduled = null;
back.justDrawn = null;
back.announced = null;
// The rebuilt game has an empty collector and a chain that starts over, so the queue must too.
pendingReset = publicSnapshot(back.state);
changed();
return true;
},
@@ -207,6 +248,7 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
game.announced = null;
pendingReset = publicSnapshot(game.state);
changed();
},
};
@@ -220,6 +262,10 @@ type Push = {
lines: { text: string; tone: string }[];
/** One entry for a change; every other seat at once on the connect push. */
presence?: { seat: PlayerIndex; connected: boolean; seen: boolean }[];
/** Ordered presentation steps — v0.8.0, identical in every seat's push because they are public. */
steps?: DisplayStep[];
/** The baseline for the step queue, sent on a connect only. */
publicReset?: PublicFrame;
/**
* THE FOUR TRANSIENT SIGNALS, added 2026-08-23.
*
@@ -270,6 +316,8 @@ export function createRemoteSession(
let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = [];
const presence = new Map<PlayerIndex, { connected: boolean; seen: boolean }>();
let displaySteps: DisplayStep[] = [];
let displayReset: PublicFrame | null = null;
let cues: string[] = [];
let scheduled: number | null = null;
let announcement: string | null = null;
@@ -324,6 +372,21 @@ export function createRemoteSession(
if (push.announcement !== undefined && push.announcement !== null) announcement = push.announcement;
// Persists until another draw replaces it, matching the local session's own `justDrawn`.
if (push.justDrawn !== undefined) justDrawnCard = push.justDrawn;
/**
* A RESET DISCARDS WHAT WAS QUEUED, rather than arriving alongside it.
*
* `publicReset` comes on a connect, which is also a RECONNECT — and a reconnecting client's
* queue holds steps whose deltas chain off a baseline the server has since moved past. Merging
* them onto the new baseline would draw a board that never existed. The history panel is what
* carries what was missed; the animation does not replay it (§ v0.8.0).
*/
if (push.publicReset) {
displayReset = push.publicReset;
displaySteps = [];
}
// Accumulated, like cues: two pushes can land between two renders and every step is one thing
// that happened.
if (push.steps) displaySteps = [...displaySteps, ...push.steps];
changed();
};
@@ -376,6 +439,12 @@ export function createRemoteSession(
},
justDrawn: () => justDrawnCard,
presence: () => [...presence].map(([seat, p]) => ({ seat, connected: p.connected, seen: p.seen })),
takeDisplaySteps: () => displaySteps.splice(0, displaySteps.length),
takeDisplayReset: () => {
const reset = displayReset;
displayReset = null;
return reset;
},
close() {
// `reportedGone` first: closing the stream fires `onerror`, and this is a deliberate exit, not
// a game that vanished — `onGone` must not be called and land the page in "that game is no
+120
View File
@@ -0,0 +1,120 @@
/**
* THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6.
*
* Holds the public board the screen is currently showing, which is not always the board the game is
* actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in
* ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a
* burst into something watchable. TODO #13.
*
* TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from
* the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are
* never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here
* imports the DOM either, so it is testable without one.
*
* NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has
* — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need
* starting, stopping and cleaning up on every game replacement, and would be untestable without
* faking timers.
*/
import type { PublicFrame } from '../sim/view.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { applyPublicDelta } from '../sim/public-delta.ts';
import { dwellForStep } from '../sim/pacing.ts';
export type StepQueue = {
/** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */
reset(frame: PublicFrame): void;
/** Queue steps to be shown in order. */
push(steps: readonly DisplayStep[]): void;
/**
* Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip
* a redraw when nothing did.
*/
advance(now: number): boolean;
/** Show everything immediately. Returns true if anything was skipped. */
skip(): boolean;
/** The board to draw, or null before any reset has arrived. */
current(): PublicFrame | null;
/**
* How many queued steps the player is still going to WATCH — the number the "N behind" counter
* shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`.
*/
behind(): number;
/** The last step actually shown, for the caption line (#15). Null before anything has been shown. */
showing(): DisplayStep | null;
/** True while there is anything left to show. */
busy(): boolean;
};
/** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */
export function createStepQueue(pace: () => number = () => 1): StepQueue {
let shown: PublicFrame | null = null;
let last: DisplayStep | null = null;
let pending: DisplayStep[] = [];
/** When the step now on screen is due to give way. Null when nothing is waiting. */
let dueAt: number | null = null;
/** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */
const show = (step: DisplayStep): void => {
shown = applyPublicDelta(shown, step.frame);
last = step;
};
return {
reset(frame) {
shown = frame;
pending = [];
dueAt = null;
// `last` deliberately survives: a reconnect should not blank the caption line, and the
// sentence describing the most recent action is still true.
},
push(steps) {
pending.push(...steps);
},
advance(now) {
if (pending.length === 0) {
dueAt = null;
return false;
}
// First step of a burst: show it immediately rather than waiting out a dwell for a board the
// player has not been shown yet.
if (dueAt === null) {
const first = pending.shift()!;
show(first);
dueAt = now + dwellForStep(first, pace());
return true;
}
let drew = false;
/**
* A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on
* this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must
* collapse within one call instead of costing a frame each. The board still passes through
* every state in order; nobody is shown a state that never existed.
*/
while (pending.length > 0 && now >= dueAt) {
const next = pending.shift()!;
show(next);
dueAt = dueAt + dwellForStep(next, pace());
drew = true;
}
if (pending.length === 0 && now >= dueAt) dueAt = null;
return drew;
},
skip() {
if (pending.length === 0) return false;
for (const step of pending) show(step);
pending = [];
dueAt = null;
return true;
},
current: () => shown,
behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length,
showing: () => last,
busy: () => pending.length > 0,
};
}