@volter/play 0.5.211 → 0.5.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * THE GAME'S RUN, the project's one (Play in the Game panel, `contributions/game-panel.tsx`; drawn in the Game view,
3
+ * `game-view.tsx`): whether it plays, its clock as std last answered it, its generation and autoplay armed for its next
4
+ * start. Kept for the page's life and never stored: a reload opens the editor, not a game.
5
+ *
6
+ * THE CLOCK. Pause, step and speed are std's `clock` control on each player's page (`std-controls.ts`); each answer is
7
+ * the page's clock (`{ paused, speed, time, frame }`), kept here beside whether a game runs and why not, and announced
8
+ * through the clock's own subscription. The speed is the person's: kept across Plays, and said to each player's page as
9
+ * it joins. Its time comes from one of two readings, and `from` says which:
10
+ * - held (paused or stepped): std's answer to the control, exact, as the play log's `pause` and `step` entries record it;
11
+ * - running: the newest step the viewed player's page traced (std's trace format 3, `s`, sixty a game second from the
12
+ * trace's start, since std's live clock is the page's `performance.now`, `adoptLiveClock`). A client page writes a
13
+ * trace line only as something happens (an input, a word said, its bot's intent), so this holds while the page is
14
+ * idle: it is when the page last traced, not the clock now.
15
+ *
16
+ * AUTOPLAY ARMED WHILE STOPPED: the next start drives the viewed player's bot as its page joins, with the goal it was
17
+ * armed with (`std-game.ts`). Stop drops the arm.
18
+ */
19
+ const listeners = new Set<() => void>();
20
+ const clockListeners = new Set<() => void>();
21
+
22
+ /** The speeds the panel offers, slowest first: std's clock takes 0.25 to 4. */
23
+ export const GAME_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
24
+
25
+ export interface GameClock {
26
+ /** The viewed player's page's game seconds and `tick`, read as `from` says. */
27
+ readonly time: number;
28
+ readonly tick: number;
29
+ /** Where `time` and `tick` were read. `std-answer`: std's answer to the pause or step that held the game (`tick` its
30
+ * frame). `newest-traced-step`: the newest step the viewed page traced while the game ran, `time` that step in
31
+ * seconds (0 until its trace says a line past its start); it holds while that page traces nothing. After Stop the
32
+ * clock keeps the run's last reading and where it was read, with `running` and `paused` false: a reading still in
33
+ * flight when its run ended is not written (`std-game.ts`). */
34
+ readonly from: 'std-answer' | 'newest-traced-step';
35
+ readonly paused: boolean;
36
+ /** Simulation seconds per real second; one of `GAME_SPEEDS`. */
37
+ readonly speed: number;
38
+ /** The run plays but no game runs: its dev server could not start, or no player's page joined. */
39
+ readonly failure: string | null;
40
+ /** A player's page has joined this run. A reader that must know whether Play WORKED (the CLI's `play`) waits for
41
+ * this or for `failure`, never for "playing" alone. */
42
+ readonly running: boolean;
43
+ }
44
+
45
+ let playing = false;
46
+ /** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity exactly when it changes
47
+ * value. */
48
+ let clock: GameClock = { time: 0, tick: 0, from: 'newest-traced-step', paused: false, speed: 1, failure: null, running: false };
49
+ /** The newest step the viewed page traced, and its seconds: the running clock's reading, kept while the game is held. */
50
+ let traced = { tick: 0, time: 0 };
51
+ /** Bumped by Restart: the Game view runs the game again on it. */
52
+ let generation = 0;
53
+ /** Autoplay armed while stopped, with the goal it drives (null: the game's own). */
54
+ let arm: { readonly goal: string | null } | null = null;
55
+
56
+ function publish(): void {
57
+ for (const listener of [...listeners]) listener();
58
+ }
59
+ function publishGameClock(): void {
60
+ for (const listener of [...clockListeners]) listener();
61
+ }
62
+ function setClock(next: Partial<GameClock>): void {
63
+ clock = { ...clock, ...next };
64
+ publishGameClock();
65
+ }
66
+
67
+ export const gamePlaying = (): boolean => playing;
68
+
69
+ export function setGamePlaying(value: boolean): void {
70
+ if (playing === value) {
71
+ // Stop with nothing playing lets go of an arm made while stopped.
72
+ if (!value && arm !== null) { arm = null; publishGameClock(); }
73
+ return;
74
+ }
75
+ playing = value;
76
+ if (value) {
77
+ // A new run's clock starts at zero and running; the speed is the person's and stays.
78
+ traced = { tick: 0, time: 0 };
79
+ setClock({ time: 0, tick: 0, from: 'newest-traced-step', paused: false, failure: null, running: false });
80
+ } else {
81
+ arm = null;
82
+ // The clock keeps the stopped run's time and frames, so `play state` still says how far it got.
83
+ setClock({ paused: false, failure: null, running: false });
84
+ }
85
+ publish();
86
+ }
87
+
88
+ /** The run's report that no game runs although the run plays (`null` once a player's page joins, which is also the
89
+ * moment the run is `running`). */
90
+ export function setGameFailure(failure: string | null): void {
91
+ const running = failure === null;
92
+ if (!playing || (clock.failure === failure && clock.running === running)) return;
93
+ setClock({ failure, running });
94
+ }
95
+
96
+ /** A page's clock as std answered it (`std-controls.ts`'s `StdClock`), kept as the run's: held, its time and frame
97
+ * exactly as answered; running, its speed, the time going back to the newest traced step. */
98
+ export function setGameClock(said: { readonly paused: boolean; readonly speed: number; readonly time: number; readonly frame: number }): void {
99
+ const next: Pick<GameClock, 'paused' | 'speed' | 'time' | 'tick' | 'from'> = said.paused
100
+ ? { paused: true, speed: said.speed, time: said.time, tick: said.frame, from: 'std-answer' }
101
+ : { paused: false, speed: said.speed, ...traced, from: 'newest-traced-step' };
102
+ if (clock.paused === next.paused && clock.speed === next.speed && clock.time === next.time && clock.tick === next.tick
103
+ && clock.from === next.from) return;
104
+ setClock(next);
105
+ }
106
+
107
+ /** The newest step the viewed player's page traced (std's trace format 3, `s`): the run's clock while it runs. */
108
+ export function setGameTraceStep(step: number, stepsPerSecond: number): void {
109
+ traced = { tick: step, time: step / stepsPerSecond };
110
+ if (clock.paused || (clock.tick === step && clock.from === 'newest-traced-step')) return;
111
+ setClock({ ...traced, from: 'newest-traced-step' });
112
+ }
113
+
114
+ /** The person's speed, kept for the page's life and said to each player's page as it joins. */
115
+ export function setGameSpeed(speed: number): void {
116
+ if (!GAME_SPEEDS.includes(speed))
117
+ throw new Error(`Play speed ${speed} is not one the panel offers: ${GAME_SPEEDS.join(', ')}.`);
118
+ if (clock.speed !== speed) setClock({ speed });
119
+ }
120
+
121
+ export function subscribeGame(listener: () => void): () => void {
122
+ listeners.add(listener);
123
+ return () => { listeners.delete(listener); };
124
+ }
125
+
126
+ export const gameClock = (): GameClock => clock;
127
+
128
+ export function subscribeGameClock(listener: () => void): () => void {
129
+ clockListeners.add(listener);
130
+ return () => { clockListeners.delete(listener); };
131
+ }
132
+
133
+ export const gameGeneration = (): number => generation;
134
+
135
+ /**
136
+ * BEGIN THE RUN AGAIN. Playing stays true throughout and the generation moves, which is what makes the Game view run
137
+ * the game again: every player's page loaded afresh. A game that is not playing simply starts.
138
+ */
139
+ export function restartGame(): void {
140
+ if (!playing) { setGamePlaying(true); return; }
141
+ generation += 1;
142
+ traced = { tick: 0, time: 0 };
143
+ setClock({ time: 0, tick: 0, from: 'newest-traced-step', paused: false, failure: null, running: false });
144
+ publish();
145
+ }
146
+
147
+ /** Autoplay armed for the next start, and the goal it drives; null when it is not armed. */
148
+ export const gameArm = (): { readonly goal: string | null } | null => arm;
149
+
150
+ /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button before Play. Disarming
151
+ * always succeeds. */
152
+ export function armGameAutoplay(armed: boolean, goal: string | null = null): void {
153
+ if (armed && playing) throw new Error('The game is already playing; switch autoplay on instead of arming it.');
154
+ if (armed) arm = { goal };
155
+ else if (arm === null) return;
156
+ else arm = null;
157
+ publishGameClock();
158
+ }
159
+
160
+ /** The arm taken by the start it was made for. */
161
+ export function takeGameArm(): { readonly goal: string | null } | null {
162
+ const taken = arm;
163
+ if (taken !== null) { arm = null; publishGameClock(); }
164
+ return taken;
165
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * THE GAME, PLAYED AS THE PROJECT'S (`docs/adr/std-scene-view.md`, decision 8): Play is the project's (`game-run.ts`),
3
+ * as an engine's Play button is, whatever document is in front, and the game shows in a GAME VIEW of its own, as
4
+ * Unity's Game view and Godot's Game workspace do. Play opens the Game view as a tab of the editor (`GAME_DOCUMENT`) and
5
+ * brings it to the front; Stop closes it and brings back the tab that was in front. Another tab in front never stops
6
+ * the game, and closing the Game view does.
7
+ *
8
+ * THE PLAYERS' PAGES OUTLIVE THE TAB'S BOX. The host parks a document's box out of the page while its tab is behind
9
+ * another, and a frame taken out of the page loads again, so the run draws in the Game view's retained layer
10
+ * (`@volter/sdk/document-layer`), mounted in the workbench's container, which the Game view's box claims while it is
11
+ * shown (laid over it, hidden once no view claims it), as Code-OSS keeps a webview that retains its context; the
12
+ * editor's doors that read the document in front (`editor.document.key`, `query`, `inspect`, `capture`) read the layer as
13
+ * its content. A hidden layer's pages are throttled, as a hidden retained webview is, and kept.
14
+ */
15
+ import { type CSSProperties, useLayoutEffect, useRef, useSyncExternalStore } from 'react';
16
+ import { editorHost } from '@volter/sdk/host';
17
+ import { useProgress } from '@volter/sdk/progress';
18
+ import { StateSurface, themeVars } from '@volter/sdk/widgets';
19
+ import { claimDocumentLayer, claimedBox, documentLayer, documentLayerOf, releaseDocumentLayer } from '@volter/sdk/document-layer';
20
+ import {
21
+ activateWorkspaceDocument,
22
+ activeWorkspaceDocumentId,
23
+ closeWorkspaceDocument,
24
+ openWorkspaceDocument,
25
+ openWorkspaceDocuments,
26
+ workspaceDocumentSelection,
27
+ } from '@volter/sdk/workspace-document-registry';
28
+ import { setDocumentPlaySignal } from '@volter/sdk/document-play-extension';
29
+ import { currentStdLevel, StdLevelView, subscribeCurrentStdLevel } from '../contributions/std-level.document';
30
+ import { gameClock, gameGeneration, gamePlaying, setGamePlaying, subscribeGame, subscribeGameClock } from './game-run';
31
+ import { stdRun, stdRunVersion, subscribeStdRun, type GameStage, type StdTileBox } from './std-game';
32
+
33
+ /** The Game view's id, and the run's: the project's one game. */
34
+ const GAME_DOCUMENT = 'play:game';
35
+ /** The Game view's Scene: the current level's view, live, a view of the Game view's document. */
36
+ const SCENE_VIEW = `${GAME_DOCUMENT}#scene`;
37
+
38
+ /** The run's stage, made as Play starts and kept across its restarts, in the Game view's retained layer: the players'
39
+ * frames, and over them the view bar and the grid's tiles. It takes no press itself: each player's frame and the view
40
+ * bar take their own, so the live Scene beneath it is the editor's to orbit and pick. */
41
+ interface StageLayer {
42
+ readonly host: HTMLDivElement;
43
+ readonly players: HTMLDivElement;
44
+ readonly overlay: HTMLDivElement;
45
+ }
46
+
47
+ let stage: StageLayer | null = null;
48
+ const stageListeners = new Set<() => void>();
49
+ function setStage(next: StageLayer | null): void {
50
+ stage = next;
51
+ if (next === null) releaseDocumentLayer(GAME_DOCUMENT);
52
+ for (const listener of [...stageListeners]) listener();
53
+ }
54
+ function subscribeStage(listener: () => void): () => void {
55
+ stageListeners.add(listener);
56
+ return () => { stageListeners.delete(listener); };
57
+ }
58
+
59
+ function makeStage(): StageLayer {
60
+ const host = documentLayer(GAME_DOCUMENT);
61
+ host.dataset['testid'] = 'game-stage';
62
+ const players = document.createElement('div');
63
+ Object.assign(players.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
64
+ const overlay = document.createElement('div');
65
+ Object.assign(overlay.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
66
+ host.replaceChildren(players, overlay);
67
+ return { host, players, overlay };
68
+ }
69
+
70
+ /** Where the live Scene stands in the Game view, as the run places it (`GameStage.scene`). */
71
+ let sceneArea: StdTileBox | 'whole' | null = null;
72
+ const sceneListeners = new Set<() => void>();
73
+ function placeScene(area: StdTileBox | 'whole' | null): void {
74
+ if (JSON.stringify(area) === JSON.stringify(sceneArea)) return;
75
+ sceneArea = area;
76
+ for (const listener of [...sceneListeners]) listener();
77
+ }
78
+ function subscribeSceneArea(listener: () => void): () => void {
79
+ sceneListeners.add(listener);
80
+ return () => { sceneListeners.delete(listener); };
81
+ }
82
+
83
+ /** The Game view: the run's stage over its box, the live Scene where the run places it (the current level's view,
84
+ * live, `std-level.document.tsx`, mounted for the run's whole life so its camera stays), and what the run says. */
85
+ function GameView({ active }: { readonly active: boolean }) {
86
+ const box = useRef<HTMLDivElement | null>(null);
87
+ const held = useSyncExternalStore(subscribeStage, () => stage, () => null);
88
+ const area = useSyncExternalStore(subscribeSceneArea, () => sceneArea, () => null);
89
+ const level = useSyncExternalStore(subscribeCurrentStdLevel, currentStdLevel, () => null);
90
+ const playing = useSyncExternalStore(subscribeGame, gamePlaying, () => false);
91
+ const failure = useSyncExternalStore(subscribeGameClock, () => gameClock().failure, () => null);
92
+ const running = useSyncExternalStore(subscribeGameClock, () => gameClock().running, () => false);
93
+ useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
94
+ useLayoutEffect(() => {
95
+ if (!active || held === null || box.current === null) return undefined;
96
+ return claimDocumentLayer(GAME_DOCUMENT, box.current);
97
+ }, [active, held]);
98
+ // (the play frame `capture --region play` takes, `editor-chrome-capture.ts`: the viewed player's page in the stage, or
99
+ // the Scene; Grid shows several, and names none)
100
+ const view = stdRun()?.view;
101
+ useLayoutEffect(() => {
102
+ if (held === null) return;
103
+ if (typeof view === 'number') held.host.dataset['volterPlayFrame'] = '';
104
+ else delete held.host.dataset['volterPlayFrame'];
105
+ }, [held, view]);
106
+ const said = !playing ? 'Stopped: press Play in the Game panel.' : failure;
107
+ useProgress(playing && !failure && !running ? { documentId: GAME_DOCUMENT, title: 'Starting the game' } : null);
108
+ const sceneBox: CSSProperties = area === null ? { inset: 0, visibility: 'hidden' } : area === 'whole' ? { inset: 0 } : {
109
+ left: `${area.left * 100}%`, top: `${area.top * 100}%`, width: `${area.width * 100}%`, height: `${area.height * 100}%`,
110
+ };
111
+ return (
112
+ <div ref={box} data-testid="game-view" style={{ position: 'absolute', inset: 0, background: themeVars.surface.inset }}>
113
+ {playing && (
114
+ <div data-testid="game-view-scene" {...(view === 'scene' ? { 'data-volter-play-frame': '' } : {})}
115
+ style={{ position: 'absolute', ...sceneBox, display: 'flex', flexDirection: 'column' }}>
116
+ {level === null
117
+ ? <StateSurface fill title="Open a level to see it here, live." />
118
+ : <StdLevelView documentId={SCENE_VIEW} level={level.path} label={level.label} live />}
119
+ </div>
120
+ )}
121
+ {said !== null && <StateSurface fill tone={playing ? 'error' : 'neutral'} title={said} style={{ pointerEvents: 'none' }} />}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /** What runs the game in a stage, as play's page half runs it (`play.page.tsx`): the answer stops it. */
127
+ export type GameRunner = (stage: GameStage) => () => void;
128
+
129
+ /** Whether the Game view holds the keyboard: it is the document in front, and the focus is the page's own, in the
130
+ * Game view (its box or its layer), or the pane that holds the Game view alone, never another part of the workbench. */
131
+ function gameViewHasKeys(): boolean {
132
+ const shown = claimedBox(GAME_DOCUMENT);
133
+ if (activeWorkspaceDocumentId() !== GAME_DOCUMENT || shown === null) return false;
134
+ const focused = document.activeElement;
135
+ if (focused === null || focused === document.body) return true;
136
+ if (shown.contains(focused) || (documentLayerOf(GAME_DOCUMENT)?.contains(focused) ?? false)) return true;
137
+ // (a pane parks the documents behind its tab, unseen: the one it shows is the Game view)
138
+ const seen = [...focused.querySelectorAll<HTMLElement>('[data-volter-document]')].filter((one) => one.checkVisibility());
139
+ return focused.contains(shown) && seen.length === 1;
140
+ }
141
+
142
+ /** The Game view brought to the front while the game runs (`play` asked of a game already playing), as Play brings it
143
+ * there as the run starts; nothing while the game has no Game view. */
144
+ export function showGameView(): void {
145
+ if (openWorkspaceDocuments().some((open) => open.descriptor.id === GAME_DOCUMENT)) activateWorkspaceDocument(GAME_DOCUMENT);
146
+ }
147
+
148
+ /**
149
+ * THE PROJECT'S RUN, kept while play is active: Play opens the Game view and runs the game in its stage, a Restart runs
150
+ * it again, and Stop (or the Game view closed) ends it and brings back the tab that was in front.
151
+ */
152
+ export function keepGameRuns(run: GameRunner): () => void {
153
+ let stop: (() => void) | null = null;
154
+ let generation = -1;
155
+ let before: string | null = null;
156
+ const view = (): boolean => openWorkspaceDocuments().some((open) => open.descriptor.id === GAME_DOCUMENT);
157
+ const sync = (): void => {
158
+ const playing = gamePlaying();
159
+ const now = gameGeneration();
160
+ if (playing && (stop === null || generation !== now)) {
161
+ stop?.();
162
+ stop = null;
163
+ generation = now;
164
+ if (!view()) {
165
+ const front = activeWorkspaceDocumentId();
166
+ before = front === GAME_DOCUMENT ? before : front;
167
+ openWorkspaceDocument({
168
+ id: GAME_DOCUMENT,
169
+ title: 'Game',
170
+ kind: 'tool-contribution',
171
+ Content: GameView,
172
+ // (the Game view closed is the game stopped)
173
+ onDispose: () => { if (gamePlaying()) setGamePlaying(false); },
174
+ // (the Hierarchy and the Inspector in every view are the running level's, its Scene's copy, as Unity's
175
+ // Hierarchy shows the running scene whichever view is focused: `docs/adr/std-scene-view.md`, decision 8)
176
+ selection: () => workspaceDocumentSelection(SCENE_VIEW),
177
+ });
178
+ } else activateWorkspaceDocument(GAME_DOCUMENT);
179
+ if (stage === null) setStage(makeStage());
180
+ const { players, overlay } = stage!;
181
+ stop = run({
182
+ container: players,
183
+ overlay,
184
+ report: (title, detail) => editorHost().console.error(`${title}: ${detail}`, 'play'),
185
+ keys: gameViewHasKeys,
186
+ scene: placeScene,
187
+ // (the stage's layer laid over the Game view's box, in a page that is seen: its players' pages draw)
188
+ shown: () => document.visibilityState !== 'hidden' && claimedBox(GAME_DOCUMENT) !== null,
189
+ });
190
+ } else if (!playing && stop !== null) {
191
+ stop();
192
+ stop = null;
193
+ placeScene(null);
194
+ setStage(null);
195
+ const back = before;
196
+ before = null;
197
+ if (view()) closeWorkspaceDocument(GAME_DOCUMENT);
198
+ if (back !== null) activateWorkspaceDocument(back);
199
+ }
200
+ };
201
+ const unsubscribe = subscribeGame(sync);
202
+ // (the host hears that the Game view is the document that plays, and whether it is paused: its status line says so)
203
+ setDocumentPlaySignal('play', {
204
+ state: (documentId) => (documentId !== GAME_DOCUMENT || !gamePlaying() ? 'stopped' : gameClock().paused ? 'paused' : 'playing'),
205
+ subscribe: (listener) => {
206
+ const stopRun = subscribeGame(listener), stopClock = subscribeGameClock(listener);
207
+ return () => { stopRun(); stopClock(); };
208
+ },
209
+ });
210
+ sync();
211
+ return () => {
212
+ unsubscribe();
213
+ setDocumentPlaySignal('play', null);
214
+ stop?.();
215
+ stop = null;
216
+ if (view()) closeWorkspaceDocument(GAME_DOCUMENT);
217
+ setStage(null);
218
+ };
219
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * THE GAME PANEL'S CLOCK, READ AT TWO RATES (`../contributions/game-panel.tsx`).
3
+ *
4
+ * The run announces its clock every frame, and one changed text node in the workbench's
5
+ * document repaints and re-layerizes the page around it (about 4 ms a frame measured), so the
6
+ * panel does not re-render on every announcement.
7
+ *
8
+ * So the panel reads the clock's STATE (paused, speed, failure, running), which changes when a
9
+ * person or the game changes it and re-renders at once, and its TEXT (time and tick) at most
10
+ * {@link CLOCK_TEXT_INTERVAL_MS} apart. The text's last change always lands (a trailing update),
11
+ * so a paused game's step or a stop reads exactly.
12
+ */
13
+ import type { GameClock } from './game-run';
14
+
15
+ /** The clock text's update period: ten times a second, faster than its hundredths can be read. */
16
+ export const CLOCK_TEXT_INTERVAL_MS = 100;
17
+
18
+ /** `subscribe`, with the listener called at most once per `interval` ms, always after the last
19
+ * announcement (trailing). For `useSyncExternalStore`. */
20
+ export function throttledSubscription(
21
+ subscribe: (listener: () => void) => () => void,
22
+ interval: number,
23
+ timers: { set: (run: () => void, ms: number) => unknown; clear: (handle: unknown) => void; now: () => number } = {
24
+ set: (run, ms) => setTimeout(run, ms),
25
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
26
+ now: () => performance.now(),
27
+ },
28
+ ): (listener: () => void) => () => void {
29
+ return (listener) => {
30
+ let pending: unknown = null;
31
+ let last = -Infinity;
32
+ const unsubscribe = subscribe(() => {
33
+ if (pending !== null) return;
34
+ pending = timers.set(() => {
35
+ pending = null;
36
+ last = timers.now();
37
+ listener();
38
+ }, Math.max(0, last + interval - timers.now()));
39
+ });
40
+ return () => {
41
+ if (pending !== null) timers.clear(pending);
42
+ pending = null;
43
+ unsubscribe();
44
+ };
45
+ };
46
+ }
47
+
48
+ /** The clock's state without its time: what the panel's controls and status line read. */
49
+ export type ClockState = Pick<GameClock, 'paused' | 'speed' | 'failure' | 'running'>;
50
+
51
+ /** A snapshot reader for {@link ClockState} that answers the SAME object until a field changes, so
52
+ * `useSyncExternalStore` re-renders only on a change of state, not on every tick. */
53
+ export function clockStateReader(read: () => GameClock): () => ClockState {
54
+ let held: ClockState | null = null;
55
+ return () => {
56
+ const clock = read();
57
+ if (held === null || held.paused !== clock.paused || held.speed !== clock.speed ||
58
+ (held.failure ?? null) !== (clock.failure ?? null) || held.running !== clock.running)
59
+ held = { paused: clock.paused, speed: clock.speed, failure: clock.failure ?? null, running: clock.running };
60
+ return held;
61
+ };
62
+ }