@volter/editor-model-play 0.5.190 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Volter Model Play
2
2
 
3
- Play scripts on detached model documents. The header's Play runs
3
+ Play scripts on detached model documents. Play (the Game panel's, in Cyclotron) runs
4
4
  `src/models/<name>.play.ts` beside `src/models/<name>.blend` on a detached copy
5
5
  of the model; the model, its selection and its history stand as they were, and
6
6
  Stop returns to them. The script's default export is called once with the play
@@ -29,26 +29,35 @@ export default (play: ModelPlayContext): ModelPlayGame => {
29
29
 
30
30
  `play.log(kind, facts?)` writes one entry, stamped with `simT` (seconds of
31
31
  simulation since Play started, the sum of the `dt`s given to `update`) and
32
- `tick` (the update in progress). Facts are snapshotted as JSON when logged. The
33
- runner adds `play-start`, `script-reload` (`{ reason: 'saved' | 'dependency-deleted', path }`),
34
- `script-error` (`{ phase, message }`), `tint-unsupported` (`{ object, material, why }`)
35
- and `play-stop` (`{ reason }`). Log transitions rather than every frame. Logging
32
+ `tick` (the update in progress). Facts are snapshotted as JSON when logged. `simT` and `tick` are the Game panel's
33
+ clock: they stand still while paused and run at the speed (see Time below). The
34
+ runner adds `play-start`, `play-restart` (`{ speed }`, right after a Restart's
35
+ `play-start`), `script-reload` (`{ reason: 'saved' | 'dependency-deleted', path }`),
36
+ `script-error` (`{ phase, message }`), `tint-unsupported` (`{ object, material, why }`),
37
+ `tint-unknown-object` (`{ object, call }`, once per name: `tint` or `setOpacity` was given a
38
+ name the model has none of, and did nothing), `autoplay-on` / `autoplay-off`
39
+ (`{ by: 'panel' | 'cli' | 'takeover' | 'script' }`, see Autoplay below),
40
+ `autoplay-unavailable` (`{ why }`, when a script's first update has run and it registered no
41
+ bot; once per run, and again only if a bot came and went),
42
+ `pause`, `resume`, `step` (`{ dt }`, one per stepped update), `speed`
43
+ (`{ speed, from }` on a change; `{ speed }` at a start that is not 1×) and
44
+ `play-stop` (`{ reason }`). Log transitions rather than every frame. Logging
36
45
  never throws and never changes timing or state; a script's `log`, `tint` and
37
46
  `setOpacity` do nothing once it has been replaced or Play has stopped.
38
47
 
39
48
  Each model document keeps its own log; a read takes the active Play's unless it
40
49
  names a document. The newest 5000 entries are kept (older ones are counted as
41
50
  `dropped`), each entry's facts up to 2048 characters of JSON. Play from a fresh
42
- copy empties the document's log; a script reload does not, and the log stays readable after Stop until the
51
+ copy, and Restart, empty the document's log; a script reload does not, and the log stays readable after Stop until the
43
52
  next Play. `console.log` does not reach the session's console feed (only
44
53
  warnings and errors do); the play log does.
45
54
 
46
55
  ```sh
47
- volter-model-editor play-log # every kept entry, one per line
48
- volter-model-editor play-log --kind death # one kind
49
- volter-model-editor play-log --since 12.5 --json # entries at or after simT 12.5, as JSON
50
- volter-model-editor play-log --document <id> # another model document's log
51
- volter-model-editor eval "(await editor.modelPlayLog({ kind: 'death' })).entries"
56
+ cyclotron play-log # every kept entry, one per line
57
+ cyclotron play-log --kind death # one kind
58
+ cyclotron play-log --since 12.5 --json # entries at or after simT 12.5, as JSON
59
+ cyclotron play-log --document <id> # another model document's log
60
+ cyclotron eval "(await editor.modelPlayLog({ kind: 'death' })).entries"
52
61
  ```
53
62
 
54
63
  ## Recolouring the copy
@@ -69,3 +78,108 @@ object wears copies of its own slots that draw the material's constant inputs
69
78
  across a script reload. An object with a material the document cannot copy
70
79
  (one the script made itself) is left as it is, and the log says so once with
71
80
  `tint-unsupported`.
81
+
82
+ ## Autoplay
83
+
84
+ A game offers its bot; the editor decides whether it drives.
85
+
86
+ ```ts
87
+ play.autoplay(({ dt, simT, tick, keys }) => {
88
+ // the game's own decision, from its own state
89
+ return car.speed < 20 ? ['ArrowUp'] : [];
90
+ });
91
+ ```
92
+
93
+ The controller is a plain function. While autoplay is on it is called before each `update`
94
+ with that update's `dt`, `simT` and `tick` (the stamps that update's log entries carry) and
95
+ `keys`, the keys the person holds; it returns the `KeyboardEvent.code` keys the bot holds for
96
+ that update (any iterable, or nothing). The runner merges them into `play.keys`, so the bot
97
+ drives through the script's own input code exactly as a person does. One bot per script:
98
+ registering again replaces it, `play.autoplay(null)` withdraws it, and it goes with the script
99
+ on a reload or Stop. The bot is called only inside the editor's Play runner; a game run
100
+ anywhere else never drives itself.
101
+
102
+ Whether it drives is the editor's:
103
+
104
+ - Autoplay is **off** whenever Play starts or restarts.
105
+ - Only the Game panel's **Autoplay** toggle, `cyclotron play autoplay on|off` or
106
+ `await editor.command('volter.model-play.autoplay', { on: true })` turn it on (or off).
107
+ - **A bot exists only while the game runs**: `play.autoplay` is called by the play script, so
108
+ a stopped game (or one still starting) has none, and neither has a running script that never
109
+ registers one. The panel says which, in words beside the toggle — "Available once the game is
110
+ running" or "No autoplay — this game doesn't provide a bot" — and `on` is refused with the
111
+ same reason, which `play state` also gives as `autoplay.why`.
112
+ - **Arming.** Pressed while stopped, the panel's Autoplay arms the next start: autoplay turns
113
+ on (`by: 'panel'`) as soon as that run's script has run its first update with a bot, and the
114
+ arm is dropped if it offers none. A person's key or click in the game before then drops it
115
+ too (`autoplay-off` with `{ by: 'takeover', armed: true }`): the person always wins. Only an
116
+ explicit arm carries over; Stop, and closing the model, drop it.
117
+ - **The person always wins.** A new key press the game would hear, or a pointer pressed in the
118
+ game's area (the HUD included), turns autoplay off before that key reaches `play.keys`, and
119
+ the panel reads "You're driving" until someone switches the bot on again. Synthetic keys and
120
+ clicks (`editor.document.key`, `click`) count as a person's: they are how an agent plays by
121
+ hand. A key repeat is not a new press; keys typed in a text field, or while the game's surface
122
+ does not hold the keyboard, are not the game's and do not take over.
123
+ - A bot that throws turns autoplay off (`by: 'script'`, with a `script-error` of phase
124
+ `autoplay`), as does a reload whose script offers no bot.
125
+
126
+ Each change is a play-log entry, `autoplay-on` or `autoplay-off`, with `by`.
127
+
128
+ ## Time
129
+
130
+ `update(dt)` is the only clock a play script is given, and the editor decides it:
131
+
132
+ | Control | What the script sees |
133
+ | --- | --- |
134
+ | Playing at 1× | One `update` per drawn frame, `dt` = the frame's seconds (at most 0.1). |
135
+ | Speed 0.25× – 4× | `dt` scaled by the speed. A scaled frame longer than 0.1 s is split into equal updates, so no single `dt` exceeds 0.1. |
136
+ | Pause | No `update` at all. The copy, the camera and the HUD hold the last frame. |
137
+ | Step (while paused) | One `update` with `dt` = 1/60. |
138
+ | Restart | The run begins again on a fresh detached copy, the clock at zero, without the camera's fly-in. |
139
+
140
+ A script that reads the page's own clock (`performance.now()`, `Date.now()`) instead of
141
+ summing `dt` is outside the reach of pause and speed.
142
+
143
+ The run's clock — simulation time (the sum of the `dt`s handed to the script) and tick (the
144
+ number of updates) — is kept per document beside whether it plays (`src/model-play.ts`).
145
+ Speed is kept for the page's life; pause and the clock reset on every Play.
146
+ When the run plays but no game runs — the script failed to start, or threw — the clock's
147
+ `failure` says why (the Game panel shows it in place of "Playing"); a save of the script
148
+ retries, and the next running game clears it.
149
+
150
+ ## Controls
151
+
152
+ The tool registers the `model` document Play extension
153
+ (`@volter/editor-sdk/kit/document-play-extension`). Beside Play and Stop it offers:
154
+
155
+ - `transport` — `setPaused`, `step`, `setSpeed`, `restart`, the `clock` and its own
156
+ subscription, the offered `speeds`, and the restart `generation` the document keys its
157
+ detached copy on.
158
+ - `transport.autoplay(documentId)` and `transport.setAutoplay(documentId, on, by)` — the bot's
159
+ switch: `{ on, available, by, armed }`, announced through `subscribeClock`;
160
+ `transport.armAutoplay(documentId, armed)` arms it, while stopped, for the next start.
161
+ - `log` — `tail(documentId, last, kind?)` (the newest entries and every kind the run wrote) and
162
+ `subscribe`, which the Game panel draws live.
163
+ - `scriptPath(sourcePath)` and `hasScript(sourcePath)` — where a model's play script goes and
164
+ whether it exists, so a layout can open a model with a script as a game.
165
+
166
+ In Cyclotron these are drawn by the Game panel (`@volter/editor-blender`): the Game /
167
+ Movie switch at the left of the bottom area's header puts it there in place of the Timeline. The panel's controls
168
+ are also commands, `volter.model-play.<verb>`, so an agent drives the same run the person
169
+ sees:
170
+
171
+ | Command | Arguments |
172
+ | --- | --- |
173
+ | `volter.model-play.state` | — |
174
+ | `volter.model-play.play` / `stop` | — (`play` also switches the document to Game mode) |
175
+ | `volter.model-play.pause` / `resume` | — |
176
+ | `volter.model-play.step` | `{ count?: 1–600 }`, while paused |
177
+ | `volter.model-play.speed` | `{ speed: 0.25 \| 0.5 \| 1 \| 2 \| 4 }` |
178
+ | `volter.model-play.restart` | — |
179
+ | `volter.model-play.mode` | `{ mode?: 'game' \| 'movie' }` |
180
+ | `volter.model-play.autoplay` | `{ on: boolean }` — the game's bot drives, or the person does |
181
+
182
+ Each takes an optional `document` (the model document's id) and otherwise acts on the model
183
+ document on screen; each answers with the panel's state. From the shell:
184
+ `cyclotron play pause`; under `eval`:
185
+ `await editor.command('volter.model-play.speed', { speed: 0.5 })`.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * THE MODEL PLAY VERB of the session wire (`@volter/editor-sdk/commands`, a
3
3
  * `workspace.command` contribution): `model-play-log`, the read behind
4
- * `volter-model-editor play-log` and `editor.modelPlayLog()` in `eval`.
4
+ * `cyclotron play-log` and `editor.modelPlayLog()` in `eval`.
5
5
  *
6
6
  * It answers one model document's play log (`../src/play-log.ts`) — `documentId`'s, or the
7
7
  * active Play's when omitted; the current run's, or the last one's after Stop — filtered by
@@ -1,9 +1,28 @@
1
1
  /** Model script tool, available to every product through project packages. */
2
2
  import { useSyncExternalStore } from 'react';
3
3
  import { Button, EditorIcon, editorIcons, MenuItem } from '@volter/editor-sdk/widgets';
4
+ import { editorHost } from '@volter/editor-sdk/host';
4
5
  import { registerDocumentPlayExtension, type DocumentPlayControlProps } from '@volter/editor-sdk/kit/document-play-extension';
5
- import { escapeModelPlay, modelPlaying, setModelPlaying, subscribeModelPlay } from '../src/model-play';
6
- import { runPlayScript } from '../src/play-script';
6
+ import {
7
+ armModelPlayAutoplay,
8
+ escapeModelPlay,
9
+ MODEL_PLAY_SPEEDS,
10
+ modelPlayAutoplay,
11
+ modelPlayClock,
12
+ modelPlayGeneration,
13
+ modelPlaying,
14
+ restartModelPlay,
15
+ setModelPlayAutoplay,
16
+ setModelPlayFailure,
17
+ setModelPlayPaused,
18
+ setModelPlaying,
19
+ setModelPlaySpeed,
20
+ stepModelPlay,
21
+ subscribeModelPlay,
22
+ subscribeModelPlayClock,
23
+ } from '../src/model-play';
24
+ import { playScriptPath, runPlayScript } from '../src/play-script';
25
+ import { beginModelPlayLog, subscribeModelPlayLog, tailModelPlayLog } from '../src/play-log';
7
26
  import type * as THREE from 'three';
8
27
  import { getCurrentProject, onProjectChange } from '@volter/editor-sdk/kit/active-project';
9
28
 
@@ -11,6 +30,7 @@ export const point = 'workspace.service';
11
30
  function usePlaying(documentId: string | undefined): boolean {
12
31
  return useSyncExternalStore(subscribeModelPlay, () => documentId ? modelPlaying(documentId) : false, () => false);
13
32
  }
33
+ // Kept because `DocumentPlayExtension` requires `Control` and `Menu` for a layout that draws Play in its header; Cyclotron's layout no longer does (its Play is the Game panel's).
14
34
  function Control({ documentId, onClose }: DocumentPlayControlProps) {
15
35
  const playing = usePlaying(documentId);
16
36
  return <Button size="compact" data-testid="model-play-button" aria-pressed={playing}
@@ -27,12 +47,64 @@ function Menu({ documentId, onClose }: DocumentPlayControlProps) {
27
47
  {playing ? 'Stop' : 'Play'}
28
48
  </MenuItem>;
29
49
  }
50
+
51
+ /**
52
+ * WHETHER A MODEL HAS A PLAY SCRIPT — `src/models/track.blend` has one when
53
+ * `src/models/track.play.ts` exists (`playScriptPath`), the same file Play imports. Asked once
54
+ * per path through the project's files door and then kept, corrected by that door's change
55
+ * events, so a layout can open a model with a script as a game and one without as a model. A
56
+ * project switch forgets every answer.
57
+ */
58
+ const scripts = new Map<string, boolean>();
59
+ const looking = new Set<string>();
60
+ const scriptListeners = new Set<() => void>();
61
+ function publishScripts(): void {
62
+ for (const listener of [...scriptListeners]) listener();
63
+ }
64
+ function projectPath(path: string): string {
65
+ return path.replaceAll('\\', '/').replace(/^\.\//, '');
66
+ }
67
+ function hasScript(sourcePath: string): boolean | null {
68
+ const path = projectPath(playScriptPath(sourcePath));
69
+ const known = scripts.get(path);
70
+ if (known !== undefined) return known;
71
+ if (!looking.has(path)) {
72
+ looking.add(path);
73
+ const project = getCurrentProject()?.rootPath;
74
+ void editorHost().files.exists(path).then(exists => exists, () => false).then(exists => {
75
+ looking.delete(path);
76
+ // An answer about the previous project is no answer about this one.
77
+ if (getCurrentProject()?.rootPath !== project) return;
78
+ scripts.set(path, exists);
79
+ publishScripts();
80
+ });
81
+ }
82
+ return null;
83
+ }
84
+
30
85
  export function start(): () => void {
86
+ const stopWatching = (() => {
87
+ try {
88
+ return editorHost().files.watch((event) => {
89
+ const path = projectPath(event.path);
90
+ if (!scripts.has(path) && !path.endsWith('.play.ts')) return;
91
+ const exists = event.type !== 'remove';
92
+ if (scripts.get(path) === exists) return;
93
+ scripts.set(path, exists);
94
+ publishScripts();
95
+ });
96
+ } catch {
97
+ // A host without a files door still plays; the answer is then read once per path.
98
+ return () => {};
99
+ }
100
+ })();
101
+ const stopProject = onProjectChange(() => { scripts.clear(); publishScripts(); });
31
102
  const unregister = registerDocumentPlayExtension('model', {
32
103
  Control, Menu, playing: modelPlaying, setPlaying: setModelPlaying, escape: escapeModelPlay,
33
104
  subscribe(listener) {
34
105
  const stopPlay = subscribeModelPlay(listener), stopProject = onProjectChange(listener);
35
- return () => { stopPlay(); stopProject(); };
106
+ scriptListeners.add(listener);
107
+ return () => { stopPlay(); stopProject(); scriptListeners.delete(listener); };
36
108
  },
37
109
  aspectRatio() {
38
110
  const size = getCurrentProject()?.config.resolution;
@@ -45,6 +117,36 @@ export function start(): () => void {
45
117
  camera: stage.camera as () => THREE.Camera, editingCamera: stage.editingCamera as () => THREE.Camera,
46
118
  ownMaterial: stage.ownMaterial as ((material: THREE.Material) => THREE.Material | null) | undefined });
47
119
  },
120
+ // THE RUN'S TRANSPORT (`model-play.ts`): the runner reads all of it each frame.
121
+ transport: {
122
+ speeds: MODEL_PLAY_SPEEDS,
123
+ clock: modelPlayClock,
124
+ subscribeClock: subscribeModelPlayClock,
125
+ setPaused: setModelPlayPaused,
126
+ step: stepModelPlay,
127
+ setSpeed: setModelPlaySpeed,
128
+ restart: restartModelPlay,
129
+ generation: modelPlayGeneration,
130
+ autoplay: modelPlayAutoplay,
131
+ setAutoplay: setModelPlayAutoplay,
132
+ armAutoplay: armModelPlayAutoplay,
133
+ // NO STAGE, SO NO RUNNER, and until 2026-10-06 the document turned Play off and wrote the
134
+ // reason to the console: Play flicked on and off with nothing said where anyone looks.
135
+ // Now the run stands with its failure — the Game panel draws it, the log records it.
136
+ fail(documentId, sourcePath, failure) {
137
+ const run = beginModelPlayLog(documentId, playScriptPath(sourcePath));
138
+ run.append('play', 'script-error', { phase: 'stage', message: failure });
139
+ run.end({ reason: 'stage-failed' });
140
+ setModelPlayFailure(documentId, failure);
141
+ },
142
+ },
143
+ log: { tail: tailModelPlayLog, subscribe: subscribeModelPlayLog },
144
+ scriptPath: playScriptPath,
145
+ hasScript,
48
146
  });
49
- return unregister;
147
+ return () => {
148
+ unregister();
149
+ stopProject();
150
+ stopWatching();
151
+ };
50
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/editor-model-play",
3
- "version": "0.5.190",
3
+ "version": "0.5.191",
4
4
  "author": "Volter AI, Inc.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  ]
29
29
  },
30
30
  "dependencies": {
31
- "@volter/editor-sdk": "0.5.190"
31
+ "@volter/editor-sdk": "0.5.191"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "react": "^19.0.0",
@@ -14,10 +14,14 @@ type Pose = ReturnType<typeof pose>;
14
14
  /** The tool blends AFTER the script has stated its complete camera pose.
15
15
  * Projection matrices also interpolate, preserving an orthographic editing
16
16
  * view exactly at the handoff to a perspective game. Neither editing camera
17
- * nor its orbit target is ever written. */
18
- export function cameraTransition(editingCamera: THREE.Camera) {
17
+ * nor its orbit target is ever written.
18
+ *
19
+ * `instant` enters already arrived: a Restart replaces a game that was on screen a frame ago,
20
+ * and flying in again from the editing pose would show the model between two games. The return
21
+ * on Stop still blends. */
22
+ export function cameraTransition(editingCamera: THREE.Camera, options?: { readonly instant?: boolean }) {
19
23
  const editing = pose(editingCamera);
20
- let phase: 'entering' | 'playing' | 'leaving' = 'entering';
24
+ let phase: 'entering' | 'playing' | 'leaving' = options?.instant ? 'playing' : 'entering';
21
25
  let elapsed = 0;
22
26
  let last = editing;
23
27
  let leavingFrom = editing;
@@ -52,6 +56,12 @@ export function cameraTransition(editingCamera: THREE.Camera) {
52
56
  ? Math.min(1, elapsed / duration)
53
57
  : Math.max(0, 1 - elapsed / (duration - 0.16)),
54
58
  approachingEdit: () => phase === 'leaving' && elapsed >= duration - 0.16,
59
+ /** A PAUSED frame: put the camera back where the last drawn frame had it. Nothing else
60
+ * states the pose while the game's update is held, and the stage's navigation runs before
61
+ * this hook each frame; the blend's own clock stands still with the game's. */
62
+ hold(camera: THREE.Camera): void {
63
+ blend(camera, last, last, 1);
64
+ },
55
65
  /** Escape completes an existing blend; otherwise begin the return. */
56
66
  stop(escape: boolean): boolean {
57
67
  if (escape && phase === 'entering') { phase = 'playing'; return false; }
package/src/model-play.ts CHANGED
@@ -1,25 +1,125 @@
1
1
  /**
2
- * WHETHER A MODEL DOCUMENT IS PLAYING (the header's Play, `blender-header-menus.tsx`; drawn by
2
+ * WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `blender-game-panel.tsx`; drawn by
3
3
  * `blender-runtime.document.tsx`). Playing, the document's area shows a detached copy of the
4
4
  * model (`BlenderRuntimeView.detach`) that the project's play script moves
5
5
  * (`play-script.ts`); the model, its selection and its history stand as they were, and Stop
6
6
  * returns to them. Kept for the page's life and never stored: a reload opens the model, not a
7
7
  * game.
8
+ *
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.
14
+ *
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.
8
25
  */
9
26
  const listeners = new Set<() => void>();
27
+ const clockListeners = new Set<() => void>();
10
28
  const playing = new Set<string>();
11
- const stops = new Map<string, (escape: boolean) => void>();
29
+ const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
30
+
31
+ /** The speeds the transport offers, slowest first (simulation seconds per real second). */
32
+ 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
+
36
+ export interface ModelPlayClock {
37
+ readonly time: number;
38
+ readonly tick: number;
39
+ readonly paused: boolean;
40
+ readonly speed: number;
41
+ /** The run is playing but no game runs: the script failed to start, or threw (`play-script.ts`). */
42
+ readonly failure: string | null;
43
+ /** A game has started in this run and runs (`DocumentPlayClock.running`). */
44
+ readonly running: boolean;
45
+ }
46
+ 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. */
49
+ 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
+ /** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
53
+ 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, or the running script no longer offering a bot (replaced, failed, or its bot
59
+ * threw). */
60
+ export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script';
61
+ export interface ModelPlayAutoplay {
62
+ /** The bot drives: its keys are merged into the keys the script reads. */
63
+ readonly on: boolean;
64
+ /** The running script registered a bot with `play.autoplay`. */
65
+ readonly available: boolean;
66
+ readonly by: ModelPlayAutoplayBy | null;
67
+ /** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
68
+ readonly armed: boolean;
69
+ }
70
+ const NO_BOT: ModelPlayAutoplay = { on: false, available: false, by: null, armed: false };
71
+ const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
72
+ /** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
73
+ const autoplays = new Map<string, ModelPlayAutoplay>();
74
+
75
+ /** A new run's autoplay: off, no bot yet. Play keeps an arm made while stopped; Stop drops it. */
76
+ function resetAutoplay(documentId: string, keepArm: boolean): void {
77
+ if (keepArm && modelPlayAutoplay(documentId).armed) autoplays.set(documentId, ARMED);
78
+ else autoplays.delete(documentId);
79
+ }
80
+
81
+ function publish(): void {
82
+ for (const listener of [...listeners]) listener();
83
+ }
84
+ function publishClock(): void {
85
+ for (const listener of [...clockListeners]) listener();
86
+ }
87
+ function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
88
+ clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
89
+ publishClock();
90
+ }
12
91
 
13
92
  export function modelPlaying(documentId: string): boolean {
14
93
  return playing.has(documentId);
15
94
  }
16
95
 
17
96
  export function setModelPlaying(documentId: string, value: boolean): void {
18
- if (playing.has(documentId) === value) return;
19
- if (!value && stops.has(documentId)) { stops.get(documentId)!(false); return; }
20
- if (value) playing.add(documentId);
21
- else playing.delete(documentId);
22
- for (const listener of [...listeners]) listener();
97
+ if (playing.has(documentId) === value) {
98
+ // Stop with nothing playing is how a closing document lets go of its play: an arm made while
99
+ // stopped goes with it, or reopening the model and pressing Play would still take it.
100
+ if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
101
+ return;
102
+ }
103
+ const stop = value ? null : currentStop(documentId);
104
+ if (stop) { stop(false); return; }
105
+ if (value) {
106
+ playing.add(documentId);
107
+ // A new run starts its clock at zero and running; the speed is the person's and stays.
108
+ steps.delete(documentId);
109
+ resetAutoplay(documentId, true);
110
+ setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
111
+ } else {
112
+ playing.delete(documentId);
113
+ restarted.delete(documentId);
114
+ steps.delete(documentId);
115
+ resetAutoplay(documentId, false);
116
+ // The clock keeps the stopped run's time and tick, so the panel still says how far it got.
117
+ // Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
118
+ // Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
119
+ // the clock alone must see the run end too.
120
+ setClock(documentId, { paused: false, failure: null, running: false });
121
+ }
122
+ publish();
23
123
  }
24
124
 
25
125
  export function finishModelPlay(documentId: string): void {
@@ -28,14 +128,36 @@ export function finishModelPlay(documentId: string): void {
28
128
  }
29
129
 
30
130
  export function escapeModelPlay(documentId: string): void {
31
- const stop = stops.get(documentId);
131
+ const stop = currentStop(documentId);
32
132
  if (stop) stop(true);
33
133
  else finishModelPlay(documentId);
34
134
  }
35
135
 
136
+ /**
137
+ * A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
138
+ * generation, which blends the camera back first; a stop a Restart has superseded is never
139
+ * asked, because that runner stands still from the moment its generation passes
140
+ * (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
141
+ * runner (a restarted game still preparing) the play simply ends.
142
+ */
36
143
  export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
37
- stops.set(documentId, stop);
38
- return () => { if (stops.get(documentId) === stop) stops.delete(documentId); };
144
+ const entry = { stop, generation: modelPlayGeneration(documentId) };
145
+ stops.set(documentId, entry);
146
+ return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
147
+ }
148
+
149
+ function currentStop(documentId: string): ((escape: boolean) => void) | null {
150
+ const entry = stops.get(documentId);
151
+ return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
152
+ }
153
+
154
+ /** The runner's report that no game runs although the run plays (`null` once one does, which
155
+ * is also the moment the run is `running`). */
156
+ export function setModelPlayFailure(documentId: string, failure: string | null): void {
157
+ const clock = modelPlayClock(documentId);
158
+ const running = failure === null;
159
+ if (!playing.has(documentId) || (clock.failure === failure && clock.running === running)) return;
160
+ setClock(documentId, { failure, running });
39
161
  }
40
162
 
41
163
  export function subscribeModelPlay(listener: () => void): () => void {
@@ -44,3 +166,124 @@ export function subscribeModelPlay(listener: () => void): () => void {
44
166
  listeners.delete(listener);
45
167
  };
46
168
  }
169
+
170
+ export function modelPlayClock(documentId: string): ModelPlayClock {
171
+ return clocks.get(documentId) ?? STILL;
172
+ }
173
+
174
+ export function subscribeModelPlayClock(listener: () => void): () => void {
175
+ clockListeners.add(listener);
176
+ return () => {
177
+ clockListeners.delete(listener);
178
+ };
179
+ }
180
+
181
+ /** Hold or release the game's updates. Only a playing document has updates to hold. */
182
+ export function setModelPlayPaused(documentId: string, paused: boolean): void {
183
+ if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
184
+ if (!paused) steps.delete(documentId);
185
+ setClock(documentId, { paused });
186
+ }
187
+
188
+ /** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
189
+ * so each one is seen. */
190
+ export function stepModelPlay(documentId: string): void {
191
+ if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
192
+ steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
193
+ }
194
+
195
+ /** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
196
+ export function takeModelPlayStep(documentId: string): boolean {
197
+ const pending = steps.get(documentId) ?? 0;
198
+ if (pending === 0) return false;
199
+ if (pending === 1) steps.delete(documentId);
200
+ else steps.set(documentId, pending - 1);
201
+ return true;
202
+ }
203
+
204
+ export function setModelPlaySpeed(documentId: string, speed: number): void {
205
+ if (!MODEL_PLAY_SPEEDS.includes(speed))
206
+ throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
207
+ if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
208
+ }
209
+
210
+ /** The runner's report of the updates it ran this frame. */
211
+ export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
212
+ if (ticks === 0) return;
213
+ const clock = modelPlayClock(documentId);
214
+ setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
215
+ }
216
+
217
+ export function modelPlayGeneration(documentId: string): number {
218
+ return generations.get(documentId) ?? 0;
219
+ }
220
+
221
+ /**
222
+ * BEGIN THE RUN AGAIN ON A FRESH COPY. Playing stays true throughout — nothing returns to the
223
+ * model — and the generation moves, which is what makes the document detach a new copy and mount
224
+ * a new runner over it; the old runner sees it is no longer the current generation and stands
225
+ * down without ending the play. A document that is not playing simply starts.
226
+ */
227
+ export function restartModelPlay(documentId: string): void {
228
+ if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
229
+ generations.set(documentId, modelPlayGeneration(documentId) + 1);
230
+ restarted.add(documentId);
231
+ steps.delete(documentId);
232
+ // An arm not yet taken (the game was still starting) carries to the fresh run.
233
+ resetAutoplay(documentId, true);
234
+ setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
235
+ publish();
236
+ }
237
+
238
+ /** Whether the run starting now was begun by Restart (asked once, by that run's runner). */
239
+ export function consumeModelPlayRestart(documentId: string): boolean {
240
+ return restarted.delete(documentId);
241
+ }
242
+
243
+ export function modelPlayAutoplay(documentId: string): ModelPlayAutoplay {
244
+ return autoplays.get(documentId) ?? NO_BOT;
245
+ }
246
+
247
+ function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
248
+ autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
249
+ publishClock();
250
+ }
251
+
252
+ /** Switch the running script's bot on or off. On asks for a playing document whose script
253
+ * registered a bot; off always succeeds, and also drops an arm not yet taken — a person who
254
+ * takes over while the game is still starting is driving, and the arm must not override them. */
255
+ export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy): void {
256
+ const now = modelPlayAutoplay(documentId);
257
+ if (on && !playing.has(documentId))
258
+ throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
259
+ const clock = modelPlayClock(documentId);
260
+ if (on && !now.available)
261
+ throw new Error(clock.running
262
+ ? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
263
+ : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
264
+ if (on ? !now.on : now.on || now.armed) setAutoplay(documentId, on ? { on, by } : { on, by, armed: false });
265
+ }
266
+
267
+ /** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
268
+ * before Play. Disarming always succeeds. */
269
+ export function armModelPlayAutoplay(documentId: string, armed: boolean): void {
270
+ if (armed && playing.has(documentId))
271
+ throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
272
+ if (modelPlayAutoplay(documentId).armed !== armed) setAutoplay(documentId, { armed });
273
+ }
274
+
275
+ /** The runner's report that a script has run its first update, offering a bot or not. An arm is
276
+ * taken here: on with a bot, dropped without one. */
277
+ export function settleModelPlayAutoplay(documentId: string, offered: boolean): void {
278
+ const now = modelPlayAutoplay(documentId);
279
+ if (!playing.has(documentId)) return;
280
+ if (!now.armed) { setModelPlayAutoplayAvailable(documentId, offered); return; }
281
+ setAutoplay(documentId, offered ? { available: true, armed: false, on: true, by: 'panel' } : { available: false, armed: false, by: 'script' });
282
+ }
283
+
284
+ /** The runner's report of whether the running script offers a bot. Losing it turns autoplay off. */
285
+ export function setModelPlayAutoplayAvailable(documentId: string, available: boolean): void {
286
+ const now = modelPlayAutoplay(documentId);
287
+ if (now.available === available || !playing.has(documentId)) return;
288
+ setAutoplay(documentId, available || !now.on ? { available } : { available, on: false, by: 'script' });
289
+ }
package/src/play-log.ts CHANGED
@@ -4,15 +4,21 @@
4
4
  * The game editor's play log is the receipt of a run (`play-*.jsonl`, `log-format.ts`); this
5
5
  * is Model Play's. A script calls `play.log(kind, facts)` (`play-script.ts`) on the
6
6
  * transitions that explain a run — a jump, a landing, a death and its cause, autoplay's
7
- * choice — and an agent reads them back with `volter-model-editor play-log` or
7
+ * choice — and an agent reads them back with `cyclotron play-log` or
8
8
  * `editor.modelPlayLog()` in `eval`. `console.log` cannot do this job: the session's
9
9
  * console feed keeps warnings and errors only.
10
10
  *
11
11
  * Every entry carries `simT` (the seconds of simulation since Play started: the sum of the
12
12
  * `dt`s the runner has handed to `update`) and `tick` (the number of the update in
13
13
  * progress; 0 while the script's default export runs), the same two stamps the game
14
- * editor's entries carry. The runner adds its own lifecycle entries (`source: 'play'`):
15
- * `play-start`, `script-reload` with its reason, `script-error`, `tint-unsupported`,
14
+ * editor's entries carry. They are the Game panel's clock too: the runner advances both at
15
+ * the same call (`play-script.ts`), so they stand still while paused and run at the speed. The
16
+ * runner adds its own lifecycle entries (`source: 'play'`): `play-start`, `play-restart` (a
17
+ * Restart's fresh run, right after its `play-start`), `script-reload` with its reason,
18
+ * `script-error`, `tint-unsupported`, `tint-unknown-object`, `pause`, `resume`, `step` (one per
19
+ * stepped update, with its `dt`), `speed` (on a change, and at a start that is not 1×),
20
+ * `autoplay-on` and `autoplay-off` (each with `by`: `panel`, `cli`, `takeover`, `script`),
21
+ * `autoplay-unavailable` (a script ran its first update without offering a bot),
16
22
  * `play-stop`.
17
23
  *
18
24
  * ONE LOG PER MODEL DOCUMENT, each its document's latest run. Two documents playing at once
@@ -28,6 +34,9 @@
28
34
  * at most {@link MODEL_PLAY_LOG_FACTS_CHARS} characters. It is never stored: a reload of the
29
35
  * page forgets it, as it forgets the game.
30
36
  *
37
+ * THE GAME PANEL DRAWS IT LIVE: {@link subscribeModelPlayLog} hears every write, and
38
+ * {@link tailModelPlayLog} reads the newest entries of one document without copying the ring.
39
+ *
31
40
  * Logging is a read of the script's own values: it never throws into the script and touches
32
41
  * neither the clock nor the copy.
33
42
  */
@@ -90,6 +99,8 @@ interface Run {
90
99
  tick: number;
91
100
  readonly ring: ModelPlayLogEntry[];
92
101
  total: number;
102
+ /** Every kind this run has written, for the panel's filter. */
103
+ readonly kinds: Set<string>;
93
104
  }
94
105
 
95
106
  /** The runner's door onto its own run's log: inert once the run has ended. */
@@ -106,6 +117,21 @@ export interface ModelPlayRun {
106
117
  const key = Symbol.for('volter.model-play-log');
107
118
  const page = globalThis as typeof globalThis & { [key]?: Map<string, Run> };
108
119
  const runs: Map<string, Run> = page[key] ??= new Map();
120
+ const listenersKey = Symbol.for('volter.model-play-log.listeners');
121
+ const pageListeners = globalThis as typeof globalThis & { [listenersKey]?: Set<() => void> };
122
+ const listeners: Set<() => void> = pageListeners[listenersKey] ??= new Set();
123
+
124
+ function publish(): void {
125
+ for (const listener of [...listeners]) {
126
+ try { listener(); } catch { /* A reader's failure is not the writer's. */ }
127
+ }
128
+ }
129
+
130
+ /** Hear every write to any document's log (several may land in one frame; coalesce). */
131
+ export function subscribeModelPlayLog(listener: () => void): () => void {
132
+ listeners.add(listener);
133
+ return () => { listeners.delete(listener); };
134
+ }
109
135
 
110
136
  function keptFacts(facts: unknown): Record<string, unknown> | undefined {
111
137
  if (facts === undefined || facts === null) return undefined;
@@ -128,13 +154,15 @@ function append(run: Run, source: ModelPlayLogEntry['source'], kind: string, fac
128
154
  };
129
155
  run.ring[run.total % MODEL_PLAY_LOG_CAPACITY] = entry;
130
156
  run.total += 1;
157
+ run.kinds.add(entry.kind);
131
158
  } catch { /* A log that cannot be written is not the script's failure. */ }
159
+ publish();
132
160
  }
133
161
 
134
162
  /** Play started from a fresh copy: the document's log replaced by an empty one at zero,
135
163
  * opened by `play-start`. */
136
164
  export function beginModelPlayLog(documentId: string, script: string): ModelPlayRun {
137
- const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0 };
165
+ const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0, kinds: new Set() };
138
166
  // Re-inserted, so the map's order is the order Plays started.
139
167
  runs.delete(documentId);
140
168
  runs.set(documentId, run);
@@ -181,3 +209,25 @@ export function readModelPlayLog(query: ModelPlayLogQuery = {}): ModelPlayLogRea
181
209
  simT: run.simT, tick: run.tick, capacity: MODEL_PLAY_LOG_CAPACITY, total: run.total, dropped, entries,
182
210
  };
183
211
  }
212
+
213
+ export interface ModelPlayLogTail {
214
+ /** Entries the document's latest run has written; 0 when it has no log. */
215
+ readonly total: number;
216
+ /** Every kind that run has written, in the order first seen. */
217
+ readonly kinds: readonly string[];
218
+ /** The newest kept entries (of the asked kind), at most `last`, oldest first. */
219
+ readonly entries: readonly ModelPlayLogEntry[];
220
+ }
221
+
222
+ /** The newest `last` entries of one document's log, walked from the newest end. */
223
+ export function tailModelPlayLog(documentId: string, last: number, kind?: string): ModelPlayLogTail {
224
+ const run = runs.get(documentId);
225
+ if (!run) return { total: 0, kinds: [], entries: [] };
226
+ const oldest = Math.max(0, run.total - MODEL_PLAY_LOG_CAPACITY);
227
+ const entries: ModelPlayLogEntry[] = [];
228
+ for (let seq = run.total - 1; seq >= oldest && entries.length < last; seq--) {
229
+ const entry = run.ring[seq % MODEL_PLAY_LOG_CAPACITY]!;
230
+ if (kind === undefined || entry.kind === kind) entries.push(entry);
231
+ }
232
+ return { total: run.total, kinds: [...run.kinds], entries: entries.reverse() };
233
+ }
@@ -25,7 +25,10 @@
25
25
  * The presenter's slots are re-read whenever they may have moved: an object whose slots the
26
26
  * presenter has put back is given copies of the slots it now has, both each frame and before
27
27
  * any change, so clearing returns the presenter's current slots, never a stale set. An object
28
- * the script removed from the copy is let go on the next frame.
28
+ * the script removed from the copy is let go on the next frame. That is heard, not searched
29
+ * for: each overridden mesh and its parents up to the copy's root report their own removal
30
+ * (three's `removed` event, which `remove`, `add` elsewhere and `attach` all dispatch), and only
31
+ * a mesh one of them reported is walked up to see whether it is still in the copy.
29
32
  */
30
33
  import type * as THREE from 'three';
31
34
 
@@ -39,6 +42,11 @@ interface Override {
39
42
  copies: Colored[];
40
43
  color: THREE.ColorRepresentation | null;
41
44
  opacity: number | null;
45
+ /** The mesh and its parents below the copy's root, each told to report its removal. */
46
+ watched: THREE.Object3D[];
47
+ /** One of them was removed from its parent since the last frame. */
48
+ moved: boolean;
49
+ readonly onRemoved: () => void;
42
50
  }
43
51
 
44
52
  const slotsOf = (slots: Slots): THREE.Material[] => Array.isArray(slots) ? slots : [slots];
@@ -71,6 +79,20 @@ export function materialOverrides(options: {
71
79
  for (const copy of override.copies) copy.dispose();
72
80
  override.copies = [];
73
81
  };
82
+ const unwatch = (override: Override): void => {
83
+ for (const object of override.watched) object.removeEventListener('removed', override.onRemoved);
84
+ override.watched = [];
85
+ };
86
+ /** Hear the mesh or a parent leave; a mesh not under the copy's root is let go next frame. */
87
+ const watch = (override: Override): void => {
88
+ unwatch(override);
89
+ let at: THREE.Object3D | null = override.mesh;
90
+ for (; at && at !== root; at = at.parent) {
91
+ at.addEventListener('removed', override.onRemoved);
92
+ override.watched.push(at);
93
+ }
94
+ override.moved = at !== root;
95
+ };
74
96
  /** Wear copies of the object's slots; false (and nothing worn) when one cannot be copied. */
75
97
  const dress = (override: Override): boolean => {
76
98
  release(override);
@@ -116,6 +138,7 @@ export function materialOverrides(options: {
116
138
  const forget = (override: Override): void => {
117
139
  if (override.mesh.material === override.shown) override.mesh.material = override.authored;
118
140
  release(override);
141
+ unwatch(override);
119
142
  overrides.delete(override.mesh);
120
143
  };
121
144
  /** The presenter re-assigned the object's slots since it last wore copies: adopt its slots
@@ -126,6 +149,7 @@ export function materialOverrides(options: {
126
149
  override.shown = override.mesh.material;
127
150
  if (dress(override)) return true;
128
151
  release(override);
152
+ unwatch(override);
129
153
  overrides.delete(override.mesh);
130
154
  return false;
131
155
  };
@@ -137,21 +161,19 @@ export function materialOverrides(options: {
137
161
  let override = overrides.get(mesh);
138
162
  if (override && !resync(override)) override = undefined;
139
163
  if (!override) {
140
- const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null };
164
+ const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null,
165
+ watched: [], moved: false, onRemoved: () => { fresh.moved = true; } };
141
166
  edit(fresh);
142
167
  if (fresh.color === null && fresh.opacity === null) return;
143
168
  if (!dress(fresh)) return;
144
169
  overrides.set(mesh, fresh);
170
+ watch(fresh);
145
171
  override = fresh;
146
172
  } else edit(override);
147
173
  if (override.color === null && override.opacity === null) forget(override);
148
174
  else paint(override);
149
175
  });
150
176
  };
151
- const inCopy = (object: THREE.Object3D): boolean => {
152
- for (let at: THREE.Object3D | null = object; at; at = at.parent) if (at === root) return true;
153
- return false;
154
- };
155
177
  return {
156
178
  tint(target, color) { change(target, (override) => { override.color = color; }); },
157
179
  setOpacity(target, opacity) {
@@ -161,7 +183,11 @@ export function materialOverrides(options: {
161
183
  },
162
184
  frame() {
163
185
  for (const override of [...overrides.values()]) {
164
- if (!inCopy(override.mesh)) { forget(override); continue; }
186
+ if (override.moved) {
187
+ // Re-walked only after a reported removal; one put back under the copy is watched anew.
188
+ watch(override);
189
+ if (override.moved) { forget(override); continue; }
190
+ }
165
191
  if (override.mesh.material !== override.shown && resync(override)) paint(override);
166
192
  }
167
193
  },
@@ -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,22 @@ 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';
42
85
  import { beginModelPlayLog } from './play-log';
43
86
  import { materialOverrides } from './play-materials';
44
87
  interface PlayComposition {
@@ -64,7 +107,7 @@ export interface ModelPlayContext {
64
107
  * Write one entry to the play log, stamped with the run's simulation time and frame
65
108
  * (`play-log.ts`): a kind and the facts that explain it, JSON-serialisable and snapshotted
66
109
  * now. Log transitions (a landing, a death and its cause, autoplay's choice), not every
67
- * frame. Read back with `volter-model-editor play-log [--since <simT>] [--kind <k>] [--json]`
110
+ * frame. Read back with `cyclotron play-log [--since <simT>] [--kind <k>] [--json]`
68
111
  * or `editor.modelPlayLog({ since, kind })` in `eval`. Never throws, never changes the game,
69
112
  * and does nothing once this script has been replaced or Play has stopped.
70
113
  *
@@ -92,14 +135,65 @@ export interface ModelPlayContext {
92
135
  /** Fade an object and the meshes under it to `opacity` (0 to 1); `null` returns the
93
136
  * authored opacity. Its colour is untouched, and the copies are `tint`'s. */
94
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>;
95
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;
96
163
 
97
164
  export interface ModelPlayGame {
98
- /** 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. */
99
168
  update(deltaSeconds: number): void;
100
169
  dispose?(): void;
101
170
  }
102
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
+
103
197
  /** The play script's project path for a model's `.blend`. */
104
198
  export function playScriptPath(blend: string): string {
105
199
  return blend.replace(/\.blend$/i, '') + '.play.ts';
@@ -148,9 +242,14 @@ export function runPlayScript(options: {
148
242
  // A fresh copy is a fresh run: its log starts empty, its clock at zero. Writes go through
149
243
  // this run's handle, which is inert once the run has ended.
150
244
  const run = beginModelPlayLog(options.documentId, modulePath);
151
- const report = (phase: 'start' | 'update' | 'stop', title: string, error: unknown): void => {
245
+ const report = (phase: 'start' | 'update' | 'stop' | 'autoplay', title: string, error: unknown): void => {
152
246
  const detail = error instanceof Error ? error.message : String(error);
153
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}`);
154
253
  options.report(title, detail);
155
254
  };
156
255
  // Said once per object: a script that tints every frame would otherwise fill the log.
@@ -165,19 +264,48 @@ export function runPlayScript(options: {
165
264
  why: options.ownMaterial ? 'the material is not one the document presents' : 'this document lends no material copies' });
166
265
  },
167
266
  });
168
- const objectOf = (target: THREE.Object3D | string): THREE.Object3D => {
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 => {
169
269
  if (typeof target !== 'string') return target;
170
- const object = root.getObjectByName(target);
171
- if (!object) throw new Error(`The model has no object named ${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
+ }
172
275
  return object;
173
276
  };
174
277
  const keys = new Set<string>();
175
278
  const heldKeys = new Set<string>();
176
- 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
+ });
177
305
  options.container.style.opacity = '0';
178
- /** One script's context: its log, tint and opacity do nothing once that script is gone
306
+ /** One script's context: its log, tint, opacity and bot do nothing once that script is gone
179
307
  * (replaced, failed, or the run stopped), so a stale timer cannot reach a later one. */
180
- const contextFor = (alive: { value: boolean }): ModelPlayContext => ({
308
+ const contextFor = (alive: Script): ModelPlayContext => ({
181
309
  root,
182
310
  find(name) {
183
311
  const object = root.getObjectByName(name) ?? null;
@@ -190,10 +318,21 @@ export function runPlayScript(options: {
190
318
  },
191
319
  keys,
192
320
  log(kind, facts) { if (alive.value) run.append('script', kind, facts); },
193
- tint(object, color) { if (alive.value) materials.tint(objectOf(object), color); },
194
- setOpacity(object, opacity) { if (alive.value) materials.setOpacity(objectOf(object), opacity); },
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
+ },
195
334
  });
196
- const scripts = new WeakMap<ModelPlayGame, { value: boolean }>();
335
+ const scripts = new WeakMap<ModelPlayGame, Script>();
197
336
  let stopped = false;
198
337
  let attempt = 0;
199
338
  let game: ModelPlayGame | null = null;
@@ -224,7 +363,7 @@ export function runPlayScript(options: {
224
363
  } finally {
225
364
  // After its own dispose, which may still log; nothing it scheduled may.
226
365
  const alive = ending && scripts.get(ending);
227
- if (alive) alive.value = false;
366
+ if (alive) { alive.value = false; alive.bot = null; }
228
367
  layers?.dispose();
229
368
  }
230
369
  };
@@ -240,7 +379,7 @@ export function runPlayScript(options: {
240
379
  const mine = ++attempt;
241
380
  if (pending) { dispose(pending.game, pending.composition); pending = null; }
242
381
  let nextComposition: PlayComposition | undefined;
243
- const alive = { value: true };
382
+ const alive: Script = { value: true, bot: null, unknown: new Set() };
244
383
  try {
245
384
  if (mountLayers) {
246
385
  const project = getCurrentProject();
@@ -282,6 +421,9 @@ export function runPlayScript(options: {
282
421
  }
283
422
  };
284
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;
285
427
  let returning = false;
286
428
  let stopReason = 'stop';
287
429
  const stopRequest = registerModelPlayStop(options.documentId, (escape) => {
@@ -291,6 +433,13 @@ export function runPlayScript(options: {
291
433
  if (firstFrame || transition.stop(escape)) finishModelPlay(options.documentId);
292
434
  });
293
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
+ }
294
443
  if (transition.leaving()) {
295
444
  options.container.style.opacity = String(transition.hudOpacity());
296
445
  if (!returning && transition.approachingEdit()) { returning = true; options.returning(); }
@@ -300,33 +449,114 @@ export function runPlayScript(options: {
300
449
  if (!surfaceHoldsKeyboard()) { keys.clear(); heldKeys.clear(); }
301
450
  else if (!transition.acceptingKeys()) keys.clear();
302
451
  else for (const key of heldKeys) keys.add(key);
303
- let replacementUpdated = false;
304
- if (pending || game) run.advance(deltaSeconds);
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;
305
516
  if (pending) {
517
+ firstSlot = 1;
306
518
  const next = pending;
307
519
  pending = null;
308
520
  try {
309
- next.game.update(deltaSeconds);
521
+ update(next.game, updates[0]!);
310
522
  end();
311
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);
312
537
  startedAt = Date.now();
313
538
  endedAt = null;
314
539
  composition = next.composition;
315
540
  live.notifyChanged();
316
541
  composition?.reveal();
317
- replacementUpdated = true;
318
542
  } catch (error) {
319
543
  dispose(next.game, next.composition);
320
544
  report('start', `${modulePath} did not start`, error);
321
545
  }
322
546
  }
323
- if (game === null) return;
547
+ if (game === null) { advanceModelPlayClock(options.documentId, simulated, ran); keys.clear(); return; }
324
548
  try {
325
- 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;
326
554
  transition.frame(camera(), deltaSeconds);
327
555
  options.container.style.opacity = String(transition.hudOpacity());
328
556
  if (firstFrame) { firstFrame = false; options.ready(); }
329
557
  } catch (error) {
558
+ advanceModelPlayClock(options.documentId, simulated, ran);
559
+ keys.clear();
330
560
  end();
331
561
  report('update', `${modulePath} failed`, error);
332
562
  return;
@@ -335,8 +565,21 @@ export function runPlayScript(options: {
335
565
  keys.clear();
336
566
  root.updateMatrixWorld(true);
337
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
+ };
338
578
  const onKeyDown = (event: KeyboardEvent): void => {
339
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();
340
583
  heldKeys.add(event.code);
341
584
  if (transition.acceptingKeys()) keys.add(event.code);
342
585
  };
@@ -344,7 +587,11 @@ export function runPlayScript(options: {
344
587
  // Preserve a between-frame tap until one game update has observed it.
345
588
  heldKeys.delete(event.code);
346
589
  };
590
+ const onPointerDown = (event: PointerEvent): void => {
591
+ if (event.target instanceof Node && surface.contains(event.target)) takeover();
592
+ };
347
593
  const onBlur = (): void => { keys.clear(); heldKeys.clear(); };
594
+ window.addEventListener('pointerdown', onPointerDown, true);
348
595
  window.addEventListener('keydown', onKeyDown, true);
349
596
  window.addEventListener('keyup', onKeyUp, true);
350
597
  window.addEventListener('blur', onBlur);
@@ -369,9 +616,12 @@ export function runPlayScript(options: {
369
616
  stopped = true;
370
617
  unregisterLive();
371
618
  stopRequest();
372
- finishModelPlay(options.documentId);
619
+ // A runner replaced by Restart leaves the play running for its successor.
620
+ if (current()) finishModelPlay(options.documentId);
373
621
  stopFrames();
622
+ stopClock();
374
623
  stopChanges();
624
+ window.removeEventListener('pointerdown', onPointerDown, true);
375
625
  window.removeEventListener('keydown', onKeyDown, true);
376
626
  window.removeEventListener('keyup', onKeyUp, true);
377
627
  window.removeEventListener('blur', onBlur);