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
+4
-1
@@ -1642,6 +1642,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const events: GameEvent[] = [
|
||||
{
|
||||
type: 'trayMoved',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
from,
|
||||
to: i.to,
|
||||
@@ -1706,6 +1707,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
// decides which car is next to come off.
|
||||
events.push({
|
||||
type: 'carsCoupled',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: i.to,
|
||||
stock: dest.couples,
|
||||
@@ -1726,7 +1728,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const stock = i.fromNose
|
||||
? tray.consist.slice(0, i.count)
|
||||
: tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
return [{ type: 'carsDropped', player, trayId: i.trayId, at: here, stock, ...(i.fromNose ? { fromNose: true } : {}) }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
@@ -1735,6 +1737,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
|
||||
@@ -37,9 +37,10 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
stock: RollingStock[];
|
||||
@@ -71,8 +72,8 @@ export type GameEvent =
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
|
||||
+55
-5
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* THE DISPLAY-STEP COLLECTOR — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 1-3.
|
||||
*
|
||||
* One ordered, watchable presentation step per accepted intent, so a player can see what everyone
|
||||
* else did instead of finding the board already rearranged. TODO #13: *"It's not fun to do my turn
|
||||
* and have magic happen in the background and then have to figure out what others did."*
|
||||
*
|
||||
* ONE HOOK, NOT TWO. The design anticipated wiring this into `GameSession.intent()` and
|
||||
* `GameSession.driveBots()` separately, with `LocalSession` doing its own thing for solitaire. It
|
||||
* does not need to: `src/server/session.ts` imports `submit` from `src/web/game.ts`, so solitaire,
|
||||
* live multiplayer and every bot turn already funnel through ONE function. Collecting there is what
|
||||
* makes solitaire a special case of multiplayer rather than a second implementation, which is the
|
||||
* standing design direction for this codebase.
|
||||
*
|
||||
* AND REPLAY IS INERT FOR FREE. `fromSave` and `fromMultiplayerSave` rebuild a game by calling
|
||||
* `applyIntent` + `record` + `drain` directly rather than `submit`, so a resumed server or a rebuilt
|
||||
* undo does NOT re-emit the whole game as steps. That was expected to need an explicit guard — the
|
||||
* plan calls it out as the same class of bug as #97, a mechanism firing on a path nobody pictured
|
||||
* it running on. It needs none, but the property is load-bearing: **if a replay path is ever moved
|
||||
* onto `submit()`, this becomes a real bug**, and `test/watchable.test.ts` pins it.
|
||||
*
|
||||
* WHAT A STEP IS. One accepted intent, or ONE AUTOMATIC PHASE — never one per `GameEvent`, because
|
||||
* the event list is not a complete reducer and a receiver could not rebuild state from it. It gets a
|
||||
* projected frame instead.
|
||||
*
|
||||
* PHASES EARN THEIR OWN STEPS, and that is TODO #18. `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 "look like they are being skipped entirely" — trains cross the Division in one
|
||||
* jump. Folding them into the triggering intent's step reproduces exactly that. So `submit()` steps
|
||||
* `advance()` one call at a time instead, and collects a step for each phase that actually DID
|
||||
* something. A phase that did nothing adds no narration and therefore produces no step at all, which
|
||||
* is Jesse's own rule (2026-09-09): "if nothing happens during a phase then we shouldn't lose time
|
||||
* to it."
|
||||
*
|
||||
* `drain()` is deliberately NOT changed. Replay, undo and `fromSave` all use it, and the
|
||||
* replay-inertness property below depends on their staying off this path. The stepped version lives
|
||||
* in `submit()` and makes the same `advance()` calls in the same order, so the resulting state is
|
||||
* identical — only the collection differs.
|
||||
*/
|
||||
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { GameState, PlayerIndex, SeatIndex } from '../engine/state.ts';
|
||||
import { seatOf } from '../engine/state.ts';
|
||||
import { publicSnapshot } from './view.ts';
|
||||
import type { PublicFrame } from './view.ts';
|
||||
import { deltaPublicFrame } from './public-delta.ts';
|
||||
import type { PublicFrameDelta } from './public-delta.ts';
|
||||
|
||||
/**
|
||||
* The wire format's version, on the ENVELOPE rather than on the projection.
|
||||
*
|
||||
* The plan's original sketch put `protocolVersion` inside `PublicFrame`. It does not belong there:
|
||||
* `PublicFrame` is a projection of the game and its property list is an allow-list that
|
||||
* `test/redaction.test.ts` enumerates, so a transport concern living in it would have to be
|
||||
* allow-listed as public game state, which it is not. The step is the message; the message carries
|
||||
* the version.
|
||||
*/
|
||||
export const DISPLAY_PROTOCOL_VERSION = 1;
|
||||
|
||||
/** What produced a step: somebody's intent, or the Division advancing a phase by itself. */
|
||||
export type StepCause = Intent['type'] | 'phase';
|
||||
|
||||
/** One watchable thing that happened, in order. */
|
||||
export type DisplayStep = {
|
||||
protocolVersion: typeof DISPLAY_PROTOCOL_VERSION;
|
||||
/** Monotonic per game. 0.8.1's reconnecting display stream needs it to detect a gap; a queue only needs the order. */
|
||||
seq: number;
|
||||
/**
|
||||
* Who acted — NULL for an automatic phase, which nobody did.
|
||||
*
|
||||
* Both are carried because Employee Rotation makes "which seat" and "which player" different
|
||||
* questions.
|
||||
*/
|
||||
player: PlayerIndex | null;
|
||||
seat: SeatIndex | null;
|
||||
/** What caused it — the input to pacing's kind classification. */
|
||||
cause: StepCause;
|
||||
/** The public board after this intent and everything it drained, against the previous step. */
|
||||
frame: PublicFrameDelta;
|
||||
/** The narration this intent added, in order, including any phase lines drained behind it. */
|
||||
lines: { text: string; tone: string }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-game collector state.
|
||||
*
|
||||
* Held on `Game` beside `log`, `cues` and `announced` and drained the same way, which is the
|
||||
* established convention in this codebase for "the model accumulated something, the view takes it".
|
||||
*/
|
||||
export type DisplayCollector = {
|
||||
/** Undrained steps, oldest first. */
|
||||
steps: DisplayStep[];
|
||||
/** The last public frame a step was built against, so the next delta has something to diff. */
|
||||
last: PublicFrame | null;
|
||||
/** Next sequence number to assign. */
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export function newCollector(): DisplayCollector {
|
||||
return { steps: [], last: null, seq: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one accepted intent as a step.
|
||||
*
|
||||
* Called from `submit()` AFTER `record()` and `drain()`, so `state` is the position the intent
|
||||
* finally produced and `lines` is everything it caused to be said. The frame is projected
|
||||
* immediately and never from a retained `GameState` reference — a retained reference would resolve
|
||||
* to the FINAL state of a whole bot run, which is exactly the teleporting this exists to prevent.
|
||||
*/
|
||||
export function collectStep(
|
||||
collector: DisplayCollector,
|
||||
state: GameState,
|
||||
player: PlayerIndex | null,
|
||||
cause: StepCause,
|
||||
lines: { text: string; tone: string }[],
|
||||
): void {
|
||||
const next = publicSnapshot(state);
|
||||
collector.steps.push({
|
||||
protocolVersion: DISPLAY_PROTOCOL_VERSION,
|
||||
seq: collector.seq++,
|
||||
player,
|
||||
seat: player === null ? null : seatOf(state, player),
|
||||
cause,
|
||||
frame: deltaPublicFrame(collector.last, next),
|
||||
lines,
|
||||
});
|
||||
collector.last = next;
|
||||
}
|
||||
|
||||
/** Take everything collected so far, leaving the collector empty — `takeMoment()`'s pattern. */
|
||||
export function takeSteps(collector: DisplayCollector): DisplayStep[] {
|
||||
return collector.steps.splice(0, collector.steps.length);
|
||||
}
|
||||
+2
-2
@@ -155,7 +155,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -181,7 +181,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
};
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* HOW LONG EACH STEP IS SHOWN — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 5.
|
||||
*
|
||||
* Shared rather than living in `src/web/`, so the 0.8.1 seatless board paces identically to a
|
||||
* player's own screen. Two views of one game that disagreed about how fast it looks would be worse
|
||||
* than either alone.
|
||||
*
|
||||
* WHY BY KIND RATHER THAN BY BUDGET. The obvious scheme is to give the whole backlog a time budget
|
||||
* and divide it by the queue length. Measured against real games, that does exactly the wrong
|
||||
* thing. From `public/replays/`: ~60 stages per game and ~5 intents per player per stage, so a
|
||||
* four-player table produces ~15 other-player steps per stage — but 44 of a 307-intent game are
|
||||
* `draw.end` and 60 are `loadUnload.end`, bookkeeping nobody wants to watch, while the thing that
|
||||
* is worth watching is rare and clustered. Two of the three published replays contain no
|
||||
* `switch.move` at all; the third has bursts of 14, 6, 6 and 6, and `trayMoved`'s own narration says
|
||||
* "N of 6 Moves left" because six is the engine's cap per crew. So a uniform budget spends the
|
||||
* player's attention on `draw.end` and rushes the switching.
|
||||
*
|
||||
* Assigning dwell by kind and letting the total fall out costs ~40s of animation across a whole
|
||||
* 60-stage game, against ~3.6 minutes for a flat 700ms — better switching visibility for a fifth of
|
||||
* the time. Jesse, 2026-09-09, on what matters: *"I definitely want to watch other players struggle
|
||||
* with the switching exercises … I don't think reading the switching in the log will be anywhere
|
||||
* nearly as interesting as watching the trains actually move on the board."*
|
||||
*
|
||||
* PACING IS CLIENT-SIDE ONLY. The server emits steps as fast as it likes and the client decides how
|
||||
* to show them, which is what keeps Gitea#20's "do not slow the authoritative game" true.
|
||||
*/
|
||||
|
||||
import type { StepCause } from './display-step.ts';
|
||||
|
||||
export type StepKind = 'switching' | 'action' | 'phase' | 'bookkeeping';
|
||||
|
||||
/**
|
||||
* THE TUNING TABLE — dwell in milliseconds per kind.
|
||||
*
|
||||
* Start generous and tune down by playing; Jesse, 2026-09-09: *"start at 1s and tune down."* This is
|
||||
* the committed default and changing it needs a web rebuild, which in the `.s9pk` is a release — so
|
||||
* it is deliberately not the only way to change the pacing. A viewer's own `pace` multiplier
|
||||
* (`Settings`, `localStorage`) and a `?pace=` URL parameter both scale these without one, and
|
||||
* `pace = 0` turns the animation off entirely, which is also TODO #18's "a player who has seen it a
|
||||
* hundred times will want it off".
|
||||
*
|
||||
* NOT IN GAME-CREATION SETTINGS, on Jesse's call 2026-09-09: dwell is presentation, not a rule, and
|
||||
* `config` rides along in saves and replays. If it ever moves there, the config field supplies this
|
||||
* table's multiplier — the table, the classification and the queue do not change.
|
||||
*/
|
||||
export const DWELL: Record<StepKind, number> = {
|
||||
/** A train physically moving on the board. The thing worth watching, and protected accordingly. */
|
||||
switching: 1000,
|
||||
/** A card, a car or a load changing hands somewhere visible. */
|
||||
action: 250,
|
||||
/**
|
||||
* An automatic phase that DID something — TODO #18.
|
||||
*
|
||||
* Only reached when the phase actually narrated: `submit()` collects no step for a phase that
|
||||
* changed nothing, so this is never spent on the empty ones Jesse is content to guess at. Between
|
||||
* an ordinary action and a switching move, because the Mainline phase moves trains the length of
|
||||
* the Division and is the clearest case of "stuff just happened without being able to see how".
|
||||
*/
|
||||
phase: 600,
|
||||
/** Turn and phase bookkeeping. Nothing moved; do not spend the player's attention on it. */
|
||||
bookkeeping: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Which kind an intent is.
|
||||
*
|
||||
* Exhaustive over `Intent['type']` on purpose — a `default` would silently drop a newly added intent
|
||||
* into whatever tier the fallback names, and the failure mode is invisible (a move that never gets
|
||||
* a beat, or bookkeeping that stalls the queue for a second). `test/pacing.test.ts` walks every
|
||||
* member of the union so a new intent cannot land here unclassified.
|
||||
*/
|
||||
export function kindOf(cause: StepCause): StepKind {
|
||||
switch (cause) {
|
||||
// The Division advancing itself — New Train, the Mainline, the shift change (TODO #18).
|
||||
case 'phase':
|
||||
return 'phase';
|
||||
|
||||
// The crew and its train moving, coupling, setting out and re-ordering — §6.1 and Appendix A.
|
||||
case 'switch.move':
|
||||
case 'switch.dropCars':
|
||||
case 'switch.sortConsist':
|
||||
case 'maneuver.flyingSwitch':
|
||||
case 'maneuver.redFlags':
|
||||
return 'switching';
|
||||
|
||||
// Something visible changed hands or position, but no train drove anywhere.
|
||||
case 'card.play':
|
||||
case 'card.discard':
|
||||
case 'draw.fromHomeOffice':
|
||||
case 'draw.fromDepartment':
|
||||
case 'newTrain.placeCar':
|
||||
case 'newTrain.passCar':
|
||||
case 'newTrain.secondSection':
|
||||
case 'newTrain.startExtra':
|
||||
case 'porter.board':
|
||||
case 'porter.detrain':
|
||||
case 'laborer.startLoad':
|
||||
case 'laborer.advanceLoad':
|
||||
case 'laborer.beginUnload':
|
||||
case 'freightAgent.stockOutbound':
|
||||
case 'freightAgent.clearInbound':
|
||||
case 'freightAgent.unjam':
|
||||
case 'mainline.clearance':
|
||||
case 'mainline.modify':
|
||||
case 'mainline.redFlag':
|
||||
case 'mainline.yardOffice':
|
||||
case 'redFlag.play':
|
||||
return 'action';
|
||||
|
||||
// Ending a phase or a turn, choosing what to do, voting. The consequences are worth watching;
|
||||
// the declaration itself is not, and there are more of these than of anything else.
|
||||
case 'localOps.choose':
|
||||
case 'loadUnload.end':
|
||||
case 'draw.end':
|
||||
case 'switch.end':
|
||||
case 'freightAgent.end':
|
||||
case 'game.extend':
|
||||
return 'bookkeeping';
|
||||
}
|
||||
}
|
||||
|
||||
/** How long to show one step, in ms, at a given speed. `pace` of 0 means "do not animate at all". */
|
||||
export function dwellFor(cause: StepCause, pace = 1): number {
|
||||
return Math.round(DWELL[kindOf(cause)] * Math.max(0, pace));
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to show one STEP — the form the queue actually uses.
|
||||
*
|
||||
* A step that said nothing gets no dwell, whatever caused it. That is one rule covering two cases
|
||||
* arrived at separately: a phase where nothing happened (Jesse, 2026-09-09 — *"if nothing happens
|
||||
* during a phase then we shouldn't lose time to it"*), and a phase that only handed the turn on,
|
||||
* which changes the board but has nothing on it to look at. Structurally typed so this file does not
|
||||
* have to import `DisplayStep` back from the module that imports `StepCause` from it.
|
||||
*/
|
||||
export function dwellForStep(
|
||||
step: { cause: StepCause; lines: readonly unknown[]; frame: { table: object } },
|
||||
pace = 1,
|
||||
): number {
|
||||
if (step.lines.length > 0) return dwellFor(step.cause, pace);
|
||||
/**
|
||||
* A SILENT STEP EARNS A BEAT ONLY WHEN THE CLOCK TURNED OVER — which is TODO #18 exactly: "give
|
||||
* every phase a visible beat", for New Train, the Mainline and the shift change.
|
||||
*
|
||||
* Measured, because the obvious rule was wrong twice. "No narration, no dwell" looked right and
|
||||
* silently killed #18: a phase can move trains without saying anything, and those steps were being
|
||||
* flashed past. "Anything that changed the board" is wrong the other way: `submit()` steps
|
||||
* `advance()` about 4.6 times per intent and most of those merely hand the turn on, so beating on
|
||||
* all of them would cost a quarter of an hour a game. The phase turning over is the thing a player
|
||||
* is being shown, and there are about 180 of those in a full game.
|
||||
*/
|
||||
const table = step.frame.table as Record<string, unknown>;
|
||||
const turned = 'phase' in table || 'phaseKey' in table || 'stage' in table || 'day' in table;
|
||||
return turned ? dwellFor(step.cause, pace) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many steps in a queue are actually going to be WATCHED.
|
||||
*
|
||||
* This is the number the "N behind" counter shows, and it is deliberately not `queue.length`. With
|
||||
* bookkeeping dwelling at zero, a backlog of 17 where 12 are `*.end` would read "17", plummet to 5
|
||||
* the instant it started, and then crawl — which is not the steady countdown the counter is for.
|
||||
* Thirteen dwelling steps means thirteen things you are going to see.
|
||||
*/
|
||||
export function watchableCount(causes: readonly StepCause[], pace = 1): number {
|
||||
return causes.filter((c) => dwellFor(c, pace) > 0).length;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Delta for the SEATLESS public frame — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 § 3.
|
||||
*
|
||||
* `frame-delta.ts` solves the same-shaped problem for a seated player's `Frame` and does NOT carry
|
||||
* over, which is worth saying plainly because reusing it looks obvious and is wrong. It nulls three
|
||||
* TOP-LEVEL keys — `cells`, `facilities`, `division` — and a `PublicFrame` has only the last of
|
||||
* those. Its `cells` and `facilities` live one level down, inside `districts[]`, one entry per seat,
|
||||
* and that is where nearly all of the bytes are.
|
||||
*
|
||||
* **So the districts are deltaed PER SEAT rather than as one array.** One accepted intent changes
|
||||
* one district; comparing the whole array as a unit would resend every other player's board on
|
||||
* every step, which is exactly the cost this exists to avoid. On a four-player table that is three
|
||||
* boards of waste per step, and a step is emitted for every bot move as well as every human one.
|
||||
*
|
||||
* It is also a TRUE PARTIAL rather than a full frame with holes in it, which is the other place
|
||||
* `frame-delta.ts` does not carry over. See `PublicFrameDelta` below for the measurement that forced
|
||||
* that; in short, most steps change one field and shipping the other thirty-four cost 16.7 MB a game.
|
||||
*
|
||||
* The convention that does carry over, kept identical so a reader of one file can read the other:
|
||||
* an absent or `null` field means "unchanged since the last thing sent to this receiver", and the
|
||||
* receiving side merges against the last full frame it actually holds. A first connect or a
|
||||
* reconnect after a gap sends a full frame instead — the display stream resets rather than replaying
|
||||
* (§ v0.8.0).
|
||||
*
|
||||
* Node-free by design, like `frame-delta.ts`: the server and the browser both import this directly.
|
||||
*/
|
||||
|
||||
import type { CellView, DivisionView, FacilityView, PublicDistrict, PublicFrame } from './view.ts';
|
||||
|
||||
/**
|
||||
* One district with its two heavy fields nulled when unchanged.
|
||||
*
|
||||
* `seat` is the identity and is always present — it is what the receiver matches on. `player` and
|
||||
* `name` are always sent too, and deliberately: Employee Rotation moves players between districts,
|
||||
* so the pairing of seat to player is itself news, and it costs two small fields to never have to
|
||||
* reason about whether a relabelling was missed.
|
||||
*/
|
||||
export type PublicDistrictDelta = Omit<PublicDistrict, 'cells' | 'facilities'> & {
|
||||
cells: CellView[] | null;
|
||||
facilities: FacilityView[] | null;
|
||||
};
|
||||
|
||||
/** The shared-table half of a `PublicFrame` — everything that is not the Division or a district. */
|
||||
type PublicTable = Omit<PublicFrame, 'division' | 'districts'>;
|
||||
|
||||
/**
|
||||
* A `PublicFrame` reduced to WHAT CHANGED.
|
||||
*
|
||||
* **Partial, not a full frame with holes**, and that distinction was measured rather than assumed.
|
||||
* The first version of this spread `...next` and nulled only the board fields, so every step shipped
|
||||
* all 35 top-level properties even when the sole change was whose turn it was. Once TODO #18 gave
|
||||
* automatic phases their own steps, most steps became exactly that — a turn handed on, nothing to
|
||||
* look at — and a full 6-day game cost **19.4 MB**, of which **16.7 MB was those silent steps at
|
||||
* ~11 KB each**. As a partial they are a few dozen bytes.
|
||||
*/
|
||||
export type PublicFrameDelta = {
|
||||
/** Only the shared-table fields whose value differs from the previous frame. */
|
||||
table: Partial<PublicTable>;
|
||||
/** The Division, only when it changed. */
|
||||
division: DivisionView[] | null;
|
||||
/** Only the districts that changed, each carrying only the board fields that changed. */
|
||||
districts: PublicDistrictDelta[];
|
||||
};
|
||||
|
||||
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
const TABLE_KEYS = (frame: PublicFrame): (keyof PublicTable)[] =>
|
||||
(Object.keys(frame) as (keyof PublicFrame)[]).filter(
|
||||
(k): k is keyof PublicTable => k !== 'division' && k !== 'districts',
|
||||
);
|
||||
|
||||
/**
|
||||
* `previous` is the last public frame actually sent to THIS receiver, or `null` for a first connect
|
||||
* or a reset — in which case everything is sent in full.
|
||||
*/
|
||||
export function deltaPublicFrame(previous: PublicFrame | null, next: PublicFrame): PublicFrameDelta {
|
||||
const before = new Map(previous?.districts.map((d) => [d.seat, d]) ?? []);
|
||||
const table: Partial<PublicTable> = {};
|
||||
for (const key of TABLE_KEYS(next)) {
|
||||
if (previous === null || !same(previous[key], next[key])) {
|
||||
(table as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
}
|
||||
const districts: PublicDistrictDelta[] = [];
|
||||
for (const d of next.districts) {
|
||||
const was = before.get(d.seat);
|
||||
const cells = was && same(was.cells, d.cells) ? null : d.cells;
|
||||
const facilities = was && same(was.facilities, d.facilities) ? null : d.facilities;
|
||||
// A district with nothing new is left out entirely rather than sent as a row of nulls: on a
|
||||
// four-player table three of them are unchanged on every single step.
|
||||
if (was && cells === null && facilities === null && same(was, d)) continue;
|
||||
districts.push({ ...d, cells, facilities });
|
||||
}
|
||||
return {
|
||||
table,
|
||||
division: previous !== null && same(previous.division, next.division) ? null : next.division,
|
||||
districts,
|
||||
};
|
||||
}
|
||||
|
||||
/** The receiving side: merges a delta back onto the last full public frame this receiver holds. */
|
||||
export function applyPublicDelta(previous: PublicFrame | null, delta: PublicFrameDelta): PublicFrame {
|
||||
const base = previous ?? (delta.table as PublicTable);
|
||||
const merged = { ...base, ...delta.table } as PublicTable;
|
||||
const bySeat = new Map((previous?.districts ?? []).map((d) => [d.seat, d]));
|
||||
for (const d of delta.districts) {
|
||||
const was = bySeat.get(d.seat);
|
||||
bySeat.set(d.seat, {
|
||||
...d,
|
||||
cells: d.cells ?? need(was?.cells, `districts[seat ${d.seat}].cells`),
|
||||
facilities: d.facilities ?? need(was?.facilities, `districts[seat ${d.seat}].facilities`),
|
||||
});
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
division: delta.division ?? need(previous?.division, 'division'),
|
||||
districts: [...bySeat.values()].sort((a, b) => a.seat - b.seat),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A delta that says "unchanged" against a receiver that has nothing to merge onto is a bug in the
|
||||
* SENDER's bookkeeping, not a recoverable state — it means the two sides disagree about what has
|
||||
* been delivered, and quietly producing a frame with a missing board would put a blank district in
|
||||
* front of a player. `frame-delta.ts` throws in the same situation and for the same reason.
|
||||
*/
|
||||
function need<T>(value: T | undefined, what: string): T {
|
||||
if (value === undefined) {
|
||||
throw new Error(`deltaPublicFrame said "${what}" is unchanged, but there is no previous frame to merge onto`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+74
-4
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user