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
+55 -5
View File
@@ -26,8 +26,10 @@ import { actionMenu, currentActor, fromMultiplayerSave, isOutOfTurn, newMultipla
import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot, seatLabel } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { publicSnapshot, snapshot, seatLabel } from '../sim/view.ts';
import type { Frame, PublicFrame } from '../sim/view.ts';
import { takeSteps } from '../sim/display-step.ts';
import type { DisplayStep } from '../sim/display-step.ts';
import { developerBot } from '../sim/bot.ts';
export type Push = {
@@ -69,6 +71,29 @@ export type Push = {
scheduled?: number | null;
announcement?: string | null;
justDrawn?: string | null;
/**
* ORDERED PRESENTATION STEPS — v0.8.0, TODO #13.
*
* A field on `Push` rather than a second SSE event type, following `presence`'s precedent and for
* its stated reason (`http.ts`): one message shape for the client to parse. `http.ts` therefore
* needs no change at all — `broadcastGame` forwards whatever this file builds.
*
* IDENTICAL IN EVERY SEAT'S PUSH, because a step carries the PUBLIC board and nothing else. A
* player's own hand, menu and objective are not animated: they arrive on the same push, already
* coalesced, exactly as they always have. That is what keeps the redaction surface at zero new
* area — `test/redaction.test.ts` guards the projection these are built from.
*/
steps?: DisplayStep[];
/**
* The public board to start a step queue from — sent on a CONNECT, never on an update.
*
* Steps carry deltas against one chain shared by the whole table, so a client that has just
* arrived (or come back) has nothing to merge the next delta onto and `applyPublicDelta` would
* rightly throw. This is that baseline: the exact frame the chain has reached, so the next step
* lands on it. A reconnecting client resets rather than replaying what it missed — the history
* panel is what carries the words, and it is already sent whole on connect (#97).
*/
publicReset?: PublicFrame;
};
/**
@@ -211,11 +236,12 @@ function buildSession(
return { cues, scheduled, announcement };
}
function pushFor(seat: PlayerIndex, moment: Moment | null): Push {
function pushFor(seat: PlayerIndex, moment: Moment | null, steps: DisplayStep[] = []): Push {
const frame = frameFor(seat);
const delta = deltaFrame(lastFrame.get(seat) ?? null, frame);
lastFrame.set(seat, frame);
const push: Push = { frame: delta, menu: menuFor(seat), lines: linesSince(seat) };
if (steps.length > 0) push.steps = steps;
if (moment) {
if (moment.cues.length > 0) push.cues = moment.cues;
if (moment.scheduled !== null) push.scheduled = moment.scheduled;
@@ -228,9 +254,13 @@ function buildSession(
function pushesForAll(): Map<PlayerIndex, Push> {
const moment = takeMoment();
// Drained ONCE for the whole broadcast, not per seat: the steps are public and identical, and
// `takeSteps` empties the collector, so draining inside the loop would give them to seat 0 and
// an empty list to everybody else.
const steps = takeSteps(game.display);
const out = new Map<PlayerIndex, Push>();
for (let seat = 0; seat < playerNames.length; seat++) {
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment));
out.set(seat as PlayerIndex, pushFor(seat as PlayerIndex, moment, steps));
}
return out;
}
@@ -341,6 +371,19 @@ function buildSession(
// here rather than fired at the first client to arrive. (It also stops `game.cues` growing without
// bound on a server, which nothing was draining before this.)
takeMoment();
/**
* THE PRESENTATION STEPS THOSE TURNS PRODUCED GO WITH THEM (v0.8.0).
*
* Left in the collector they would be delivered on the FIRST broadcast after somebody connects —
* but that client's `publicReset` is the board as it stands AFTER these very moves, so replaying
* them onto it would draw positions the game had already left. The plan says as much: opening bot
* moves need no replay, and a later display simply receives the final reset.
*
* This is the only moment the collector holds anything outside an intent. `pushesForAll()` drains
* it synchronously at the end of every `intent()`, so between moves it is always empty — which is
* what makes dropping here safe rather than a race with a seat that has not been sent them yet.
*/
takeSteps(game.display);
return {
playerCount: playerNames.length,
@@ -355,7 +398,14 @@ function buildSession(
// blank history panel mid-game, with the server holding the whole log. `Push.lines` on a
// connect IS the history, which is what lets the Frame stop carrying a second copy.
sentLines.delete(seat);
return pushFor(seat, null);
const push = pushFor(seat, null);
/**
* The baseline for this client's step queue (v0.8.0). `game.display.last` is the exact frame
* the shared delta chain has reached, so the next step merges onto it; before any step has
* been collected there is no chain yet and a fresh projection is the same thing.
*/
push.publicReset = game.display.last ?? publicSnapshot(game.state);
return push;
},
intent(seat, seq, i) {