@volter/editor-model-play 0.5.189 → 0.5.191

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.
@@ -22,6 +22,34 @@
22
22
  * The tool blends the camera from the editing pose for 0.8 seconds after
23
23
  * update, holding keys empty until arrival. Stop freezes the copy and blends
24
24
  * back before disposing it; Escape during either blend completes that blend.
25
+ *
26
+ * THE GAME'S TIME IS THE `dt` IT IS HANDED, and the runner decides it (`model-play.ts` keeps
27
+ * the run's clock): paused, `update` is not called at all and the camera holds the last drawn
28
+ * pose; a Step is one `update` of one nominal frame; a speed scales the frame's seconds, and a
29
+ * scaled frame longer than a tenth of a second is run as several updates so the promise below —
30
+ * at most a tenth per call — holds at 4× too. The camera's own blends run on the page's time,
31
+ * because they are the editor's motion and not the game's. Restart is the document detaching a
32
+ * fresh copy for a new runner (`restartModelPlay`); this runner, no longer the current
33
+ * generation, stands down without ending the play.
34
+ *
35
+ * THE PLAY LOG KEEPS THE SAME CLOCK (`play-log.ts`): the run's log is advanced by exactly the
36
+ * `dt` of each `update` the panel's clock counts, at the same call, so an entry's `simT` and
37
+ * `tick` are the numbers the Game panel shows. A Restart is a fresh copy and so a fresh log,
38
+ * opened by `play-start` and then `play-restart`; pause, resume, each step and a speed change
39
+ * are lifecycle entries of their own.
40
+ *
41
+ * AUTOPLAY IS THE GAME'S BOT AND THE EDITOR'S SWITCH. A script offers one bot,
42
+ * `play.autoplay(controller)`: a plain function the runner calls before each `update` while
43
+ * autoplay is on, handed that update's `dt`, `simT`, `tick` and the keys the person holds, and
44
+ * answering the keys the bot holds. The runner merges those into `keys` for that update, so the
45
+ * bot drives through the script's own input code, exactly as a person's keys do. Whether it
46
+ * drives is not the script's to say (`model-play.ts`): off at every Play and Restart, on only
47
+ * from the Game panel or its verb, and off again the moment a person presses a key the game
48
+ * would hear or presses a pointer in the game — before that key reaches `keys`. A synthetic key
49
+ * (`editor.document.key`) is dispatched as a DOM event like a person's and is handled as one;
50
+ * the bot's own keys never pass through the DOM, so they cannot take over from themselves. The
51
+ * controller is called only here, inside the editor's runner: a game run anywhere else never
52
+ * drives itself.
25
53
  */
26
54
  import { editorHost } from '@volter/editor-sdk/host';
27
55
  import { getCurrentProject } from '@volter/editor-sdk/kit/active-project';
@@ -38,7 +66,24 @@ import type * as THREE from 'three';
38
66
  import { projectPlayLayers } from '@volter/editor-sdk/kit/project-play-layers';
39
67
  import { beginProjectMountEpoch, projectEntryImportUrl } from '@volter/editor-sdk/session/project-module-url';
40
68
  import { cameraTransition } from './camera-transition';
41
- import { finishModelPlay, registerModelPlayStop } from './model-play';
69
+ import {
70
+ advanceModelPlayClock,
71
+ consumeModelPlayRestart,
72
+ finishModelPlay,
73
+ MODEL_PLAY_STEP_SECONDS,
74
+ modelPlayAutoplay,
75
+ modelPlayClock,
76
+ modelPlayGeneration,
77
+ registerModelPlayStop,
78
+ setModelPlayAutoplay,
79
+ setModelPlayFailure,
80
+ setModelPlayAutoplayAvailable,
81
+ settleModelPlayAutoplay,
82
+ subscribeModelPlayClock,
83
+ takeModelPlayStep,
84
+ } from './model-play';
85
+ import { beginModelPlayLog } from './play-log';
86
+ import { materialOverrides } from './play-materials';
42
87
  interface PlayComposition {
43
88
  readonly entries: readonly string[];
44
89
  loadScript(): Promise<{ default?: unknown }>;
@@ -58,14 +103,97 @@ export interface ModelPlayContext {
58
103
  readonly camera: THREE.Camera;
59
104
  /** The keys held now, by `KeyboardEvent.code` (`ArrowUp`, `KeyW`, `Space`). */
60
105
  readonly keys: ReadonlySet<string>;
106
+ /**
107
+ * Write one entry to the play log, stamped with the run's simulation time and frame
108
+ * (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
109
+ * now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
110
+ * frame. Read back with `cyclotron play-log [--since <simT>] [--kind <k>] [--json]`
111
+ * or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
112
+ * and does nothing once this script has been replaced or Play has stopped.
113
+ *
114
+ * play.log('death', { cause: 'lava', at: player.position, stage });
115
+ */
116
+ log(kind: string, facts?: Record<string, unknown>): void;
117
+ /**
118
+ * Draw an object and the meshes under it in `color` (a `THREE.Color`, `'#2bff6b'`,
119
+ * `0x2bff6b`); an emitting surface glows in it too, an image texture is multiplied by it.
120
+ * `null` returns the authored colours. Other objects wearing the same Blender material keep
121
+ * theirs. An object is a name or one `find` answered.
122
+ *
123
+ * THE PRESENTED MATERIALS, which is why this exists: a mesh's `material` is an array with
124
+ * one shared `MeshPhysicalMaterial` per Blender material slot (a lone material only for an
125
+ * object without slots), so `material.color` is undefined, and editing an entry recolours
126
+ * every object wearing that Blender material. The presenter re-assigns those slots
127
+ * whenever it re-applies shading, `clone()` drops its shader hooks, and a node graph
128
+ * driving Base Color or Alpha ignores `color` and `opacity`. A tinted or faded object
129
+ * wears copies of its own slots, drawn from their constant inputs (a node graph's
130
+ * other inputs are not drawn while it does), and keeps them through a script reload. An
131
+ * object with a material the document cannot copy (one the script made) is left as it is,
132
+ * and the log says so once with a `tint-unsupported` entry.
133
+ */
134
+ tint(object: THREE.Object3D | string, color: THREE.ColorRepresentation | null): void;
135
+ /** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
136
+ * authored opacity. Its colour is untouched, and the copies are `tint`'s. */
137
+ setOpacity(object: THREE.Object3D | string, opacity: number | null): void;
138
+ /**
139
+ * Offer this game's bot. While the person (or an agent, `play autoplay on`) has autoplay on in
140
+ * the Game panel, `controller` is called before each `update` and the keys it answers are held
141
+ * for that update, merged into `keys`. Autoplay is off at every Play and Restart, and a
142
+ * person's key or pointer in the game turns it off. One bot per script: registering again
143
+ * replaces it, `null` withdraws it, and it goes with the script. Never bind autoplay to a game
144
+ * key, and never start it from the script.
145
+ *
146
+ * play.autoplay(({ dt }) => (car.speed < 20 ? ['ArrowUp'] : []));
147
+ */
148
+ autoplay(controller: ModelPlayAutoplayController | null): void;
149
+ }
150
+
151
+ /** What the bot is handed before each `update` it drives. */
152
+ export interface ModelPlayAutoplayInput {
153
+ /** The `dt` the coming `update` is handed. */
154
+ readonly dt: number;
155
+ /** Simulation seconds and the update's number, as that update's log entries carry them. */
156
+ readonly simT: number;
157
+ readonly tick: number;
158
+ /** The keys the person holds, by `KeyboardEvent.code`; the bot's are merged with them. */
159
+ readonly keys: ReadonlySet<string>;
61
160
  }
161
+ /** A game's bot: the keys (`KeyboardEvent.code`) it holds for the coming update. */
162
+ export type ModelPlayAutoplayController = (input: ModelPlayAutoplayInput) => Iterable<string> | null | undefined;
62
163
 
63
164
  export interface ModelPlayGame {
64
- /** Once per drawn frame, with the seconds since the last one (at most a tenth). */
165
+ /** Once per drawn frame, with the simulation seconds since the last call (at most a tenth).
166
+ * Not called while the game is paused; called once per Step; at a speed other than 1× the
167
+ * seconds are scaled, and a fast frame may call it more than once. */
65
168
  update(deltaSeconds: number): void;
66
169
  dispose?(): void;
67
170
  }
68
171
 
172
+ /** One script's lifetime: `value` until it is replaced, fails or the run stops; `bot` is what it
173
+ * offered `play.autoplay`; `unknown` the object names it asked for that the model lacks, each
174
+ * logged once — per script, so a typo that survives a reload is said again for the new one. */
175
+ interface Script {
176
+ value: boolean;
177
+ bot: ModelPlayAutoplayController | null;
178
+ readonly unknown: Set<string>;
179
+ }
180
+
181
+ /** The longest `dt` one update is handed; longer scaled frames are split (`frameUpdates`). */
182
+ const MAX_UPDATE_SECONDS = 0.1;
183
+
184
+ /**
185
+ * THE UPDATES THIS DRAWN FRAME RUNS, as the `dt` each is handed: none while paused, one nominal
186
+ * frame for a Step, and otherwise the frame's seconds times the speed, split into equal parts of
187
+ * at most {@link MAX_UPDATE_SECONDS}.
188
+ */
189
+ function frameUpdates(documentId: string, frameSeconds: number): number[] {
190
+ const clock = modelPlayClock(documentId);
191
+ if (clock.paused) return takeModelPlayStep(documentId) ? [MODEL_PLAY_STEP_SECONDS] : [];
192
+ const scaled = frameSeconds * clock.speed;
193
+ const parts = Math.max(1, Math.ceil(scaled / MAX_UPDATE_SECONDS - 1e-9));
194
+ return Array.from({ length: parts }, () => scaled / parts);
195
+ }
196
+
69
197
  /** The play script's project path for a model's `.blend`. */
70
198
  export function playScriptPath(blend: string): string {
71
199
  return blend.replace(/\.blend$/i, '') + '.play.ts';
@@ -107,14 +235,77 @@ export function runPlayScript(options: {
107
235
  readonly container: HTMLElement;
108
236
  readonly ready: () => void;
109
237
  readonly returning: () => void;
238
+ readonly ownMaterial?: ((material: THREE.Material) => THREE.Material | null) | undefined;
110
239
  }): () => void {
111
- const { blend, root, camera, onFrame, report } = options;
240
+ const { blend, root, camera, onFrame } = options;
112
241
  const modulePath = playScriptPath(blend);
242
+ // A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
243
+ // this run's handle, which is inert once the run has ended.
244
+ const run = beginModelPlayLog(options.documentId, modulePath);
245
+ const report = (phase: 'start' | 'update' | 'stop' | 'autoplay', title: string, error: unknown): void => {
246
+ const detail = error instanceof Error ? error.message : String(error);
247
+ run.append('play', 'script-error', { phase, message: detail });
248
+ // NO GAME RUNS NOW (a first start that failed, or the running game threw): the run still
249
+ // plays, and a save retries it, but what is on screen is not a game. Said on the clock, so
250
+ // the Game panel can say so and the document drops a Restart's cover (`failure`).
251
+ if ((phase === 'start' || phase === 'update') && game === null && current())
252
+ setModelPlayFailure(options.documentId, `${title}: ${detail}`);
253
+ options.report(title, detail);
254
+ };
255
+ // Said once per object: a script that tints every frame would otherwise fill the log.
256
+ const unsupported = new WeakSet<THREE.Mesh>();
257
+ const materials = materialOverrides({
258
+ root,
259
+ ownMaterial: options.ownMaterial,
260
+ unsupported: (mesh, material) => {
261
+ if (unsupported.has(mesh)) return;
262
+ unsupported.add(mesh);
263
+ run.append('play', 'tint-unsupported', { object: mesh.name, material: material.name,
264
+ why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
265
+ },
266
+ });
267
+ // An unknown name is the script's typo, not a reason to stop its game: said once per name, per script.
268
+ const objectOf = (script: Script, target: THREE.Object3D | string, call: 'tint' | 'setOpacity'): THREE.Object3D | null => {
269
+ if (typeof target !== 'string') return target;
270
+ const object = root.getObjectByName(target) ?? null;
271
+ if (!object && !script.unknown.has(target)) {
272
+ script.unknown.add(target);
273
+ run.append('play', 'tint-unknown-object', { object: target, call });
274
+ }
275
+ return object;
276
+ };
113
277
  const keys = new Set<string>();
114
278
  const heldKeys = new Set<string>();
115
- const transition = cameraTransition(options.editingCamera());
279
+ // The run this runner belongs to. A Restart moves the document to the next generation, whose
280
+ // own runner takes over; this one must then stand down without ending the play.
281
+ const generation = modelPlayGeneration(options.documentId);
282
+ const current = (): boolean => modelPlayGeneration(options.documentId) === generation;
283
+ const restarted = consumeModelPlayRestart(options.documentId);
284
+ const transition = cameraTransition(options.editingCamera(), { instant: restarted });
285
+ // THE TRANSPORT'S CHANGES, IN THE LOG. Pause, resume and speed are the person's (or an
286
+ // agent's) calls on `model-play.ts`, between frames; this run notes each as it lands. A run
287
+ // that Restart has replaced notes nothing more — its successor's log has the restart.
288
+ let seen = modelPlayClock(options.documentId);
289
+ let seenBot = modelPlayAutoplay(options.documentId);
290
+ if (restarted) run.append('play', 'play-restart', { speed: seen.speed });
291
+ else if (seen.speed !== 1) run.append('play', 'speed', { speed: seen.speed });
292
+ const stopClock = subscribeModelPlayClock(() => {
293
+ const now = modelPlayClock(options.documentId);
294
+ if (!current()) return;
295
+ if (now.paused !== seen.paused) run.append('play', now.paused ? 'pause' : 'resume');
296
+ if (now.speed !== seen.speed) run.append('play', 'speed', { speed: now.speed, from: seen.speed });
297
+ seen = now;
298
+ const bot = modelPlayAutoplay(options.documentId);
299
+ if (bot.on !== seenBot.on) run.append('play', bot.on ? 'autoplay-on' : 'autoplay-off', { by: bot.by });
300
+ // An arm dropped before it was taken: a takeover, or a script that offers no bot. (Stop's
301
+ // reset has no `by`, and its `play-stop` says enough.)
302
+ else if (seenBot.armed && !bot.armed && bot.by !== null) run.append('play', 'autoplay-off', { by: bot.by, armed: true });
303
+ seenBot = bot;
304
+ });
116
305
  options.container.style.opacity = '0';
117
- const context: ModelPlayContext = {
306
+ /** One script's context: its log, tint, opacity and bot do nothing once that script is gone
307
+ * (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
308
+ const contextFor = (alive: Script): ModelPlayContext => ({
118
309
  root,
119
310
  find(name) {
120
311
  const object = root.getObjectByName(name) ?? null;
@@ -126,7 +317,22 @@ export function runPlayScript(options: {
126
317
  return camera();
127
318
  },
128
319
  keys,
129
- };
320
+ log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
321
+ tint(object, color) {
322
+ const target = alive.value ? objectOf(alive, object, 'tint') : null;
323
+ if (target) materials.tint(target, color);
324
+ },
325
+ setOpacity(object, opacity) {
326
+ const target = alive.value ? objectOf(alive, object, 'setOpacity') : null;
327
+ if (target) materials.setOpacity(target, opacity);
328
+ },
329
+ autoplay(controller) {
330
+ if (controller !== null && typeof controller !== 'function')
331
+ throw new Error('play.autoplay takes a function (the bot) or null.');
332
+ if (alive.value) alive.bot = controller;
333
+ },
334
+ });
335
+ const scripts = new WeakMap<ModelPlayGame, Script>();
130
336
  let stopped = false;
131
337
  let attempt = 0;
132
338
  let game: ModelPlayGame | null = null;
@@ -153,8 +359,13 @@ export function runPlayScript(options: {
153
359
  const dispose = (ending: ModelPlayGame | null, layers: PlayComposition | null): void => {
154
360
  try { ending?.dispose?.(); }
155
361
  catch (error) {
156
- report(`${modulePath} failed while stopping`, error instanceof Error ? error.message : String(error));
157
- } finally { layers?.dispose(); }
362
+ report('stop', `${modulePath} failed while stopping`, error);
363
+ } finally {
364
+ // After its own dispose, which may still log; nothing it scheduled may.
365
+ const alive = ending && scripts.get(ending);
366
+ if (alive) { alive.value = false; alive.bot = null; }
367
+ layers?.dispose();
368
+ }
158
369
  };
159
370
  const end = (): void => {
160
371
  dispose(game, composition);
@@ -163,10 +374,12 @@ export function runPlayScript(options: {
163
374
  if (startedAt !== null) endedAt = Date.now();
164
375
  live.notifyChanged();
165
376
  };
166
- const start = async (): Promise<void> => {
377
+ /** `reload` says why a replacement mounts, for the log's `script-reload`; null for Play's first start. */
378
+ const start = async (reload: { readonly reason: string; readonly path: string } | null): Promise<void> => {
167
379
  const mine = ++attempt;
168
380
  if (pending) { dispose(pending.game, pending.composition); pending = null; }
169
381
  let nextComposition: PlayComposition | undefined;
382
+ const alive: Script = { value: true, bot: null, unknown: new Set() };
170
383
  try {
171
384
  if (mountLayers) {
172
385
  const project = getCurrentProject();
@@ -191,26 +404,42 @@ export function runPlayScript(options: {
191
404
  };
192
405
  }
193
406
  if (stopped || mine !== attempt) { nextComposition?.dispose(); return; }
194
- const next = await startGame(modulePath, context, nextComposition);
407
+ // Before the replacement's default export runs, so what it logs follows this.
408
+ if (reload) run.append('play', 'script-reload', reload);
409
+ const next = await startGame(modulePath, contextFor(alive), nextComposition);
410
+ scripts.set(next, alive);
195
411
  if (stopped || mine !== attempt) {
196
412
  dispose(next, nextComposition ?? null);
197
413
  return;
198
414
  }
199
415
  pending = { game: next, composition: nextComposition ?? null };
200
416
  } catch (error) {
417
+ alive.value = false;
201
418
  nextComposition?.dispose();
202
419
  if (stopped || mine !== attempt) return;
203
- report(`${modulePath} did not start`, error instanceof Error ? error.message : String(error));
420
+ report('start', `${modulePath} did not start`, error);
204
421
  }
205
422
  };
206
423
  let firstFrame = true;
424
+ /** Whether the last script to run its first update offered a bot (null before any did), so
425
+ * `autoplay-unavailable` is said once per run, and again only after a bot came and went. */
426
+ let offeredBot: boolean | null = null;
207
427
  let returning = false;
428
+ let stopReason = 'stop';
208
429
  const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
430
+ if (escape) stopReason = 'escape';
209
431
  keys.clear();
210
432
  heldKeys.clear();
211
433
  if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
212
434
  });
213
435
  const stopFrames = onFrame((deltaSeconds) => {
436
+ // REPLACED BY A RESTART, and not yet unmounted: the document keeps this copy on screen until
437
+ // the new one has drawn, so it stands as it is — no update, no clock, the camera held.
438
+ if (!current()) {
439
+ if (game !== null) transition.hold(camera());
440
+ keys.clear();
441
+ return;
442
+ }
214
443
  if (transition.leaving()) {
215
444
  options.container.style.opacity = String(transition.hudOpacity());
216
445
  if (!returning && transition.approachingEdit()) { returning = true; options.returning(); }
@@ -220,41 +449,137 @@ export function runPlayScript(options: {
220
449
  if (!surfaceHoldsKeyboard()) { keys.clear(); heldKeys.clear(); }
221
450
  else if (!transition.acceptingKeys()) keys.clear();
222
451
  else for (const key of heldKeys) keys.add(key);
223
- let replacementUpdated = false;
452
+ // The panel's toggle is enabled by the bot the running script offers, as of the last frame.
453
+ if (current()) setModelPlayAutoplayAvailable(options.documentId, game !== null && scripts.get(game)?.bot != null);
454
+ const updates = frameUpdates(options.documentId, deltaSeconds);
455
+ if (updates.length === 0) {
456
+ // PAUSED: no update, so nothing states the camera; hold the pose the last frame drew.
457
+ // A pending replacement waits too — its first update is a tick of the game's time.
458
+ // A tap made while paused is dropped; a key still held is seen by the next step.
459
+ if (game !== null) transition.hold(camera());
460
+ keys.clear();
461
+ return;
462
+ }
463
+ // Each update is counted — by the panel's clock and the log alike — as it is CALLED, so an
464
+ // update that throws still took its tick, in both.
465
+ let ran = 0;
466
+ let simulated = 0;
467
+ // THE BOT'S KEYS, per update: the person's keys of this frame and what the bot holds now. It
468
+ // drives only once the camera has arrived, as a person's keys reach the game only then.
469
+ const clock = modelPlayClock(options.documentId);
470
+ const driving = current() && modelPlayAutoplay(options.documentId).on && transition.acceptingKeys();
471
+ const person: ReadonlySet<string> = driving ? new Set(keys) : keys;
472
+ /** `keys` back to the person's alone. Only ever after `drive` has added the bot's, when
473
+ * `person` is a copy — never `keys` itself. */
474
+ const restorePerson = (): void => {
475
+ keys.clear();
476
+ for (const key of person) keys.add(key);
477
+ };
478
+ /** Merge the bot's keys into `keys` for one update; true when it did, and the caller then
479
+ * restores the person's keys after that update, however it ends. */
480
+ const drive = (script: ModelPlayGame, dt: number): boolean => {
481
+ const bot = driving ? scripts.get(script)?.bot : null;
482
+ // Asked again per update: the bot's own failure, a takeover, or `play.autoplay(null)`
483
+ // ends it mid-frame.
484
+ if (!bot || !modelPlayAutoplay(options.documentId).on) return false;
485
+ restorePerson();
486
+ try {
487
+ for (const key of bot({ dt, simT: clock.time + simulated, tick: clock.tick + ran, keys: person }) ?? []) keys.add(String(key));
488
+ return true;
489
+ } catch (error) {
490
+ restorePerson();
491
+ setModelPlayAutoplay(options.documentId, false, 'script');
492
+ report('autoplay', `${modulePath}'s autoplay failed`, error);
493
+ return false;
494
+ }
495
+ };
496
+ /** One update, bot-driven or not. The bot's keys hold for this update only: restored in a
497
+ * `finally`, so neither a bot withdrawn mid-frame, nor an update that throws, nor a reloaded
498
+ * script leaves them reading as held. */
499
+ const update = (script: ModelPlayGame, dt: number): void => {
500
+ tick(dt);
501
+ const driven = drive(script, dt);
502
+ try { script.update(dt); }
503
+ finally { if (driven) restorePerson(); }
504
+ };
505
+ // A paused frame that runs an update is a Step; its entry carries the step's own tick.
506
+ const stepping = modelPlayClock(options.documentId).paused;
507
+ const tick = (dt: number): void => {
508
+ run.advance(dt);
509
+ ran += 1;
510
+ simulated += dt;
511
+ if (stepping) run.append('play', 'step', { dt });
512
+ };
513
+ // A replacement's first update takes the frame's first slot whether it starts or throws; a
514
+ // running game that a failed replacement leaves in place runs the rest of the frame.
515
+ let firstSlot = 0;
224
516
  if (pending) {
517
+ firstSlot = 1;
225
518
  const next = pending;
226
519
  pending = null;
227
520
  try {
228
- next.game.update(deltaSeconds);
521
+ update(next.game, updates[0]!);
229
522
  end();
230
523
  game = next.game;
524
+ // NOW THE SCRIPT HAS SAID WHETHER IT OFFERS A BOT (its default export and first update
525
+ // are where `play.autoplay` is called): said before `running`, so no reader sees a running
526
+ // game with its bot not yet counted, and an arm made while stopped is taken or dropped.
527
+ const offered = scripts.get(next.game)?.bot != null;
528
+ if (!offered && offeredBot !== false)
529
+ run.append('play', 'autoplay-unavailable', { why: 'the play script registers no bot with play.autoplay(controller)' });
530
+ offeredBot = offered;
531
+ // A KEY THE PERSON ALREADY HOLDS is a takeover the runner did not hear: it was pressed while
532
+ // the stage was still preparing, before these listeners existed, and has only repeated
533
+ // since. The person is driving, so an arm waiting for this update is dropped.
534
+ if (heldKeys.size > 0 && modelPlayAutoplay(options.documentId).armed) takeover();
535
+ settleModelPlayAutoplay(options.documentId, offered);
536
+ setModelPlayFailure(options.documentId, null);
231
537
  startedAt = Date.now();
232
538
  endedAt = null;
233
539
  composition = next.composition;
234
540
  live.notifyChanged();
235
541
  composition?.reveal();
236
- replacementUpdated = true;
237
542
  } catch (error) {
238
543
  dispose(next.game, next.composition);
239
- report(`${modulePath} did not start`, error instanceof Error ? error.message : String(error));
544
+ report('start', `${modulePath} did not start`, error);
240
545
  }
241
546
  }
242
- if (game === null) return;
547
+ if (game === null) { advanceModelPlayClock(options.documentId, simulated, ran); keys.clear(); return; }
243
548
  try {
244
- if (!replacementUpdated) game.update(deltaSeconds);
549
+ for (let index = firstSlot; index < updates.length; index++) {
550
+ update(game, updates[index]!);
551
+ }
552
+ advanceModelPlayClock(options.documentId, simulated, ran);
553
+ ran = 0;
245
554
  transition.frame(camera(), deltaSeconds);
246
555
  options.container.style.opacity = String(transition.hudOpacity());
247
556
  if (firstFrame) { firstFrame = false; options.ready(); }
248
557
  } catch (error) {
558
+ advanceModelPlayClock(options.documentId, simulated, ran);
559
+ keys.clear();
249
560
  end();
250
- report(`${modulePath} failed`, error instanceof Error ? error.message : String(error));
561
+ report('update', `${modulePath} failed`, error);
251
562
  return;
252
563
  }
564
+ materials.frame();
253
565
  keys.clear();
254
566
  root.updateMatrixWorld(true);
255
567
  });
568
+ // THE PERSON ALWAYS WINS: a new key press the game would hear, or a pointer pressed anywhere
569
+ // in the game's area (its HUD included), hands control back before the input is the game's.
570
+ // Untrusted events count: `editor.document.key` and the probe's clicks are how an agent plays
571
+ // by hand.
572
+ const surface = options.container.parentElement ?? options.container;
573
+ const takeover = (): void => {
574
+ // An arm still waiting for the bot counts too: the person is driving before it could start.
575
+ const now = modelPlayAutoplay(options.documentId);
576
+ if (current() && (now.on || now.armed)) setModelPlayAutoplay(options.documentId, false, 'takeover');
577
+ };
256
578
  const onKeyDown = (event: KeyboardEvent): void => {
257
579
  if (!surfaceAcceptsKey(event)) return;
580
+ // A repeat is a key already held, not a new press — unless this runner never saw it go down:
581
+ // then it was pressed before the runner was listening, and that press was a takeover.
582
+ if (!event.repeat || !heldKeys.has(event.code)) takeover();
258
583
  heldKeys.add(event.code);
259
584
  if (transition.acceptingKeys()) keys.add(event.code);
260
585
  };
@@ -262,7 +587,11 @@ export function runPlayScript(options: {
262
587
  // Preserve a between-frame tap until one game update has observed it.
263
588
  heldKeys.delete(event.code);
264
589
  };
590
+ const onPointerDown = (event: PointerEvent): void => {
591
+ if (event.target instanceof Node && surface.contains(event.target)) takeover();
592
+ };
265
593
  const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
594
+ window.addEventListener('pointerdown', onPointerDown, true);
266
595
  window.addEventListener('keydown', onKeyDown, true);
267
596
  window.addEventListener('keyup', onKeyUp, true);
268
597
  window.addEventListener('blur', onBlur);
@@ -272,26 +601,33 @@ export function runPlayScript(options: {
272
601
  // would manufacture a mount failure while the old game is still alive.
273
602
  // A missing dependency continues through the ordinary error-reporting path.
274
603
  if (type === 'delete' && projectModuleChangeMatches(changed, modulePath)) {
604
+ stopReason = 'script-deleted';
275
605
  finishModelPlay(options.documentId);
276
606
  return;
277
607
  }
278
608
  // A composed HUD and script must remount together, including shared-store
279
609
  // edits. A fresh epoch is threaded to both through the UI tool door.
280
610
  const entries = [modulePath, ...retryEntries, ...(composition?.entries ?? []), ...(pending?.composition?.entries ?? [])];
281
- if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry)))) void start();
611
+ if ([changed, ...(affected ?? [])].some(path => entries.some(entry => projectModuleChangeMatches(path, entry))))
612
+ void start({ reason: type === 'delete' ? 'dependency-deleted' : 'saved', path: changed });
282
613
  });
283
- void start();
614
+ void start(null);
284
615
  return () => {
285
616
  stopped = true;
286
617
  unregisterLive();
287
618
  stopRequest();
288
- finishModelPlay(options.documentId);
619
+ // A runner replaced by Restart leaves the play running for its successor.
620
+ if (current()) finishModelPlay(options.documentId);
289
621
  stopFrames();
622
+ stopClock();
290
623
  stopChanges();
624
+ window.removeEventListener('pointerdown', onPointerDown, true);
291
625
  window.removeEventListener('keydown', onKeyDown, true);
292
626
  window.removeEventListener('keyup', onKeyUp, true);
293
627
  window.removeEventListener('blur', onBlur);
294
628
  if (pending) { dispose(pending.game, pending.composition); pending = null; }
295
629
  end();
630
+ materials.dispose();
631
+ run.end({ reason: stopReason });
296
632
  };
297
633
  }