@volter/play 0.5.212 → 0.5.214

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.
@@ -1,36 +1,27 @@
1
1
  /**
2
- * THE GAME PANEL — what the Blender document's bottom area shows in GAME mode (editor-blender's play mode), in place of
3
- * the Timeline (owner, 2026-10-06: "In game mode, the bottom panel is replaced with a game
4
- * version of something like that. Perhaps that's where the 'autoplay' controls and info will
5
- * live instead").
6
- *
7
- * ## One Play, here
8
- *
9
- * The confusion it ends was two Plays on one screen: the header's (the level's game) and the
10
- * Timeline's (the file's animation). A mode now chooses which one is shown. In Animation mode the
11
- * Timeline's transport is the one shown; in Game mode this panel's Play is, and the Timeline is
12
- * not mounted. A game started in Game mode keeps running in Animation mode. The 3D viewport's header carries neither (`blender-header-menus.tsx`
13
- * is its menus alone). Escape still stops a game, from the stage, as before.
2
+ * THE GAME PANEL — Play, the project's, in editor-blender's bottom area: under every document, and below a model in its
3
+ * Game mode, where its Animation mode shows Blender's Timeline instead (owner, 2026-10-06: "In game mode, the bottom
4
+ * panel is replaced with a game version of something like that. Perhaps that's where the 'autoplay' controls and info
5
+ * will live instead").
14
6
  *
15
7
  * ## Where it is
16
8
  *
17
- * The Blender document's bottom area shows it (`@volter/editor-blender`'s `blender-play-area.tsx`): play registers it
18
- * as the model Play's `Panel` (`play.page.tsx`, through editor-blender's API), and the area's own Game | Animation switch and size stay
19
- * the area's. It draws with the area's button and the Timeline's widget table.
9
+ * play registers it through editor-blender's API (`registerPlay`, `play.page.tsx`); the area's own Game | Animation
10
+ * switch below a model and its size stay the area's. It draws with the area's button and the Timeline's widget table.
11
+ * The game itself shows in the Game view (`../src/game-view.tsx`), which Play opens.
20
12
  *
21
- * ## What it drives is the Play tool's own run
13
+ * ## What it drives is the game's run
22
14
  *
23
- * Every control here is a call on the Play tool's door (`DocumentPlayExtension` and its
24
- * `transport`, `@volter/editor-blender/api`), each std's own control said to the game's pages
25
- * (`../src/std-controls.ts`): Pause holds the game's own clock, and the clock drawn here is the
26
- * one std answered, the game's seconds and frames. The verbs below are the same calls behind
27
- * command ids (`volter.model-play.<verb>`), which is how an agent drives exactly what the person
28
- * does: `cyclotron play pause`, or `await editor.command('volter.model-play.pause')` under `eval`.
15
+ * Every control here is a call on the run (`../src/game-run.ts`, `../src/game-controls.ts`), each std's own control said
16
+ * to the game's pages (`../src/std-controls.ts`): Pause holds the game's own clock, and the clock drawn here is the one
17
+ * std answered, the game's seconds and frames. The verbs below are the same calls behind command ids
18
+ * (`volter.play.<verb>`), which is how an agent drives exactly what the person does: `cyclotron play pause`, or
19
+ * `await editor.command('volter.play.pause')` under `eval`.
29
20
  *
30
21
  * ## Autoplay is the editor's switch, and the person always wins
31
22
  *
32
23
  * A std game offers its bot's goals (its `autopilot/` program's); whether the bot drives the
33
- * player in view, and with which goal, is this panel's toggle (or `volter.model-play.autoplay`).
24
+ * player in view, and with which goal, is this panel's toggle (or `volter.play.autoplay`).
34
25
  * A person's own key or click in the player's page takes it back on the spot (std's page does
35
26
  * that), so the driver line reads "You're driving". Stopped, pressing Autoplay arms it for the
36
27
  * next start. Where it cannot be switched on, the panel SAYS why, and `play state` carries the
@@ -38,8 +29,8 @@
38
29
  *
39
30
  * ## The play log, live
40
31
  *
41
- * The right of the panel is the run's play log as it is written (the Play tool's `log` door):
42
- * the newest entries of this document, newest at the bottom, filtered by kind. It follows the
32
+ * The right of the panel is the run's play log as it is written (`../src/play-log.ts`): its newest entries, newest at
33
+ * the bottom, filtered by kind. It follows the
43
34
  * newest entry until the person scrolls up to read, and follows again once they scroll back to
44
35
  * the bottom.
45
36
  *
@@ -50,12 +41,6 @@
50
41
  * playhead's blue, which is Blender's own selected-widget colour in the same theme.
51
42
  */
52
43
  import type { ViewVerb } from '@volter/sdk/commands';
53
- import type {
54
- DocumentPlayAutoplay,
55
- DocumentPlayClock,
56
- DocumentPlayExtension,
57
- DocumentPlayTransport,
58
- } from '@volter/editor-blender/api';
59
44
  import {
60
45
  type ReactNode,
61
46
  useCallback,
@@ -67,129 +52,75 @@ import {
67
52
  useState,
68
53
  useSyncExternalStore,
69
54
  } from 'react';
70
- import { editorBlender, registeredModelPlay, subscribeRegisteredModelPlay } from '../src/editor-blender';
55
+ import { editorBlender } from '../src/editor-blender';
71
56
  import { CLOCK_TEXT_INTERVAL_MS, type ClockState, clockStateReader, throttledSubscription } from '../src/play-clock';
72
57
  import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
73
58
  import { editorHost } from '@volter/sdk/host';
74
- import { activateWorkspaceDocument } from '@volter/sdk/workspace-document-registry';
75
- import { captureStdView, isStdGame, stdRun, stdRunVersion, subscribeStdGame, subscribeStdRun } from '../src/std-game';
76
- import { EXPORT_LABELS, EXPORT_TARGETS, isExportTarget, onStdExport, setExportMenuOpen, startStdExport, stdExport, stdTargets, type ExportTarget, type StdExport } from '../src/std-export';
59
+ import { GAME_SPEEDS, gameClock, type GameClock, gamePlaying, subscribeGame, subscribeGameClock } from '../src/game-run';
60
+ import {
61
+ armAutoplay,
62
+ type GameAutoplay,
63
+ gameAutoplay,
64
+ pauseGame,
65
+ playGame,
66
+ restartPlay,
67
+ setGameAutoplay,
68
+ setGameSpeedOf,
69
+ stepGame,
70
+ subscribeGameControls,
71
+ } from '../src/game-controls';
72
+ import { captureStdView, isStdGame, MOST_PLAYERS, setStdPlayers, STD_DEV, stdPlayersAsked, stepStdPlayers, stdRun, stdRunVersion, subscribeStdGame, subscribeStdRun } from '../src/std-game';
73
+ import { EXPORT_LABELS, EXPORT_TARGETS, isExportTarget, onStdExport, startStdExport, stdExport, stdTargets, type ExportTarget, type StdExport } from '../src/std-export';
74
+ import { subscribePlayLog, tailPlayLog } from '../src/play-log';
75
+ import { showGameView } from '../src/game-view';
77
76
 
78
77
  /** Said where the project is not a std game, whose Play has nothing to run. */
79
78
  const NOT_STD = 'This project is not a std game (no std.json): `cyclotron add-play` makes it one.';
80
- import { subscribeModelPlayLog, tailModelPlayLog } from '../src/play-log';
81
-
82
- const STILL: DocumentPlayClock = { time: 0, tick: 0, paused: false, speed: 1, running: false };
83
- const NO_BOT: DocumentPlayAutoplay = { on: false, available: false, by: null };
84
-
85
- function extension(): DocumentPlayExtension | null {
86
- return registeredModelPlay();
87
- }
88
79
 
89
80
  /** The bottom area's mode and the model document it serves: editor-blender's, through its API. */
90
81
  const playMode = () => editorBlender().playMode;
91
82
 
92
83
  // ---------------------------------------------------------------------------------------------
93
- // THE VERBS — `volter.model-play.<verb>`, the panel's controls behind command ids.
84
+ // THE VERBS — `volter.play.<verb>`, the panel's controls behind command ids.
94
85
  // ---------------------------------------------------------------------------------------------
95
86
 
96
- /** Which document a verb acts on: the one it names, else the model document on screen. */
97
- function verbDocument(args: Record<string, unknown> | undefined): string {
98
- const named = args?.['document'];
99
- if (typeof named === 'string' && named !== '') return named;
87
+ /** The model document a mode verb acts on: the one in front, whose bottom area is its Game or Animation. */
88
+ function modelInFront(): string {
100
89
  const served = playMode().servedModelDocument();
101
- if (served === null) throw new Error('No model document is open; open one, or name it with `document`.');
90
+ if (served === null) throw new Error('No model document is in front: Game | Animation is a model\'s bottom area.');
102
91
  return served;
103
92
  }
104
93
 
105
- /** How long a verb waits for a model document still opening on screen before it answers that it is still opening
106
- * (well inside the 30 s a workbench command is given; `cyclotron play` asks again). */
107
- const VERB_OPEN_WAIT_MS = 20_000;
108
- /** The sentence a verb answers while the model document on screen is still opening; `cyclotron play` reads it. */
109
- const STILL_OPENING = 'is still opening';
110
-
111
- /** The verbs that start a game, which a document plays only on screen: its stage draws the game. */
112
- const BRINGS_FORWARD = new Set(['play', 'restart']);
113
-
114
- /**
115
- * A NAMED DOCUMENT IS BROUGHT FORWARD BEFORE IT PLAYS: a game runs in its document's stage, which only the document on
116
- * screen draws, so a Play asked of one behind another would wait for a first frame that never comes. An open document
117
- * is activated and waited for; one not open is refused, by name.
118
- */
119
- async function broughtForward(documentId: string): Promise<void> {
120
- if (playMode().servedModelDocument() === documentId) return;
121
- if (!activateWorkspaceDocument(documentId))
122
- throw new Error(`${documentId} is not open, so Play cannot bring it forward to play it: open it (\`editor.open('${documentId.replace(/^document:/, '')}')\`), then ask again.`);
123
- const bound = Date.now() + VERB_OPEN_WAIT_MS;
124
- while (playMode().servedModelDocument() !== documentId) {
125
- if (Date.now() > bound)
126
- throw new Error(`${documentId} ${STILL_OPENING}: it was brought forward, and Blender is still reading or presenting its file. Ask again.`);
127
- await new Promise((done) => setTimeout(done, 100));
128
- }
129
- }
130
-
131
- /**
132
- * A VERB ACTS ON THE MODEL DOCUMENT ON SCREEN ONCE IT IS ON SCREEN: one still opening (Blender reading or presenting
133
- * its file) is waited for, rather than answered "No model document is open" (editor-blender's `noteModelOpening`); `cyclotron play` asks again while it says so.
134
- * A verb that starts a game on a named document brings that document forward first ({@link broughtForward}).
135
- */
136
- function waitingForTheDocument(verbs: readonly ViewVerb[]): ViewVerb[] {
137
- return verbs.map((verb) => ({
138
- ...verb,
139
- run: async (args) => {
140
- const named = args?.['document'];
141
- if (typeof named !== 'string' || named === '') {
142
- const found = await playMode().documentOnScreen(VERB_OPEN_WAIT_MS);
143
- if (found !== null && 'opening' in found)
144
- throw new Error(`${found.opening} ${STILL_OPENING}: Blender is reading or presenting its file, and the Game panel's controls act on it once it is on screen. Ask again.`);
145
- } else if (BRINGS_FORWARD.has(verb.id)) await broughtForward(named);
146
- return verb.run(args);
147
- },
148
- }));
149
- }
150
-
151
- function verbExtension(): DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> } {
152
- const found = extension();
153
- if (found === null)
154
- throw new Error('No Play tool is registered for model documents: @volter/play is not active.');
155
- if (found.transport === undefined)
156
- throw new Error('The registered Play tool has no pause, step, speed or restart.');
157
- return found as DocumentPlayExtension & { transport: NonNullable<DocumentPlayExtension['transport']> };
158
- }
159
-
160
94
  /** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
161
95
  * lands on the next drawn frame (a step runs there), so read `state` again to see it. */
162
- function gameState(documentId: string): unknown {
163
- const found = extension();
164
- const blend = playMode().modelDocumentSource(documentId);
96
+ function gameState(): unknown {
97
+ const model = playMode().servedModelDocument();
165
98
  return {
166
- document: documentId,
167
- mode: playMode().modelPlayMode(documentId),
168
- playing: found?.playing(documentId) ?? false,
169
- script: blend === undefined || found?.scriptPath === undefined ? null : {
170
- path: found.scriptPath(blend),
171
- exists: found.hasScript?.(blend) ?? null,
172
- },
173
- clock: found?.transport?.clock(documentId) ?? STILL,
174
- speeds: found?.transport?.speeds ?? [],
175
- autoplay: autoplayState(documentId),
99
+ // (a model in front has Game or Animation; any other document, the Game panel)
100
+ mode: model === null ? 'game' : playMode().modelPlayMode(model),
101
+ playing: gamePlaying(),
102
+ script: { path: STD_DEV, exists: isStdGame() ?? null },
103
+ clock: gameClock(),
104
+ speeds: GAME_SPEEDS,
105
+ autoplay: autoplayState(),
106
+ // (a std game's: how many players Play starts, and how many have joined the running game)
107
+ ...(isStdGame() === true ? { players: { asked: stdPlayersAsked(), joined: stdRun()?.players.length ?? 0 } } : {}),
176
108
  export: stdExport(),
177
109
  };
178
110
  }
179
111
 
180
- /** Who drives the player in view: the bot while autoplay is on, else the person. */
181
- function driverOf(playing: boolean, autoplay: DocumentPlayAutoplay): 'bot' | 'person' | null {
112
+ /** Who drives the player in view: the bot while std's play drives its page, else the person. */
113
+ function driverOf(playing: boolean, autoplay: GameAutoplay): 'bot' | 'person' | null {
182
114
  if (!playing) return null;
183
- return autoplay.on ? 'bot' : 'person';
115
+ return autoplay.driving === true ? 'bot' : 'person';
184
116
  }
185
117
 
186
118
  /** The bot's switch and who drives (`driverOf`), null when nothing plays; `why` says why the bot cannot be switched
187
119
  * on, null when it can. */
188
- function autoplayState(documentId: string): unknown {
189
- const found = extension();
190
- const autoplay = found?.transport?.autoplay?.(documentId) ?? NO_BOT;
191
- const playing = found?.playing(documentId) ?? false;
192
- const why = autoplayWhy(playing, found?.transport?.clock(documentId) ?? STILL, autoplay);
120
+ function autoplayState(): unknown {
121
+ const autoplay = gameAutoplay();
122
+ const playing = gamePlaying();
123
+ const why = autoplayWhy(playing, gameClock(), autoplay);
193
124
  return { ...autoplay, driver: driverOf(playing, autoplay), why };
194
125
  }
195
126
 
@@ -197,14 +128,14 @@ function autoplayState(documentId: string): unknown {
197
128
  * WHY THE BOT CANNOT BE SWITCHED ON: `not-running` while stopped or still starting (a player's page must have joined),
198
129
  * `game` once it runs and the run says why (`autoplay.unavailable`: no goals, no player in view), null when it can.
199
130
  */
200
- function autoplayUnavailable(playing: boolean, clock: Pick<DocumentPlayClock, 'failure' | 'running'>, autoplay: DocumentPlayAutoplay): 'not-running' | 'game' | null {
131
+ function autoplayUnavailable(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): 'not-running' | 'game' | null {
201
132
  if (playing && clock.running && autoplay.available) return null;
202
133
  if (!playing || !clock.running) return 'not-running';
203
134
  return 'game';
204
135
  }
205
136
 
206
137
  /** {@link autoplayUnavailable} as the CLI says it. */
207
- function autoplayWhy(playing: boolean, clock: Pick<DocumentPlayClock, 'failure' | 'running'>, autoplay: DocumentPlayAutoplay): string | null {
138
+ function autoplayWhy(playing: boolean, clock: Pick<GameClock, 'failure' | 'running'>, autoplay: GameAutoplay): string | null {
208
139
  switch (autoplayUnavailable(playing, clock, autoplay)) {
209
140
  case null: return null;
210
141
  case 'game': return autoplay.unavailable ?? 'No autoplay for the player in view.';
@@ -214,134 +145,124 @@ function autoplayWhy(playing: boolean, clock: Pick<DocumentPlayClock, 'failure'
214
145
  }
215
146
  }
216
147
 
217
- function requirePlaying(documentId: string, verb: string): void {
218
- if (!verbExtension().playing(documentId))
219
- throw new Error(`Nothing is playing in ${documentId}, so there is nothing to ${verb}; \`play\` starts it.`);
148
+ function requirePlaying(verb: string): void {
149
+ if (!gamePlaying()) throw new Error(`Nothing is playing, so there is nothing to ${verb}; \`play\` starts it.`);
220
150
  }
221
151
 
222
- /** The Game panel's verbs: the Code-OSS commands `volter.model-play.<verb>`, registered by play's page half. */
223
- export const MODEL_PLAY_VIEW_VERBS: readonly ViewVerb[] = waitingForTheDocument([
224
- { id: 'state', run: (args) => gameState(verbDocument(args)) },
152
+ /** The Game panel's verbs: the Code-OSS commands `volter.play.<verb>`, registered by play's page half. */
153
+ export const PLAY_VIEW_VERBS: readonly ViewVerb[] = [
154
+ { id: 'state', run: () => gameState() },
225
155
  {
226
156
  id: 'mode',
227
157
  run: (args) => {
228
- const documentId = verbDocument(args);
229
158
  const mode = args?.['mode'] ?? args?.['to'];
230
159
  if (mode !== undefined) {
231
160
  const asked = playMode().playModeOf(mode);
232
161
  if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
233
- editorBlender().switchPlayMode(documentId, asked);
162
+ editorBlender().switchPlayMode(modelInFront(), asked);
163
+ }
164
+ return gameState();
165
+ },
166
+ },
167
+ {
168
+ // HOW MANY PLAY: a std game's player count, the project's setting (`volter.play.players`); a running game's players
169
+ // join or leave to match it.
170
+ id: 'players',
171
+ run: async (args) => {
172
+ const raw = args?.['players'] ?? args?.['count'];
173
+ if (raw !== undefined) {
174
+ if (isStdGame() !== true) throw new Error('players is a std game\'s player count; this project is not a std game.');
175
+ const count = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
176
+ if (!Number.isInteger(count) || count < 1 || count > 8) throw new Error(`players is a whole number from 1 to 8; got ${JSON.stringify(raw)}.`);
177
+ // (answered once the setting is in force)
178
+ await setStdPlayers(count);
234
179
  }
235
- return gameState(documentId);
180
+ return gameState();
236
181
  },
237
182
  },
238
183
  {
239
184
  id: 'game-mode',
240
- run: (args) => { const documentId = verbDocument(args); editorBlender().switchPlayMode(documentId, 'game'); return gameState(documentId); },
185
+ run: () => { editorBlender().switchPlayMode(modelInFront(), 'game'); return gameState(); },
241
186
  },
242
187
  {
243
188
  id: 'animation-mode',
244
- run: (args) => { const documentId = verbDocument(args); editorBlender().switchPlayMode(documentId, 'animation'); return gameState(documentId); },
189
+ run: () => { editorBlender().switchPlayMode(modelInFront(), 'animation'); return gameState(); },
245
190
  },
246
191
  {
247
- // PLAY IS GAME MODE'S CONTROL, so asking for it puts the document in Game mode: the person
248
- // then sees the panel that owns the run an agent started.
192
+ // (the game played in the Game view, brought to the front where it already plays, as Play opens it)
249
193
  id: 'play',
250
- run: (args) => {
251
- const documentId = verbDocument(args);
252
- const found = verbExtension();
253
- if (playMode().modelPlayMode(documentId) !== 'game') editorBlender().switchPlayMode(documentId, 'game');
254
- if (!found.playing(documentId)) found.setPlaying(documentId, true);
255
- return gameState(documentId);
256
- },
257
- },
258
- {
259
- id: 'stop',
260
- run: (args) => {
261
- const documentId = verbDocument(args);
262
- verbExtension().setPlaying(documentId, false);
263
- return gameState(documentId);
194
+ run: () => {
195
+ if (!gamePlaying()) playGame(true);
196
+ else showGameView();
197
+ return gameState();
264
198
  },
265
199
  },
200
+ { id: 'stop', run: () => { playGame(false); return gameState(); } },
266
201
  {
267
202
  id: 'pause',
268
- run: async (args) => {
269
- const documentId = verbDocument(args);
270
- requirePlaying(documentId, 'pause');
271
- await verbExtension().transport.setPaused(documentId, true);
272
- return gameState(documentId);
203
+ run: async () => {
204
+ requirePlaying('pause');
205
+ await pauseGame(true);
206
+ return gameState();
273
207
  },
274
208
  },
275
209
  {
276
210
  id: 'resume',
277
- run: async (args) => {
278
- const documentId = verbDocument(args);
279
- requirePlaying(documentId, 'resume');
280
- await verbExtension().transport.setPaused(documentId, false);
281
- return gameState(documentId);
211
+ run: async () => {
212
+ requirePlaying('resume');
213
+ await pauseGame(false);
214
+ return gameState();
282
215
  },
283
216
  },
284
217
  {
285
218
  // `count` frames of 1/60 s, which std runs (pausing the game first) and answers.
286
219
  id: 'step',
287
220
  run: async (args) => {
288
- const documentId = verbDocument(args);
289
- requirePlaying(documentId, 'step');
290
- const { transport } = verbExtension();
221
+ requirePlaying('step');
291
222
  const raw = args?.['count'];
292
223
  const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
293
224
  if (!Number.isInteger(count) || count < 1 || count > 600) throw new Error(`step's \`count\` is a whole number of frames from 1 to 600; got ${JSON.stringify(raw)}.`);
294
- await transport.step(documentId, count);
295
- return gameState(documentId);
225
+ await stepGame(count);
226
+ return gameState();
296
227
  },
297
228
  },
298
229
  {
299
230
  id: 'speed',
300
231
  run: async (args) => {
301
- const documentId = verbDocument(args);
302
- const { transport } = verbExtension();
303
232
  const raw = args?.['speed'] ?? args?.['to'];
304
233
  const speed = typeof raw === 'string' ? Number(raw.replace(/x$/i, '')) : Number(raw);
305
- if (!transport.speeds.includes(speed))
306
- throw new Error(`speed is one of ${transport.speeds.join(', ')} (game seconds per real second); got ${JSON.stringify(raw)}.`);
307
- await transport.setSpeed(documentId, speed);
308
- return gameState(documentId);
234
+ if (!GAME_SPEEDS.includes(speed))
235
+ throw new Error(`speed is one of ${GAME_SPEEDS.join(', ')} (game seconds per real second); got ${JSON.stringify(raw)}.`);
236
+ await setGameSpeedOf(speed);
237
+ return gameState();
309
238
  },
310
239
  },
311
240
  {
312
- // THE EDITOR'S AUTOPLAY SWITCH: on plays one of the game's goals (`goal`, the game's own when absent), off hands the
313
- // player in view back to the person. Asked from here (an agent's `cli`), the goal is std's own headless play,
314
- // which plays whether or not the editor is seen: it answers once the play has started, and `play state`'s
315
- // `autoplay.state` and the play log say how it ends.
241
+ // THE EDITOR'S AUTOPLAY SWITCH: on plays one of the game's goals (`goal`, the game's own when absent), off ends
242
+ // that play, handing a page it drives back to the person. From here (an agent's `cli`) as from the panel, the goal is std's own play,
243
+ // recorded and checked, on the viewed player's page while the Game view is on screen and on std's headless device
244
+ // while it is not (`setStdAutoplay`): it answers once the play has started, and `play state`'s `autoplay.state`
245
+ // and the play log say how it ends.
316
246
  id: 'autoplay',
317
247
  run: async (args) => {
318
- const documentId = verbDocument(args);
319
248
  const raw = args?.['on'];
320
249
  const on = raw === true || raw === 'on' || raw === 'true' ? true
321
250
  : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
322
251
  if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on [<goal>]|off\`); got ${String(raw)}.`);
323
252
  const goal = typeof args?.['goal'] === 'string' && args['goal'] !== '' ? args['goal'] : undefined;
324
- const found = verbExtension();
325
- const { transport } = found;
326
- if (transport.setAutoplay === undefined)
327
- throw new Error('The registered Play tool has no autoplay.');
328
253
  // (refused while nothing plays; the run says its own reasons, a headless play needing no player's page)
329
- if (on && !found.playing(documentId)) throw new Error(autoplayWhy(false, transport.clock(documentId), NO_BOT) ?? 'Nothing is playing.');
330
- await transport.setAutoplay(documentId, on, 'cli', on ? goal : undefined);
331
- return gameState(documentId);
254
+ if (on && !gamePlaying()) throw new Error(autoplayWhy(false, gameClock(), gameAutoplay()) ?? 'Nothing is playing.');
255
+ await setGameAutoplay(on, 'cli', on ? goal : undefined);
256
+ return gameState();
332
257
  },
333
258
  },
334
259
  {
335
260
  id: 'restart',
336
- run: (args) => {
337
- const documentId = verbDocument(args);
338
- const found = verbExtension();
339
- if (playMode().modelPlayMode(documentId) !== 'game') editorBlender().switchPlayMode(documentId, 'game');
340
- found.transport.restart(documentId);
341
- return gameState(documentId);
261
+ run: () => {
262
+ restartPlay();
263
+ return gameState();
342
264
  },
343
265
  },
344
- ]).concat([
345
266
  {
346
267
  // THE GAME EXPORTED, as the panel's Export menu does it, the project's and so no document's: `target` web, roblox or
347
268
  // unreal; an engine the game doesn't target refused unless `add` (true) makes it one first, as the menu's "Add
@@ -363,7 +284,7 @@ export const MODEL_PLAY_VIEW_VERBS: readonly ViewVerb[] = waitingForTheDocument(
363
284
  return { export: stdExport(), targets: await stdTargets() };
364
285
  },
365
286
  },
366
- ]);
287
+ ];
367
288
 
368
289
  // ---------------------------------------------------------------------------------------------
369
290
  // THE PANEL
@@ -398,24 +319,21 @@ function clockText(seconds: number): string {
398
319
  /** The clock as std last answered it, redrawn at most every {@link CLOCK_TEXT_INTERVAL_MS} ms and always after the
399
320
  * last change (`../src/play-clock.ts`): the game's seconds and frames where it is paused, which a step moves exactly;
400
321
  * running, its speed, the time being the game's own between answers. */
401
- function PlayClockText({ documentId, transport }: { documentId: string | null; transport: DocumentPlayTransport | undefined }) {
322
+ function PlayClockText() {
402
323
  const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
403
- const subscribe = useMemo(
404
- () => throttledSubscription((listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}), CLOCK_TEXT_INTERVAL_MS),
405
- [transport],
406
- );
324
+ const subscribe = useMemo(() => throttledSubscription(subscribeGameClock, CLOCK_TEXT_INTERVAL_MS), []);
407
325
  const text = useSyncExternalStore(
408
326
  subscribe,
409
327
  () => {
410
- const clock = documentId !== null && transport ? transport.clock(documentId) : STILL;
411
- const playing = documentId !== null && (registeredModelPlay()?.playing(documentId) ?? false);
328
+ const clock = gameClock();
329
+ const playing = gamePlaying();
412
330
  return !playing ? `stopped · ${speedText(clock.speed)}` : clock.paused ? `${clockText(clock.time)} · frame ${clock.tick}` : `running · ${speedText(clock.speed)}`;
413
331
  },
414
332
  () => `${clockText(0)} · frame 0`,
415
333
  );
416
334
  return (
417
335
  <span
418
- data-testid="model-play-clock"
336
+ data-testid="play-clock"
419
337
  style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
420
338
  >
421
339
  {text}
@@ -429,58 +347,38 @@ function speedText(speed: number): string {
429
347
 
430
348
  /** A control's refusal, said in the console under its name. */
431
349
  const said = (control: string) => (error: unknown): void =>
432
- editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, 'model-play');
350
+ editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, 'play');
433
351
 
434
352
  export function GamePanel() {
435
353
  const { PlayAreaButton, PlayModeSwitch, timelineGeometry: { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
436
354
  const { subscribePlayMode, playModeVersion } = playMode();
437
355
  useSyncExternalStore(subscribePlayMode, playModeVersion, playModeVersion);
438
- const documentId = playMode().servedModelDocument();
439
- const found = useSyncExternalStore(subscribeRegisteredModelPlay, extension, () => null);
440
- const transport = found?.transport;
441
- const playing = documentId !== null && (found?.playing(documentId) ?? false);
442
- const subscribeClock = useCallback(
443
- (listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}),
444
- [transport],
445
- );
356
+ // (the project's run, whatever document is in front)
357
+ const playing = useSyncExternalStore(subscribeGame, gamePlaying, () => false);
446
358
  // THE CLOCK'S STATE, not its time (`../src/play-clock.ts`): the panel re-renders when a game is
447
359
  // paused, sped up, fails or starts running, never once a frame; its time is {@link PlayClockText}'s.
448
- const readClockState = useMemo(
449
- () => clockStateReader(() => (documentId !== null && transport ? transport.clock(documentId) : STILL)),
450
- [documentId, transport],
451
- );
452
- const clock = useSyncExternalStore(subscribeClock, readClockState, () => STILL);
360
+ const readClockState = useMemo(() => clockStateReader(gameClock), []);
361
+ const clock = useSyncExternalStore(subscribeGameClock, readClockState, readClockState);
453
362
  // Whether the project is a std game, which Play plays (undefined while the session is asked).
454
363
  const std = useSyncExternalStore(subscribeStdGame, isStdGame, () => undefined);
455
- // Its changes are announced with the clock's.
456
- const autoplay = useSyncExternalStore(
457
- subscribeClock,
458
- () => (documentId !== null && transport?.autoplay ? transport.autoplay(documentId) : NO_BOT),
459
- () => NO_BOT,
460
- );
461
- const blend = documentId === null ? undefined : playMode().modelDocumentSource(documentId);
462
- const scriptPath = blend === undefined ? null : (found?.scriptPath?.(blend) ?? null);
463
- const canPlay = documentId !== null && found !== null;
464
- // A PREFAB IS NOT A LEVEL: Play on one plays the game, its levels as its client draws them, the prefab saved (and so
465
- // exported) first, as Unity plays the open scene and a prefab is not one.
364
+ const autoplay = useSyncExternalStore(subscribeGameControls, gameAutoplay, gameAutoplay);
365
+ const scriptPath = STD_DEV;
466
366
  // THE GAME'S NEWEST ERROR, a player's page's (`std-game.ts`): said where the person looks, as the console says each.
467
367
  useSyncExternalStore(subscribeStdRun, stdRunVersion, stdRunVersion);
468
- const thrown = playing && documentId !== null ? stdRun(documentId)?.lastError ?? null : null;
469
- // (std's headless play of a goal, where it stands: `win: achieved`, `lose: failed — why`)
368
+ const asked = stdPlayersAsked();
369
+ const thrown = playing ? stdRun()?.lastError ?? null : null;
370
+ // (std's own play of a goal, where it stands: `win: achieved`, `lose: failed — why`)
470
371
  const headlessLine = playing && autoplay.state ? ` · ${autoplay.state}` : '';
471
- const prefab = blend !== undefined && blend.startsWith('prefabs/') ? ' · a prefab: Play plays the game\'s levels, this prefab saved first' : '';
472
- const status = documentId === null
473
- ? 'No model document is open.'
474
- : std === false
372
+ const status = std === false
475
373
  ? NOT_STD
476
374
  : playing && clock.failure
477
- // The run plays but no game runs (`DocumentPlayClock.failure`): say so, not "Playing".
375
+ // The run plays but no game runs (`GameClock.failure`): say so, not "Playing".
478
376
  ? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the game) to retry, or Stop.${headlessLine}`
479
377
  : playing && thrown !== null
480
378
  ? `Playing — the game threw: ${thrown}${headlessLine}`
481
379
  : playing
482
- ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath ?? ''}`.trim() + prefab + headlessLine
483
- : (scriptPath === null ? 'Stopped.' : `Stopped · Play runs ${scriptPath}`) + prefab;
380
+ ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath}` + headlessLine
381
+ : `Stopped · Play runs ${scriptPath}`;
484
382
  return (
485
383
  <div
486
384
  data-testid="game-panel"
@@ -516,77 +414,83 @@ export function GamePanel() {
516
414
  <div style={{ flex: 1 }} />
517
415
  <div style={{ display: 'flex' }} role="group" aria-label="Game transport">
518
416
  <PlayAreaButton
519
- testId="model-play-button"
520
- label={playing ? 'Stop playing (Escape)' : 'Play this level’s game'}
417
+ testId="play-button"
418
+ label={playing ? 'Stop the game' : 'Play the game'}
521
419
  pressed={playing}
522
420
  wide
523
- disabled={!canPlay}
524
- // The run's state NOW, not the render's: a game can stop between the two (Escape, an
525
- // agent's `stop`, a failed script), and a toggle read from a stale render does nothing.
526
- onClick={() => { if (documentId !== null && found) found.setPlaying(documentId, !found.playing(documentId)); }}
421
+ // The run's state NOW, not the render's: a game can stop between the two (an agent's `stop`, the Game view
422
+ // closed, a failed script), and a toggle read from a stale render does nothing.
423
+ onClick={() => { playGame(!gamePlaying()); }}
527
424
  >
528
425
  {mark(playing ? MARKS.stop : MARKS.play)}
529
426
  {playing ? 'Stop' : 'Play'}
530
427
  </PlayAreaButton>
531
- {transport && (
532
- <>
533
- <PlayAreaButton
534
- testId="model-play-pause"
535
- label={clock.paused ? 'Resume' : 'Pause'}
536
- pressed={playing && clock.paused}
537
- disabled={!playing}
538
- onClick={() => { if (documentId !== null) void Promise.resolve(transport.setPaused(documentId, !transport.clock(documentId).paused)).catch(said('Pause')); }}
539
- >
540
- {mark(clock.paused ? MARKS.play : MARKS.pause)}
541
- </PlayAreaButton>
542
- <PlayAreaButton
543
- testId="model-play-step"
544
- label="Step one frame (pauses first)"
545
- disabled={!playing}
546
- onClick={() => { if (documentId !== null) void Promise.resolve(transport.step(documentId)).catch(said('Step')); }}
547
- >
548
- {mark(MARKS.step)}
549
- </PlayAreaButton>
550
- <PlayAreaButton
551
- testId="model-play-restart"
552
- label="Restart: the level saved, every player's page loaded again"
553
- disabled={!playing}
554
- onClick={() => { if (documentId !== null) transport.restart(documentId); }}
555
- >
556
- {mark(MARKS.restart)}
557
- </PlayAreaButton>
558
- <PlayAreaButton
559
- testId="model-play-capture"
560
- label="Capture the viewed player's next frame (std's receiver writes it; the log names the file)"
561
- disabled={!playing}
562
- onClick={() => { void captureStdView().catch(said('Capture')); }}
563
- >
564
- {mark(MARKS.capture)}
565
- </PlayAreaButton>
566
- </>
567
- )}
428
+ <PlayAreaButton
429
+ testId="play-pause"
430
+ label={clock.paused ? 'Resume' : 'Pause'}
431
+ pressed={playing && clock.paused}
432
+ disabled={!playing}
433
+ onClick={() => { void pauseGame(!gameClock().paused).catch(said('Pause')); }}
434
+ >
435
+ {mark(clock.paused ? MARKS.play : MARKS.pause)}
436
+ </PlayAreaButton>
437
+ <PlayAreaButton
438
+ testId="play-step"
439
+ label="Step one frame (pauses first)"
440
+ disabled={!playing}
441
+ onClick={() => { void stepGame().catch(said('Step')); }}
442
+ >
443
+ {mark(MARKS.step)}
444
+ </PlayAreaButton>
445
+ <PlayAreaButton
446
+ testId="play-restart"
447
+ label="Restart: the level saved, every player's page loaded again"
448
+ disabled={!playing}
449
+ onClick={() => { restartPlay(); }}
450
+ >
451
+ {mark(MARKS.restart)}
452
+ </PlayAreaButton>
453
+ <PlayAreaButton
454
+ testId="play-capture"
455
+ label="Capture the viewed player's next frame (std's receiver writes it; the log names the file)"
456
+ disabled={!playing}
457
+ onClick={() => { void captureStdView().catch(said('Capture')); }}
458
+ >
459
+ {mark(MARKS.capture)}
460
+ </PlayAreaButton>
568
461
  </div>
569
- {transport && (
570
- <div style={{ display: 'flex' }} role="group" aria-label="Game speed">
571
- {transport.speeds.map((speed) => (
572
- <PlayAreaButton
573
- key={speed}
574
- testId={`model-play-speed-${speed}`}
575
- label={`Speed ${speedText(speed)}`}
576
- pressed={clock.speed === speed}
577
- wide
578
- disabled={documentId === null}
579
- onClick={() => { if (documentId !== null) void Promise.resolve(transport.setSpeed(documentId, speed)).catch(said('Speed')); }}
580
- >
581
- {speedText(speed)}
582
- </PlayAreaButton>
583
- ))}
462
+ {std === true && (
463
+ // HOW MANY PLAY, beside Play as an engine's Play settings are: set before a run, and a running game's
464
+ // players join or leave to match. A refused write is said on the console.
465
+ <div style={{ display: 'flex', alignItems: 'center' }} role="group" aria-label="Players">
466
+ <span style={{ padding: '0 4px' }}>Players</span>
467
+ <PlayAreaButton testId="std-players-remove" label="One player fewer (a running game's last player leaves)" disabled={asked <= 1} onClick={() => { stepStdPlayers(-1).catch(() => {}); }}>
468
+ −
469
+ </PlayAreaButton>
470
+ <span data-testid="std-players-count" style={{ minWidth: 14, textAlign: 'center' }}>{asked}</span>
471
+ <PlayAreaButton testId="std-players-add" label="One more player (a running game's new player joins)" disabled={asked >= MOST_PLAYERS} onClick={() => { stepStdPlayers(1).catch(() => {}); }}>
472
+ +
473
+ </PlayAreaButton>
584
474
  </div>
585
475
  )}
476
+ <div style={{ display: 'flex' }} role="group" aria-label="Game speed">
477
+ {GAME_SPEEDS.map((speed) => (
478
+ <PlayAreaButton
479
+ key={speed}
480
+ testId={`play-speed-${speed}`}
481
+ label={`Speed ${speedText(speed)}`}
482
+ pressed={clock.speed === speed}
483
+ wide
484
+ onClick={() => { void setGameSpeedOf(speed).catch(said('Speed')); }}
485
+ >
486
+ {speedText(speed)}
487
+ </PlayAreaButton>
488
+ ))}
489
+ </div>
586
490
  <div style={{ flex: 1 }} />
587
491
  {std === true && <ExportControl />}
588
492
  {/* THE CLOCK IS THE GAME'S, as std answered it: its seconds and frames, still while paused. */}
589
- <PlayClockText documentId={documentId} transport={transport} />
493
+ <PlayClockText />
590
494
  </div>
591
495
  <div
592
496
  style={{
@@ -600,15 +504,13 @@ export function GamePanel() {
600
504
  }}
601
505
  >
602
506
  <div style={{ flex: '0 1 260px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 }}>
603
- <span data-testid="model-play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
507
+ <span data-testid="play-status" title={status} style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
604
508
  {status}
605
509
  </span>
606
- {transport?.autoplay && documentId !== null && (
607
- <AutoplayControl documentId={documentId} playing={playing} clock={clock} autoplay={autoplay} transport={transport} />
608
- )}
510
+ <AutoplayControl playing={playing} clock={clock} autoplay={autoplay} />
609
511
  {std === true && <ExportNote />}
610
512
  </div>
611
- {documentId !== null && <PlayLogView documentId={documentId} />}
513
+ <PlayLogView />
612
514
  </div>
613
515
  </div>
614
516
  );
@@ -639,10 +541,9 @@ function ExportControl() {
639
541
  // (closed by a press outside it or Escape: its buttons keep the keyboard where it was, as the panel's do, so no blur
640
542
  // comes to close it)
641
543
  useEffect(() => {
642
- setExportMenuOpen(open);
643
544
  if (!open) return undefined;
644
545
  const outside = (event: PointerEvent): void => { if (!menu.current?.contains(event.target as Node)) setOpen(false); };
645
- // (Escape closes the menu alone: the panel's own Escape, which stops the game, never hears it)
546
+ // (Escape closes the menu alone)
646
547
  const escape = (event: KeyboardEvent): void => {
647
548
  if (event.key !== 'Escape') return;
648
549
  event.preventDefault();
@@ -652,7 +553,6 @@ function ExportControl() {
652
553
  window.addEventListener('pointerdown', outside, true);
653
554
  window.addEventListener('keydown', escape, true);
654
555
  return () => {
655
- setExportMenuOpen(false);
656
556
  window.removeEventListener('pointerdown', outside, true);
657
557
  window.removeEventListener('keydown', escape, true);
658
558
  };
@@ -665,7 +565,7 @@ function ExportControl() {
665
565
  return (
666
566
  <div ref={menu} style={{ position: 'relative', display: 'inline-flex' }}>
667
567
  <PlayAreaButton
668
- testId="model-play-export"
568
+ testId="play-export"
669
569
  label={running && current !== null ? `Exporting to ${EXPORT_LABELS[current.target]}` : 'Export the game: to the web, or to an engine'}
670
570
  pressed={open}
671
571
  wide
@@ -689,7 +589,7 @@ function ExportControl() {
689
589
  key={target}
690
590
  type="button"
691
591
  role="menuitem"
692
- data-testid={`model-play-export-${target}`}
592
+ data-testid={`play-export-${target}`}
693
593
  onMouseDown={(event) => event.preventDefault()}
694
594
  onClick={() => choose(target)}
695
595
  style={{ background: 'none', border: 'none', color: TIMELINE_CHROME.text, font: 'inherit', textAlign: 'left', padding: '4px 8px', cursor: 'pointer' }}
@@ -712,7 +612,7 @@ function ExportNote() {
712
612
  const text = exportText(current, label);
713
613
  return (
714
614
  <span
715
- data-testid="model-play-export-note"
615
+ data-testid="play-export-note"
716
616
  data-export={current.running ? 'running' : current.ok === true ? 'exported' : 'failed'}
717
617
  title={text}
718
618
  style={{ color: TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
@@ -732,25 +632,24 @@ function exportText(current: StdExport, label: string): string {
732
632
  // AUTOPLAY AND THE PLAY LOG
733
633
  // ---------------------------------------------------------------------------------------------
734
634
 
735
- function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
736
- readonly documentId: string;
635
+ function AutoplayControl({ playing, clock, autoplay }: {
737
636
  readonly playing: boolean;
738
637
  readonly clock: ClockState;
739
- readonly autoplay: DocumentPlayAutoplay;
740
- readonly transport: DocumentPlayTransport;
638
+ readonly autoplay: GameAutoplay;
741
639
  }) {
742
640
  const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
743
641
  const unavailable = autoplayUnavailable(playing, clock, autoplay);
744
642
  const armed = autoplay.armed === true;
745
643
  // STOPPED, the button ARMS: the next start switches the bot on once its player joins.
746
- const canArm = !playing && transport.armAutoplay !== undefined;
644
+ const canArm = !playing;
747
645
  const label = unavailable === 'game'
748
646
  ? autoplay.unavailable ?? 'No autoplay for the player in view'
749
647
  : unavailable === 'not-running'
750
648
  ? canArm
751
649
  ? armed ? 'Autoplay is armed for the next Play; press to disarm' : 'Let the game’s bot drive your player as soon as it joins'
752
650
  : 'Autoplay is available once a player has joined'
753
- : autoplay.on ? 'Stop autoplay and drive yourself' : 'Let the game’s bot drive your player (any key or click in its page takes over)';
651
+ : autoplay.on ? (autoplay.driving === true ? 'Stop autoplay and drive yourself' : 'Stop the bot’s play of its goal')
652
+ : 'Let the game’s bot drive your player (any key or click in its page takes over)';
754
653
  // SAID, NOT ONLY HOVERED: why there is nothing to switch.
755
654
  const note = unavailable === 'game'
756
655
  ? autoplay.unavailable ?? 'No autoplay for the player in view'
@@ -767,7 +666,7 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
767
666
  <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
768
667
  {goals.length > 1 && (
769
668
  <select
770
- data-testid="model-play-autoplay-goal"
669
+ data-testid="play-autoplay-goal"
771
670
  title="The goal Autoplay's bot plays"
772
671
  value={goal ?? ''}
773
672
  disabled={autoplay.on}
@@ -782,18 +681,18 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
782
681
  {/* KEEP THE KEYBOARD OFF THIS BUTTON (`PlayAreaButton`'s). Left focused, a person taking over with Space would
783
682
  also click this button and turn autoplay straight back on. */}
784
683
  <PlayAreaButton
785
- testId="model-play-autoplay"
684
+ testId="play-autoplay"
786
685
  label={label}
787
686
  pressed={autoplay.on || armed}
788
687
  wide
789
- disabled={canArm ? false : !playing || unavailable !== null || transport.setAutoplay === undefined}
688
+ disabled={canArm ? false : !playing || unavailable !== null}
790
689
  // The switch's state NOW, as the transport's buttons read theirs: a takeover can land between the render and
791
690
  // the click.
792
691
  onClick={() => {
793
- const now = transport.autoplay?.(documentId) ?? NO_BOT;
692
+ const now = gameAutoplay();
794
693
  try {
795
- if (!(extension()?.playing(documentId) ?? false)) transport.armAutoplay?.(documentId, !now.armed, goal ?? undefined);
796
- else void Promise.resolve(transport.setAutoplay?.(documentId, !now.on, 'panel', now.on ? undefined : goal ?? undefined)).catch(said('Autoplay'));
694
+ if (!gamePlaying()) armAutoplay(now.armed !== true, goal ?? undefined);
695
+ else void setGameAutoplay(!now.on, 'panel', now.on ? undefined : goal ?? undefined).catch(said('Autoplay'));
797
696
  } catch (error) { said('Autoplay')(error); }
798
697
  }}
799
698
  >
@@ -801,18 +700,18 @@ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
801
700
  </PlayAreaButton>
802
701
  </span>
803
702
  {note !== null ? (
804
- <span data-testid="model-play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
703
+ <span data-testid="play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
805
704
  {note}
806
705
  </span>
807
706
  ) : playing && (
808
707
  // WHO DRIVES THE PLAYER IN VIEW, and the goal the bot plays: "Bot driving · collect-all".
809
708
  <span
810
- data-testid="model-play-driver"
709
+ data-testid="play-driver"
811
710
  data-driver={driver ?? undefined}
812
711
  style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
813
712
  >
814
- {driver === 'bot' ? `Bot driving${autoplay.behavior ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
815
- {autoplay.state ? ` · headless ${autoplay.state}` : ''}
713
+ {driver === 'bot' ? `Bot driving${autoplay.behavior && !autoplay.state ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
714
+ {autoplay.state ? ` · ${autoplay.state}` : ''}
816
715
  </span>
817
716
  )}
818
717
  </div>
@@ -827,19 +726,19 @@ function factsText(facts: Record<string, unknown> | undefined): string {
827
726
  try { return JSON.stringify(facts); } catch { return ''; }
828
727
  }
829
728
 
830
- function PlayLogView({ documentId }: { readonly documentId: string }) {
729
+ function PlayLogView() {
831
730
  const { TIMELINE_CHROME, TIMELINE_THEME } = editorBlender().timelineGeometry;
832
731
  const [kind, setKind] = useState('');
833
732
  // Writes land several to a frame; the view redraws at most once per drawn frame.
834
733
  const [, redraw] = useReducer((count: number) => count + 1, 0);
835
734
  useEffect(() => {
836
735
  let frame = 0;
837
- const stop = subscribeModelPlayLog(() => {
736
+ const stop = subscribePlayLog(() => {
838
737
  if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
839
738
  });
840
739
  return () => { stop(); cancelAnimationFrame(frame); };
841
740
  }, []);
842
- const tail = tailModelPlayLog(documentId, LOG_LINES, kind === '' ? undefined : kind);
741
+ const tail = tailPlayLog(LOG_LINES, kind === '' ? undefined : kind);
843
742
  const list = useRef<HTMLDivElement>(null);
844
743
  // Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
845
744
  const following = useRef(true);
@@ -849,11 +748,11 @@ function PlayLogView({ documentId }: { readonly documentId: string }) {
849
748
  });
850
749
  const newest = tail.entries.at(-1)?.seq;
851
750
  return (
852
- <div data-testid="model-play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
751
+ <div data-testid="play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
853
752
  <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
854
753
  <span>Play log</span>
855
754
  <select
856
- data-testid="model-play-log-kind"
755
+ data-testid="play-log-kind"
857
756
  aria-label="Show entries of kind"
858
757
  value={kind}
859
758
  // Blurred once chosen, so the player's next arrow key moves the game, not this list.
@@ -875,7 +774,7 @@ function PlayLogView({ documentId }: { readonly documentId: string }) {
875
774
  </div>
876
775
  <div
877
776
  ref={list}
878
- data-testid="model-play-log-entries"
777
+ data-testid="play-log-entries"
879
778
  data-newest={newest}
880
779
  onScroll={(event) => {
881
780
  const element = event.currentTarget;