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