@volter/play 0.5.210 → 0.5.212

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.
package/src/model-play.ts CHANGED
@@ -1,145 +1,53 @@
1
1
  /**
2
- * WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `blender-game-panel.tsx`; drawn by
3
- * `blender-runtime.document.tsx`). Playing, the document's area shows a detached copy of the
4
- * model (`BlenderRuntimeView.detach`) that the project's play script moves
5
- * (`play-script.ts`); the model, its selection and its history stand as they were, and Stop
6
- * returns to them. Kept for the page's life and never stored: a reload opens the model, not a
7
- * game.
2
+ * WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `contributions/game-panel.tsx`; drawn by
3
+ * `blender-runtime.document.tsx`). Playing, the document's stage shows the std game's players' pages (`std-game.ts`);
4
+ * the model, its selection and its history stand as they were, and Stop returns to them. Kept for the page's life and
5
+ * never stored: a reload opens the model, not a game.
8
6
  *
9
- * AND HOW ITS CLOCK RUNS. Pause, step, speed and restart are state of the RUN, so they are kept
10
- * here beside "is it playing", and the runner (`play-script.ts`) reads them on every frame: the
11
- * `dt` a script's `update` receives is the only time a play script is given, so holding it is
12
- * what freezes the game and scaling it is what speeds the game up. A script that reads the
13
- * page's own clock (`performance.now()`, `Date.now()`) instead is outside that reach.
7
+ * AND ITS CLOCK, AS STD LAST ANSWERED IT. Pause, step and speed are std's `clock` control on each player's page
8
+ * (`std-controls.ts`); each answer is the page's clock (`{ paused, speed, time, frame }`), kept here per document
9
+ * beside whether a game runs and why not, and announced through the clock's own subscription. The speed is the
10
+ * person's: kept across Plays, and said to each player's page as it joins.
14
11
  *
15
- * AND WHO DRIVES. Autoplay is the editor's switch, not the game's: a script only offers a bot
16
- * (`play.autoplay(controller)`, `play-script.ts`), and whether that bot drives is kept here. It
17
- * is off whenever a run begins — Play, Restart, Stop — and only the Game panel's toggle or its
18
- * verb (`volter.model-play.autoplay`) turns it on. The one carry-over is an ARM: a person may
19
- * press Autoplay while stopped (there is no bot yet — a script offers it only once it runs), and
20
- * the next start turns autoplay on the moment its script has run its first update with a bot, or
21
- * drops the arm if it offers none. The runner turns it off the moment a person
22
- * presses a key or touches the game (`takeover`), or when the running script stops offering a
23
- * bot (`script`). Its changes are announced through the clock's subscription, which the panel
24
- * and the runner already hold.
12
+ * AND AUTOPLAY ARMED WHILE STOPPED: the next start drives the viewed player's bot as its page joins, with the goal it
13
+ * was armed with (`std-game.ts`). Stop drops the arm.
25
14
  */
26
15
  const listeners = new Set<() => void>();
27
16
  const clockListeners = new Set<() => void>();
28
17
  const playing = new Set<string>();
29
- const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
30
18
 
31
- /** The speeds the transport offers, slowest first (simulation seconds per real second). */
19
+ /** The speeds the transport offers, slowest first: std's clock takes 0.25 to 4. */
32
20
  export const MODEL_PLAY_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
33
- /** The `dt` one Step hands the game: one nominal 60 Hz frame, whatever the speed. */
34
- export const MODEL_PLAY_STEP_SECONDS = 1 / 60;
35
21
 
36
22
  export interface ModelPlayClock {
23
+ /** The viewed player's page's game seconds and frames, as std last answered them. */
37
24
  readonly time: number;
38
25
  readonly tick: number;
39
26
  readonly paused: boolean;
40
27
  readonly speed: number;
41
- /** The run is playing but no game runs: the script failed to start, or threw (`play-script.ts`). */
28
+ /** The run is playing but no game runs: its dev server could not start, or no player's page joined. */
42
29
  readonly failure: string | null;
43
- /** A game has started in this run and runs (`DocumentPlayClock.running`). */
30
+ /** A player's page has joined this run (`DocumentPlayClock.running`). */
44
31
  readonly running: boolean;
45
32
  }
46
33
  const STILL: ModelPlayClock = { time: 0, tick: 0, paused: false, speed: 1, failure: null, running: false };
47
- /** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity
48
- * exactly when it changes value. */
34
+ /** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity exactly when it changes
35
+ * value. */
49
36
  const clocks = new Map<string, ModelPlayClock>();
50
- /** Steps asked for while paused and not yet run; the runner takes one per drawn frame. */
51
- const steps = new Map<string, number>();
52
37
  /** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
53
38
  const generations = new Map<string, number>();
54
- /** Documents whose next run was begun by Restart, and so enters without the camera's blend. */
55
- const restarted = new Set<string>();
56
-
57
- /** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
58
- * in the game, the running script no longer offering a bot (replaced, failed, or its bot
59
- * threw), or the run's time limit. */
60
- export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script' | 'limit';
61
- /**
62
- * EVERY AUTOPLAY RUN HAS A LIMIT, in simulation seconds: a bot whose game never ends would
63
- * otherwise drive forever, and an agent waiting on it would wait forever. Reaching it turns
64
- * autoplay off, pauses the game and logs `autoplay-limit` (`play-script.ts`).
65
- */
66
- export const MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS = 300;
67
- export interface ModelPlayAutoplay {
68
- /** The bot drives: its keys are merged into the keys the script reads. */
69
- readonly on: boolean;
70
- /** The running script registered a bot with `play.autoplay`. */
71
- readonly available: boolean;
72
- /** The behaviours the running script's bot offers (`play.autoplay({ win, lose })`), in its
73
- * order; a bare function is the one behaviour `play`. */
74
- readonly behaviors: readonly string[];
75
- /** The behaviour driving now, or armed for the next start. */
76
- readonly behavior: string | null;
77
- /** This run's limit and the simulation time it began at; null while autoplay is off. */
78
- readonly limit: number | null;
79
- readonly since: number | null;
80
- readonly by: ModelPlayAutoplayBy | null;
81
- /** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
82
- readonly armed: boolean;
83
- /** A person's key or pointer reached the game in this run. Until one does, nobody drives
84
- * while autoplay is off — the game runs on no input. */
85
- readonly person: boolean;
86
- /** What the bot last said it is doing (`{ keys, state }` from its controller); kept after
87
- * autoplay goes off, so a limit or takeover still shows where the bot was. */
88
- readonly state: string | null;
89
- /** Why the last arm was not taken at start (the bot offers no bot, or not the behaviour it asked
90
- * for, or several with none named); null otherwise. Said in the panel and the play log. */
91
- readonly refused: string | null;
92
- }
93
- /** What a switch-on asks for: the behaviour (required when the bot offers several) and the limit. */
94
- export interface ModelPlayAutoplayRequest {
95
- readonly behavior?: string | null;
96
- readonly limit?: number | null;
97
- }
98
- const NO_BOT: ModelPlayAutoplay = { on: false, available: false, behaviors: [], behavior: null, limit: null, since: null, by: null, armed: false, person: false, state: null, refused: null };
99
- const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
100
- /** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
101
- const autoplays = new Map<string, ModelPlayAutoplay>();
102
-
103
- /** A new run's autoplay: off, no bot yet, nobody driving. Play keeps an arm made while stopped
104
- * (with the behaviour and limit it asked for); Stop drops it. The behaviours the last run's bot
105
- * offered are kept, not available: while stopped they are what the panel's picker offers for an
106
- * arm, which is checked against the bot the next start offers. */
107
- function resetAutoplay(documentId: string, keepArm: boolean): void {
108
- const now = modelPlayAutoplay(documentId);
109
- if (keepArm && now.armed) autoplays.set(documentId, { ...ARMED, behaviors: now.behaviors, behavior: now.behavior, limit: now.limit });
110
- else if (now.behaviors.length) autoplays.set(documentId, { ...NO_BOT, behaviors: now.behaviors });
111
- else autoplays.delete(documentId);
112
- }
113
-
114
- /** A limit as given: a positive number of simulation seconds, or the default. */
115
- function autoplayLimit(limit: number | null | undefined): number {
116
- if (limit === undefined || limit === null) return MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS;
117
- if (!Number.isFinite(limit) || limit <= 0) throw new Error(`An autoplay limit is a positive number of simulation seconds; got ${String(limit)}.`);
118
- return limit;
119
- }
120
-
121
- /** The behaviour a switch-on drives: the one named, which the bot must offer; with none named,
122
- * the bot's only behaviour. A bot with several needs one named, so a run always says what its
123
- * bot was trying to do. */
124
- function chosenBehavior(behaviors: readonly string[], named: string | null | undefined): string {
125
- if (named) {
126
- if (!behaviors.includes(named))
127
- throw new Error(`This game's bot has no behaviour "${named}"; it offers ${behaviors.map(b => `"${b}"`).join(', ')}.`);
128
- return named;
129
- }
130
- if (behaviors.length === 1) return behaviors[0]!;
131
- throw new Error(`This game's bot offers several behaviours (${behaviors.join(', ')}); name the one to run: \`play autoplay on <behaviour>\`.`);
132
- }
39
+ /** Autoplay armed while stopped, with the goal it drives (null: the game's own). */
40
+ const arms = new Map<string, { readonly goal: string | null }>();
133
41
 
134
42
  function publish(): void {
135
43
  for (const listener of [...listeners]) listener();
136
44
  }
137
- function publishClock(): void {
45
+ export function publishModelPlayClock(): void {
138
46
  for (const listener of [...clockListeners]) listener();
139
47
  }
140
48
  function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
141
49
  clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
142
- publishClock();
50
+ publishModelPlayClock();
143
51
  }
144
52
 
145
53
  export function modelPlaying(documentId: string): boolean {
@@ -148,64 +56,25 @@ export function modelPlaying(documentId: string): boolean {
148
56
 
149
57
  export function setModelPlaying(documentId: string, value: boolean): void {
150
58
  if (playing.has(documentId) === value) {
151
- // Stop with nothing playing is how a closing document lets go of its play: an arm made while
152
- // stopped goes with it, or reopening the model and pressing Play would still take it.
153
- if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
59
+ // Stop with nothing playing is how a closing document lets go of its play: an arm made while stopped goes with it.
60
+ if (!value && arms.delete(documentId)) publishModelPlayClock();
154
61
  return;
155
62
  }
156
- const stop = value ? null : currentStop(documentId);
157
- if (stop) { stop(false); return; }
158
63
  if (value) {
159
64
  playing.add(documentId);
160
- // A new run starts its clock at zero and running; the speed is the person's and stays.
161
- steps.delete(documentId);
162
- resetAutoplay(documentId, true);
65
+ // A new run's clock starts at zero and running; the speed is the person's and stays.
163
66
  setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
164
67
  } else {
165
68
  playing.delete(documentId);
166
- restarted.delete(documentId);
167
- steps.delete(documentId);
168
- resetAutoplay(documentId, false);
169
- // The clock keeps the stopped run's time and tick, so the panel still says how far it got.
170
- // Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
171
- // Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
172
- // the clock alone must see the run end too.
69
+ arms.delete(documentId);
70
+ // The clock keeps the stopped run's time and frames, so the panel still says how far it got.
173
71
  setClock(documentId, { paused: false, failure: null, running: false });
174
72
  }
175
73
  publish();
176
74
  }
177
75
 
178
- export function finishModelPlay(documentId: string): void {
179
- stops.delete(documentId);
180
- setModelPlaying(documentId, false);
181
- }
182
-
183
- export function escapeModelPlay(documentId: string): void {
184
- const stop = currentStop(documentId);
185
- if (stop) stop(true);
186
- else finishModelPlay(documentId);
187
- }
188
-
189
- /**
190
- * A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
191
- * generation, which blends the camera back first; a stop a Restart has superseded is never
192
- * asked, because that runner stands still from the moment its generation passes
193
- * (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
194
- * runner (a restarted game still preparing) the play simply ends.
195
- */
196
- export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
197
- const entry = { stop, generation: modelPlayGeneration(documentId) };
198
- stops.set(documentId, entry);
199
- return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
200
- }
201
-
202
- function currentStop(documentId: string): ((escape: boolean) => void) | null {
203
- const entry = stops.get(documentId);
204
- return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
205
- }
206
-
207
- /** The runner's report that no game runs although the run plays (`null` once one does, which
208
- * is also the moment the run is `running`). */
76
+ /** The run's report that no game runs although the run plays (`null` once a player's page joins, which is also the
77
+ * moment the run is `running`). */
209
78
  export function setModelPlayFailure(documentId: string, failure: string | null): void {
210
79
  const clock = modelPlayClock(documentId);
211
80
  const running = failure === null;
@@ -213,11 +82,23 @@ export function setModelPlayFailure(documentId: string, failure: string | null):
213
82
  setClock(documentId, { failure, running });
214
83
  }
215
84
 
85
+ /** A page's clock as std answered it (`std-controls.ts`'s `StdClock`), kept as the run's. */
86
+ export function setModelPlayClock(documentId: string, clock: { readonly paused: boolean; readonly speed: number; readonly time: number; readonly frame: number }): void {
87
+ const now = modelPlayClock(documentId);
88
+ if (now.paused === clock.paused && now.speed === clock.speed && now.time === clock.time && now.tick === clock.frame) return;
89
+ setClock(documentId, { paused: clock.paused, speed: clock.speed, time: clock.time, tick: clock.frame });
90
+ }
91
+
92
+ /** The person's speed, kept for the page's life and said to each player's page as it joins. */
93
+ export function setModelPlaySpeed(documentId: string, speed: number): void {
94
+ if (!MODEL_PLAY_SPEEDS.includes(speed))
95
+ throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
96
+ if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
97
+ }
98
+
216
99
  export function subscribeModelPlay(listener: () => void): () => void {
217
100
  listeners.add(listener);
218
- return () => {
219
- listeners.delete(listener);
220
- };
101
+ return () => { listeners.delete(listener); };
221
102
  }
222
103
 
223
104
  export function modelPlayClock(documentId: string): ModelPlayClock {
@@ -226,45 +107,7 @@ export function modelPlayClock(documentId: string): ModelPlayClock {
226
107
 
227
108
  export function subscribeModelPlayClock(listener: () => void): () => void {
228
109
  clockListeners.add(listener);
229
- return () => {
230
- clockListeners.delete(listener);
231
- };
232
- }
233
-
234
- /** Hold or release the game's updates. Only a playing document has updates to hold. */
235
- export function setModelPlayPaused(documentId: string, paused: boolean): void {
236
- if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
237
- if (!paused) steps.delete(documentId);
238
- setClock(documentId, { paused });
239
- }
240
-
241
- /** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
242
- * so each one is seen. */
243
- export function stepModelPlay(documentId: string): void {
244
- if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
245
- steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
246
- }
247
-
248
- /** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
249
- export function takeModelPlayStep(documentId: string): boolean {
250
- const pending = steps.get(documentId) ?? 0;
251
- if (pending === 0) return false;
252
- if (pending === 1) steps.delete(documentId);
253
- else steps.set(documentId, pending - 1);
254
- return true;
255
- }
256
-
257
- export function setModelPlaySpeed(documentId: string, speed: number): void {
258
- if (!MODEL_PLAY_SPEEDS.includes(speed))
259
- throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
260
- if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
261
- }
262
-
263
- /** The runner's report of the updates it ran this frame. */
264
- export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
265
- if (ticks === 0) return;
266
- const clock = modelPlayClock(documentId);
267
- setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
110
+ return () => { clockListeners.delete(listener); };
268
111
  }
269
112
 
270
113
  export function modelPlayGeneration(documentId: string): number {
@@ -272,105 +115,35 @@ export function modelPlayGeneration(documentId: string): number {
272
115
  }
273
116
 
274
117
  /**
275
- * BEGIN THE RUN AGAIN ON A FRESH COPY. Playing stays true throughout — nothing returns to the
276
- * model — and the generation moves, which is what makes the document detach a new copy and mount
277
- * a new runner over it; the old runner sees it is no longer the current generation and stands
278
- * down without ending the play. A document that is not playing simply starts.
118
+ * BEGIN THE RUN AGAIN. Playing stays true throughout (nothing returns to the model) and the generation moves, which is
119
+ * what makes the document detach a new copy and run Play's runner again: every player's page loaded afresh. A document
120
+ * that is not playing simply starts.
279
121
  */
280
122
  export function restartModelPlay(documentId: string): void {
281
123
  if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
282
124
  generations.set(documentId, modelPlayGeneration(documentId) + 1);
283
- restarted.add(documentId);
284
- steps.delete(documentId);
285
- // An arm not yet taken (the game was still starting) carries to the fresh run.
286
- resetAutoplay(documentId, true);
287
125
  setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
288
126
  publish();
289
127
  }
290
128
 
291
- /** Whether the run starting now was begun by Restart (asked once, by that run's runner). */
292
- export function consumeModelPlayRestart(documentId: string): boolean {
293
- return restarted.delete(documentId);
129
+ /** Autoplay armed for the next start, and the goal it drives; null when it is not armed. */
130
+ export function modelPlayArm(documentId: string): { readonly goal: string | null } | null {
131
+ return arms.get(documentId) ?? null;
294
132
  }
295
133
 
296
- export function modelPlayAutoplay(documentId: string): ModelPlayAutoplay {
297
- return autoplays.get(documentId) ?? NO_BOT;
298
- }
299
-
300
- function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
301
- autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
302
- publishClock();
303
- }
304
-
305
- /** Switch the running script's bot on or off. On asks for a playing document whose script
306
- * registered a bot, names the behaviour to drive (required when the bot offers several) and
307
- * starts the run's limit; off always succeeds, and also drops an arm not yet taken — a person
308
- * who takes over while the game is still starting is driving, and the arm must not override them. */
309
- export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy, request: ModelPlayAutoplayRequest = {}): void {
310
- const now = modelPlayAutoplay(documentId);
311
- if (on && !playing.has(documentId))
312
- throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
313
- const clock = modelPlayClock(documentId);
314
- if (on && !now.available)
315
- throw new Error(clock.running
316
- ? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
317
- : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
318
- if (on) {
319
- const behavior = chosenBehavior(now.behaviors, request.behavior);
320
- const limit = autoplayLimit(request.limit);
321
- // Switching on again (another behaviour, a new limit) is a new run of the bot.
322
- setAutoplay(documentId, { on, by, behavior, limit, since: clock.time, armed: false, state: null, refused: null });
323
- } else if (now.on || now.armed) setAutoplay(documentId, { on, by, armed: false, since: null });
324
- }
325
-
326
- /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
327
- * before Play. The behaviour and limit are checked when the start takes the arm, since the bot
328
- * is offered only then. Disarming always succeeds. */
329
- export function armModelPlayAutoplay(documentId: string, armed: boolean, request: ModelPlayAutoplayRequest = {}): void {
134
+ /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button before Play. Disarming
135
+ * always succeeds. */
136
+ export function armModelPlayAutoplay(documentId: string, armed: boolean, goal: string | null = null): void {
330
137
  if (armed && playing.has(documentId))
331
138
  throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
332
- if (armed) setAutoplay(documentId, { armed, refused: null, behavior: request.behavior ?? null, limit: request.limit === undefined || request.limit === null ? null : autoplayLimit(request.limit) });
333
- else if (modelPlayAutoplay(documentId).armed) setAutoplay(documentId, { armed, behavior: null, limit: null });
334
- }
335
-
336
- /** The runner's report that a script has run its first update, offering a bot's behaviours or
337
- * none. An arm is taken here: on with a bot that has the behaviour it asked for (or only one),
338
- * dropped otherwise. */
339
- export function settleModelPlayAutoplay(documentId: string, behaviors: readonly string[]): void {
340
- const now = modelPlayAutoplay(documentId);
341
- if (!playing.has(documentId)) return;
342
- if (!now.armed) { setModelPlayAutoplayAvailable(documentId, behaviors); return; }
343
- let behavior: string | null = null;
344
- let refused: string | null = null;
345
- if (!behaviors.length) refused = 'the play script registers no bot with play.autoplay';
346
- else {
347
- try { behavior = chosenBehavior(behaviors, now.behavior); }
348
- catch (error) { refused = error instanceof Error ? error.message : String(error); }
349
- }
350
- setAutoplay(documentId, behavior !== null
351
- ? { available: true, behaviors, armed: false, on: true, by: 'panel', behavior, limit: now.limit ?? MODEL_PLAY_AUTOPLAY_LIMIT_SECONDS, since: modelPlayClock(documentId).time, state: null, refused: null }
352
- : { available: behaviors.length > 0, behaviors, armed: false, behavior: null, limit: null, by: 'script', refused });
353
- }
354
-
355
- /** The runner's report of the behaviours the running script's bot offers (none: no bot). Losing
356
- * the bot, or the behaviour driving, turns autoplay off. */
357
- export function setModelPlayAutoplayAvailable(documentId: string, behaviors: readonly string[]): void {
358
- const now = modelPlayAutoplay(documentId);
359
- if (!playing.has(documentId)) return;
360
- const available = behaviors.length > 0;
361
- const same = now.available === available && now.behaviors.length === behaviors.length && now.behaviors.every((b, i) => b === behaviors[i]);
362
- if (same) return;
363
- const lost = now.on && (now.behavior === null || !behaviors.includes(now.behavior));
364
- setAutoplay(documentId, lost ? { available, behaviors, on: false, by: 'script', since: null } : { available, behaviors });
365
- }
366
-
367
- /** The runner's report of what the driving bot says it is doing; announced only when it changes. */
368
- export function setModelPlayBotState(documentId: string, state: string | null): void {
369
- if (playing.has(documentId) && modelPlayAutoplay(documentId).state !== state) setAutoplay(documentId, { state });
139
+ if (armed) arms.set(documentId, { goal });
140
+ else if (!arms.delete(documentId)) return;
141
+ publishModelPlayClock();
370
142
  }
371
143
 
372
- /** The runner's report that a person's key or pointer reached the game: from now on in this run,
373
- * autoplay off means the person drives. */
374
- export function noteModelPlayPerson(documentId: string): void {
375
- if (playing.has(documentId) && !modelPlayAutoplay(documentId).person) setAutoplay(documentId, { person: true });
144
+ /** The arm taken by the start it was made for. */
145
+ export function takeModelPlayArm(documentId: string): { readonly goal: string | null } | null {
146
+ const arm = arms.get(documentId) ?? null;
147
+ if (arm !== null) { arms.delete(documentId); publishModelPlayClock(); }
148
+ return arm;
376
149
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * THE GAME PANEL'S CLOCK, READ AT TWO RATES (`../contributions/game-panel.tsx`).
3
+ *
4
+ * The Play tool 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 { DocumentPlayClock } from '@volter/editor-blender/api';
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<DocumentPlayClock, '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: () => DocumentPlayClock): () => 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
+ }