@volter/play 0.5.211 → 0.5.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,908 @@
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.
14
+ *
15
+ * ## Where it is
16
+ *
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.
20
+ *
21
+ * ## What it drives is the Play tool's own run
22
+ *
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`.
29
+ *
30
+ * ## Autoplay is the editor's switch, and the person always wins
31
+ *
32
+ * 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`).
34
+ * A person's own key or click in the player's page takes it back on the spot (std's page does
35
+ * that), so the driver line reads "You're driving". Stopped, pressing Autoplay arms it for the
36
+ * next start. Where it cannot be switched on, the panel SAYS why, and `play state` carries the
37
+ * same reason as `autoplay.why`.
38
+ *
39
+ * ## The play log, live
40
+ *
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
43
+ * newest entry until the person scrolls up to read, and follows again once they scroll back to
44
+ * the bottom.
45
+ *
46
+ * ## It is drawn as the Timeline is
47
+ *
48
+ * Same area, same header band, the same widget table (`blender-timeline-geometry.ts`), so the
49
+ * bottom of the screen changes its job and not its family. The pressed speed cell takes the
50
+ * playhead's blue, which is Blender's own selected-widget colour in the same theme.
51
+ */
52
+ 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
+ import {
60
+ type ReactNode,
61
+ useCallback,
62
+ useEffect,
63
+ useLayoutEffect,
64
+ useMemo,
65
+ useReducer,
66
+ useRef,
67
+ useState,
68
+ useSyncExternalStore,
69
+ } from 'react';
70
+ import { editorBlender, registeredModelPlay, subscribeRegisteredModelPlay } from '../src/editor-blender';
71
+ import { CLOCK_TEXT_INTERVAL_MS, type ClockState, clockStateReader, throttledSubscription } from '../src/play-clock';
72
+ import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
73
+ 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';
77
+
78
+ /** Said where the project is not a std game, whose Play has nothing to run. */
79
+ 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
+
89
+ /** The bottom area's mode and the model document it serves: editor-blender's, through its API. */
90
+ const playMode = () => editorBlender().playMode;
91
+
92
+ // ---------------------------------------------------------------------------------------------
93
+ // THE VERBS — `volter.model-play.<verb>`, the panel's controls behind command ids.
94
+ // ---------------------------------------------------------------------------------------------
95
+
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;
100
+ const served = playMode().servedModelDocument();
101
+ if (served === null) throw new Error('No model document is open; open one, or name it with `document`.');
102
+ return served;
103
+ }
104
+
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
+ /** THE ONE REPORT every verb answers with — what the panel draws, as data. A control's effect
161
+ * 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);
165
+ 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),
176
+ export: stdExport(),
177
+ };
178
+ }
179
+
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 {
182
+ if (!playing) return null;
183
+ return autoplay.on ? 'bot' : 'person';
184
+ }
185
+
186
+ /** The bot's switch and who drives (`driverOf`), null when nothing plays; `why` says why the bot cannot be switched
187
+ * 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);
193
+ return { ...autoplay, driver: driverOf(playing, autoplay), why };
194
+ }
195
+
196
+ /**
197
+ * WHY THE BOT CANNOT BE SWITCHED ON: `not-running` while stopped or still starting (a player's page must have joined),
198
+ * `game` once it runs and the run says why (`autoplay.unavailable`: no goals, no player in view), null when it can.
199
+ */
200
+ function autoplayUnavailable(playing: boolean, clock: Pick<DocumentPlayClock, 'failure' | 'running'>, autoplay: DocumentPlayAutoplay): 'not-running' | 'game' | null {
201
+ if (playing && clock.running && autoplay.available) return null;
202
+ if (!playing || !clock.running) return 'not-running';
203
+ return 'game';
204
+ }
205
+
206
+ /** {@link autoplayUnavailable} as the CLI says it. */
207
+ function autoplayWhy(playing: boolean, clock: Pick<DocumentPlayClock, 'failure' | 'running'>, autoplay: DocumentPlayAutoplay): string | null {
208
+ switch (autoplayUnavailable(playing, clock, autoplay)) {
209
+ case null: return null;
210
+ case 'game': return autoplay.unavailable ?? 'No autoplay for the player in view.';
211
+ case 'not-running': return !playing
212
+ ? 'Autoplay is available once the game is running, and nothing is playing; `play` starts it.'
213
+ : `Autoplay is available once the game is running, and it is not running yet${clock.failure ? `: ${clock.failure}` : '.'}`;
214
+ }
215
+ }
216
+
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.`);
220
+ }
221
+
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)) },
225
+ {
226
+ id: 'mode',
227
+ run: (args) => {
228
+ const documentId = verbDocument(args);
229
+ const mode = args?.['mode'] ?? args?.['to'];
230
+ if (mode !== undefined) {
231
+ const asked = playMode().playModeOf(mode);
232
+ if (asked === null) throw new Error(`mode is \`game\` or \`animation\`; got ${String(mode)}.`);
233
+ editorBlender().switchPlayMode(documentId, asked);
234
+ }
235
+ return gameState(documentId);
236
+ },
237
+ },
238
+ {
239
+ id: 'game-mode',
240
+ run: (args) => { const documentId = verbDocument(args); editorBlender().switchPlayMode(documentId, 'game'); return gameState(documentId); },
241
+ },
242
+ {
243
+ id: 'animation-mode',
244
+ run: (args) => { const documentId = verbDocument(args); editorBlender().switchPlayMode(documentId, 'animation'); return gameState(documentId); },
245
+ },
246
+ {
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.
249
+ 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);
264
+ },
265
+ },
266
+ {
267
+ 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);
273
+ },
274
+ },
275
+ {
276
+ 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);
282
+ },
283
+ },
284
+ {
285
+ // `count` frames of 1/60 s, which std runs (pausing the game first) and answers.
286
+ id: 'step',
287
+ run: async (args) => {
288
+ const documentId = verbDocument(args);
289
+ requirePlaying(documentId, 'step');
290
+ const { transport } = verbExtension();
291
+ const raw = args?.['count'];
292
+ const count = raw === undefined ? 1 : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
293
+ 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);
296
+ },
297
+ },
298
+ {
299
+ id: 'speed',
300
+ run: async (args) => {
301
+ const documentId = verbDocument(args);
302
+ const { transport } = verbExtension();
303
+ const raw = args?.['speed'] ?? args?.['to'];
304
+ 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);
309
+ },
310
+ },
311
+ {
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.
316
+ id: 'autoplay',
317
+ run: async (args) => {
318
+ const documentId = verbDocument(args);
319
+ const raw = args?.['on'];
320
+ const on = raw === true || raw === 'on' || raw === 'true' ? true
321
+ : raw === false || raw === 'off' || raw === 'false' ? false : undefined;
322
+ if (on === undefined) throw new Error(`autoplay's \`on\` is true or false (\`play autoplay on [<goal>]|off\`); got ${String(raw)}.`);
323
+ 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
+ // (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);
332
+ },
333
+ },
334
+ {
335
+ 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);
342
+ },
343
+ },
344
+ ]).concat([
345
+ {
346
+ // THE GAME EXPORTED, as the panel's Export menu does it, the project's and so no document's: `target` web, roblox or
347
+ // unreal; an engine the game doesn't target refused unless `add` (true) makes it one first, as the menu's "Add
348
+ // Roblox and export" does. It answers once the export has started with the export and the game's targets; the
349
+ // panel says how it stands and ends, and the console holds each line std printed. With no `target`, the export as it
350
+ // stands.
351
+ id: 'export',
352
+ run: async (args) => {
353
+ const target = args?.['target'];
354
+ const add = args?.['add'];
355
+ if (add !== undefined && typeof add !== 'boolean') throw new Error(`export's \`add\` is true or false; got ${JSON.stringify(add)}.`);
356
+ if (target !== undefined) {
357
+ if (!isExportTarget(target)) throw new Error(`export's \`target\` is one of ${EXPORT_TARGETS.join(', ')}; got ${JSON.stringify(target)}.`);
358
+ const targets = await stdTargets();
359
+ if (target !== 'web' && !targets.includes(target) && add !== true)
360
+ throw new Error(`${EXPORT_LABELS[target]} isn't a target of this game: export it with \`add: true\` to add it first (std target add ${target}), only where the person asked for ${EXPORT_LABELS[target]}.`);
361
+ startStdExport(target, add === true);
362
+ }
363
+ return { export: stdExport(), targets: await stdTargets() };
364
+ },
365
+ },
366
+ ]);
367
+
368
+ // ---------------------------------------------------------------------------------------------
369
+ // THE PANEL
370
+ // ---------------------------------------------------------------------------------------------
371
+
372
+ /** A 12-px mark in the widget's ink, the way the Timeline header draws its transport. */
373
+ function mark(path: string): ReactNode {
374
+ const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
375
+ return (
376
+ <svg width={12} height={12} viewBox="0 0 12 12" aria-hidden="true">
377
+ <path d={path} fill={TIMELINE_CHROME.widgetText} />
378
+ </svg>
379
+ );
380
+ }
381
+ const MARKS = {
382
+ play: 'M3 2l7 4-7 4z',
383
+ stop: 'M2.5 2.5h7v7h-7z',
384
+ pause: 'M3 2h2v8H3zM7 2h2v8H7z',
385
+ step: 'M10 2H8.5v8H10zM2.5 2v8L7.5 6z',
386
+ restart: 'M6 1.5V0l3 2.25-3 2.25V3a3 3 0 1 0 3 3h1.5A4.5 4.5 0 1 1 6 1.5z',
387
+ capture: 'M4 2l1-1h2l1 1h2.5v8h-9V2zM6 8.5a2.25 2.25 0 1 0 0-4.5 2.25 2.25 0 0 0 0 4.5z',
388
+ } as const;
389
+
390
+ /** Simulation seconds as a clock reads them: `m:ss.ss`, hours only when there are some. */
391
+ function clockText(seconds: number): string {
392
+ const whole = Math.floor(seconds / 60);
393
+ const rest = (seconds - whole * 60).toFixed(2).padStart(5, '0');
394
+ if (whole < 60) return `${whole}:${rest}`;
395
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}:${rest}`;
396
+ }
397
+
398
+ /** The clock as std last answered it, redrawn at most every {@link CLOCK_TEXT_INTERVAL_MS} ms and always after the
399
+ * last change (`../src/play-clock.ts`): the game's seconds and frames where it is paused, which a step moves exactly;
400
+ * running, its speed, the time being the game's own between answers. */
401
+ function PlayClockText({ documentId, transport }: { documentId: string | null; transport: DocumentPlayTransport | undefined }) {
402
+ const { TIMELINE_CHROME } = editorBlender().timelineGeometry;
403
+ const subscribe = useMemo(
404
+ () => throttledSubscription((listener: () => void) => transport?.subscribeClock(listener) ?? (() => {}), CLOCK_TEXT_INTERVAL_MS),
405
+ [transport],
406
+ );
407
+ const text = useSyncExternalStore(
408
+ subscribe,
409
+ () => {
410
+ const clock = documentId !== null && transport ? transport.clock(documentId) : STILL;
411
+ const playing = documentId !== null && (registeredModelPlay()?.playing(documentId) ?? false);
412
+ return !playing ? `stopped · ${speedText(clock.speed)}` : clock.paused ? `${clockText(clock.time)} · frame ${clock.tick}` : `running · ${speedText(clock.speed)}`;
413
+ },
414
+ () => `${clockText(0)} · frame 0`,
415
+ );
416
+ return (
417
+ <span
418
+ data-testid="model-play-clock"
419
+ style={{ color: TIMELINE_CHROME.widgetText, fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }}
420
+ >
421
+ {text}
422
+ </span>
423
+ );
424
+ }
425
+
426
+ function speedText(speed: number): string {
427
+ return `${speed}×`;
428
+ }
429
+
430
+ /** A control's refusal, said in the console under its name. */
431
+ const said = (control: string) => (error: unknown): void =>
432
+ editorHost().console.error(`${control}: ${error instanceof Error ? error.message : String(error)}`, 'model-play');
433
+
434
+ export function GamePanel() {
435
+ const { PlayAreaButton, PlayModeSwitch, timelineGeometry: { HEADER_HEIGHT, TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
436
+ const { subscribePlayMode, playModeVersion } = playMode();
437
+ 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
+ );
446
+ // THE CLOCK'S STATE, not its time (`../src/play-clock.ts`): the panel re-renders when a game is
447
+ // 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);
453
+ // Whether the project is a std game, which Play plays (undefined while the session is asked).
454
+ 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.
466
+ // THE GAME'S NEWEST ERROR, a player's page's (`std-game.ts`): said where the person looks, as the console says each.
467
+ 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`)
470
+ 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
475
+ ? NOT_STD
476
+ : playing && clock.failure
477
+ // The run plays but no game runs (`DocumentPlayClock.failure`): say so, not "Playing".
478
+ ? `Not running — ${clock.failure.replace(/\.$/, '')}. Restart (or save the game) to retry, or Stop.${headlessLine}`
479
+ : playing && thrown !== null
480
+ ? `Playing — the game threw: ${thrown}${headlessLine}`
481
+ : playing
482
+ ? `${clock.paused ? 'Paused' : 'Playing'} ${scriptPath ?? ''}`.trim() + prefab + headlessLine
483
+ : (scriptPath === null ? 'Stopped.' : `Stopped · Play runs ${scriptPath}`) + prefab;
484
+ return (
485
+ <div
486
+ data-testid="game-panel"
487
+ data-playing={playing || undefined}
488
+ data-paused={(playing && clock.paused) || undefined}
489
+ data-failed={(playing && Boolean(clock.failure)) || undefined}
490
+ style={{
491
+ // Filled from the host box, as the Timeline is and for the reason its own comment gives.
492
+ position: 'absolute',
493
+ inset: 0,
494
+ display: 'flex',
495
+ flexDirection: 'column',
496
+ overflow: 'hidden',
497
+ background: TIMELINE_THEME.back,
498
+ color: TIMELINE_CHROME.text,
499
+ font: '11px Inter, system-ui, sans-serif',
500
+ }}
501
+ >
502
+ <div
503
+ style={{
504
+ height: HEADER_HEIGHT,
505
+ flex: `0 0 ${HEADER_HEIGHT}px`,
506
+ display: 'flex',
507
+ alignItems: 'center',
508
+ gap: TIMELINE_CHROME.headerGap,
509
+ padding: TIMELINE_CHROME.headerPadding,
510
+ background: TIMELINE_THEME.header,
511
+ borderBottom: `1px solid ${TIMELINE_CHROME.rule}`,
512
+ }}
513
+ >
514
+ <PlayModeSwitch title="Game" />
515
+ {/* THE TRANSPORT IN THE MIDDLE, between two spacers, where the Timeline centres its own. */}
516
+ <div style={{ flex: 1 }} />
517
+ <div style={{ display: 'flex' }} role="group" aria-label="Game transport">
518
+ <PlayAreaButton
519
+ testId="model-play-button"
520
+ label={playing ? 'Stop playing (Escape)' : 'Play this level’s game'}
521
+ pressed={playing}
522
+ 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)); }}
527
+ >
528
+ {mark(playing ? MARKS.stop : MARKS.play)}
529
+ {playing ? 'Stop' : 'Play'}
530
+ </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
+ )}
568
+ </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
+ ))}
584
+ </div>
585
+ )}
586
+ <div style={{ flex: 1 }} />
587
+ {std === true && <ExportControl />}
588
+ {/* THE CLOCK IS THE GAME'S, as std answered it: its seconds and frames, still while paused. */}
589
+ <PlayClockText documentId={documentId} transport={transport} />
590
+ </div>
591
+ <div
592
+ style={{
593
+ flex: 1,
594
+ minHeight: 0,
595
+ display: 'flex',
596
+ alignItems: 'stretch',
597
+ gap: TIMELINE_CHROME.headerGap,
598
+ padding: TIMELINE_CHROME.statusPadding,
599
+ overflow: 'hidden',
600
+ }}
601
+ >
602
+ <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' }}>
604
+ {status}
605
+ </span>
606
+ {transport?.autoplay && documentId !== null && (
607
+ <AutoplayControl documentId={documentId} playing={playing} clock={clock} autoplay={autoplay} transport={transport} />
608
+ )}
609
+ {std === true && <ExportNote />}
610
+ </div>
611
+ {documentId !== null && <PlayLogView documentId={documentId} />}
612
+ </div>
613
+ </div>
614
+ );
615
+ }
616
+
617
+ // ---------------------------------------------------------------------------------------------
618
+ // EXPORT
619
+ // ---------------------------------------------------------------------------------------------
620
+
621
+ /**
622
+ * EXPORT, as an engine's editor builds for a platform when it is chosen: a button that opens its menu, the web (`std
623
+ * build`), or an engine, "Add Roblox and export" where it is not yet one of the game's targets (`std target add`), so a
624
+ * game is built for an engine only once someone asks for it. A menu, never a select: a stray key on a closed select
625
+ * changes its value, and a choice here installs packages. Its targets are read afresh as it opens and as an export
626
+ * ends; while they are unknown, an engine is named plainly and the session decides.
627
+ */
628
+ function ExportControl() {
629
+ const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
630
+ const current = useSyncExternalStore(onStdExport, stdExport, () => null);
631
+ const [targets, setTargets] = useState<readonly ExportTarget[] | null>(null);
632
+ const [open, setOpen] = useState(false);
633
+ const menu = useRef<HTMLDivElement>(null);
634
+ const look = useCallback(() => { void stdTargets().then(setTargets, () => setTargets(null)); }, []);
635
+ const running = current?.running === true;
636
+ // (read again as an export ends and as another project opens: a project's targets are its own)
637
+ const project = useSyncExternalStore(onProjectChange, () => getCurrentProject()?.rootPath ?? null, () => null);
638
+ useEffect(look, [look, running, project]);
639
+ // (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
+ // comes to close it)
641
+ useEffect(() => {
642
+ setExportMenuOpen(open);
643
+ if (!open) return undefined;
644
+ 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)
646
+ const escape = (event: KeyboardEvent): void => {
647
+ if (event.key !== 'Escape') return;
648
+ event.preventDefault();
649
+ event.stopImmediatePropagation();
650
+ setOpen(false);
651
+ };
652
+ window.addEventListener('pointerdown', outside, true);
653
+ window.addEventListener('keydown', escape, true);
654
+ return () => {
655
+ setExportMenuOpen(false);
656
+ window.removeEventListener('pointerdown', outside, true);
657
+ window.removeEventListener('keydown', escape, true);
658
+ };
659
+ }, [open]);
660
+ const adds = (target: ExportTarget): boolean => target !== 'web' && targets !== null && !targets.includes(target);
661
+ const choose = (target: ExportTarget): void => {
662
+ setOpen(false);
663
+ try { startStdExport(target, adds(target)); } catch (error) { said('Export')(error); }
664
+ };
665
+ return (
666
+ <div ref={menu} style={{ position: 'relative', display: 'inline-flex' }}>
667
+ <PlayAreaButton
668
+ testId="model-play-export"
669
+ label={running && current !== null ? `Exporting to ${EXPORT_LABELS[current.target]}` : 'Export the game: to the web, or to an engine'}
670
+ pressed={open}
671
+ wide
672
+ disabled={running}
673
+ onClick={() => { if (!open) look(); setOpen(!open); }}
674
+ >
675
+ {running ? 'Exporting…' : 'Export'}
676
+ </PlayAreaButton>
677
+ {open && (
678
+ <div
679
+ role="menu"
680
+ aria-label="Export to"
681
+ style={{
682
+ position: 'absolute', right: 0, top: '100%', marginTop: 4, zIndex: 1,
683
+ display: 'flex', flexDirection: 'column', minWidth: 170,
684
+ background: TIMELINE_THEME.header, border: `1px solid ${TIMELINE_CHROME.rule}`, borderRadius: TIMELINE_CHROME.widgetRadius,
685
+ }}
686
+ >
687
+ {EXPORT_TARGETS.map((target) => (
688
+ <button
689
+ key={target}
690
+ type="button"
691
+ role="menuitem"
692
+ data-testid={`model-play-export-${target}`}
693
+ onMouseDown={(event) => event.preventDefault()}
694
+ onClick={() => choose(target)}
695
+ style={{ background: 'none', border: 'none', color: TIMELINE_CHROME.text, font: 'inherit', textAlign: 'left', padding: '4px 8px', cursor: 'pointer' }}
696
+ >
697
+ {adds(target) ? `Add ${EXPORT_LABELS[target]} and export` : EXPORT_LABELS[target]}
698
+ </button>
699
+ ))}
700
+ </div>
701
+ )}
702
+ </div>
703
+ );
704
+ }
705
+
706
+ /** THE EXPORT, SAID where the person looks: running with std's last line, or how it ended and where the export is. */
707
+ function ExportNote() {
708
+ const { timelineGeometry: { TIMELINE_CHROME } } = editorBlender();
709
+ const current = useSyncExternalStore(onStdExport, stdExport, () => null);
710
+ if (current === null) return null;
711
+ const label = EXPORT_LABELS[current.target];
712
+ const text = exportText(current, label);
713
+ return (
714
+ <span
715
+ data-testid="model-play-export-note"
716
+ data-export={current.running ? 'running' : current.ok === true ? 'exported' : 'failed'}
717
+ title={text}
718
+ style={{ color: TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
719
+ >
720
+ {text}
721
+ </span>
722
+ );
723
+ }
724
+
725
+ function exportText(current: StdExport, label: string): string {
726
+ if (current.running) return `Exporting to ${label}${current.line === null ? '…' : ` · ${current.line}`}`;
727
+ if (current.ok === true) return `Exported to ${label}: ${current.out ?? ''}${current.added ? ` · ${label} added as a target` : ''}`;
728
+ return `Export to ${label} failed${current.line === null ? '' : `: ${current.line}`} (the console has every line)`;
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------------------------
732
+ // AUTOPLAY AND THE PLAY LOG
733
+ // ---------------------------------------------------------------------------------------------
734
+
735
+ function AutoplayControl({ documentId, playing, clock, autoplay, transport }: {
736
+ readonly documentId: string;
737
+ readonly playing: boolean;
738
+ readonly clock: ClockState;
739
+ readonly autoplay: DocumentPlayAutoplay;
740
+ readonly transport: DocumentPlayTransport;
741
+ }) {
742
+ const { PlayAreaButton, timelineGeometry: { TIMELINE_CHROME, TIMELINE_THEME } } = editorBlender();
743
+ const unavailable = autoplayUnavailable(playing, clock, autoplay);
744
+ const armed = autoplay.armed === true;
745
+ // STOPPED, the button ARMS: the next start switches the bot on once its player joins.
746
+ const canArm = !playing && transport.armAutoplay !== undefined;
747
+ const label = unavailable === 'game'
748
+ ? autoplay.unavailable ?? 'No autoplay for the player in view'
749
+ : unavailable === 'not-running'
750
+ ? canArm
751
+ ? 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
+ : '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)';
754
+ // SAID, NOT ONLY HOVERED: why there is nothing to switch.
755
+ const note = unavailable === 'game'
756
+ ? autoplay.unavailable ?? 'No autoplay for the player in view'
757
+ : unavailable === 'not-running'
758
+ ? armed ? 'Armed — turns on once your player joins' : 'Available once a player has joined'
759
+ : null;
760
+ // THE GOAL TO PLAY, when the game's bot offers several: chosen here, as `play autoplay on <goal>` names it. Shown
761
+ // with the one driving (or armed) selected, else the game's own.
762
+ const goals = autoplay.behaviors ?? [];
763
+ const [picked, setPicked] = useState<string | null>(null);
764
+ const goal = autoplay.on || armed ? autoplay.behavior ?? null : picked !== null && goals.includes(picked) ? picked : autoplay.behavior ?? goals[0] ?? null;
765
+ const driver = driverOf(playing, autoplay);
766
+ return (
767
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap, minWidth: 0 }}>
768
+ {goals.length > 1 && (
769
+ <select
770
+ data-testid="model-play-autoplay-goal"
771
+ title="The goal Autoplay's bot plays"
772
+ value={goal ?? ''}
773
+ disabled={autoplay.on}
774
+ onChange={(event) => { setPicked(event.target.value); event.target.blur(); }}
775
+ style={{ background: TIMELINE_CHROME.widget, color: TIMELINE_CHROME.widgetText, border: 'none', borderRadius: TIMELINE_CHROME.widgetRadius, font: 'inherit', minWidth: 0 }}
776
+ >
777
+ {goals.map((name) => <option key={name} value={name}>{name}</option>)}
778
+ </select>
779
+ )}
780
+ {/* The tooltip lives on a wrapper too: a disabled button is the one that most needs it. */}
781
+ <span title={label} style={{ display: 'inline-flex' }}>
782
+ {/* KEEP THE KEYBOARD OFF THIS BUTTON (`PlayAreaButton`'s). Left focused, a person taking over with Space would
783
+ also click this button and turn autoplay straight back on. */}
784
+ <PlayAreaButton
785
+ testId="model-play-autoplay"
786
+ label={label}
787
+ pressed={autoplay.on || armed}
788
+ wide
789
+ disabled={canArm ? false : !playing || unavailable !== null || transport.setAutoplay === undefined}
790
+ // The switch's state NOW, as the transport's buttons read theirs: a takeover can land between the render and
791
+ // the click.
792
+ onClick={() => {
793
+ const now = transport.autoplay?.(documentId) ?? NO_BOT;
794
+ 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'));
797
+ } catch (error) { said('Autoplay')(error); }
798
+ }}
799
+ >
800
+ Autoplay
801
+ </PlayAreaButton>
802
+ </span>
803
+ {note !== null ? (
804
+ <span data-testid="model-play-autoplay-note" data-reason={unavailable ?? undefined} style={{ color: TIMELINE_CHROME.widgetText, minWidth: 0 }}>
805
+ {note}
806
+ </span>
807
+ ) : playing && (
808
+ // WHO DRIVES THE PLAYER IN VIEW, and the goal the bot plays: "Bot driving · collect-all".
809
+ <span
810
+ data-testid="model-play-driver"
811
+ data-driver={driver ?? undefined}
812
+ style={{ color: autoplay.on ? TIMELINE_THEME.playhead : TIMELINE_CHROME.widgetText, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}
813
+ >
814
+ {driver === 'bot' ? `Bot driving${autoplay.behavior ? ` · ${autoplay.behavior}` : ''}` : 'You’re driving'}
815
+ {autoplay.state ? ` · headless ${autoplay.state}` : ''}
816
+ </span>
817
+ )}
818
+ </div>
819
+ );
820
+ }
821
+
822
+ /** Enough to read back what just happened; the whole log is `cyclotron play-log`. */
823
+ const LOG_LINES = 200;
824
+
825
+ function factsText(facts: Record<string, unknown> | undefined): string {
826
+ if (facts === undefined) return '';
827
+ try { return JSON.stringify(facts); } catch { return ''; }
828
+ }
829
+
830
+ function PlayLogView({ documentId }: { readonly documentId: string }) {
831
+ const { TIMELINE_CHROME, TIMELINE_THEME } = editorBlender().timelineGeometry;
832
+ const [kind, setKind] = useState('');
833
+ // Writes land several to a frame; the view redraws at most once per drawn frame.
834
+ const [, redraw] = useReducer((count: number) => count + 1, 0);
835
+ useEffect(() => {
836
+ let frame = 0;
837
+ const stop = subscribeModelPlayLog(() => {
838
+ if (frame === 0) frame = requestAnimationFrame(() => { frame = 0; redraw(); });
839
+ });
840
+ return () => { stop(); cancelAnimationFrame(frame); };
841
+ }, []);
842
+ const tail = tailModelPlayLog(documentId, LOG_LINES, kind === '' ? undefined : kind);
843
+ const list = useRef<HTMLDivElement>(null);
844
+ // Follows the newest entry until the person scrolls up; back at the bottom, it follows again.
845
+ const following = useRef(true);
846
+ useLayoutEffect(() => {
847
+ const element = list.current;
848
+ if (element && following.current) element.scrollTop = element.scrollHeight;
849
+ });
850
+ const newest = tail.entries.at(-1)?.seq;
851
+ return (
852
+ <div data-testid="model-play-log" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
853
+ <div style={{ display: 'flex', alignItems: 'center', gap: TIMELINE_CHROME.headerGap }}>
854
+ <span>Play log</span>
855
+ <select
856
+ data-testid="model-play-log-kind"
857
+ aria-label="Show entries of kind"
858
+ value={kind}
859
+ // Blurred once chosen, so the player's next arrow key moves the game, not this list.
860
+ onChange={(event) => { setKind(event.target.value); event.target.blur(); }}
861
+ style={{
862
+ height: TIMELINE_CHROME.unit,
863
+ background: TIMELINE_CHROME.widget,
864
+ color: TIMELINE_CHROME.widgetText,
865
+ border: `1px solid ${TIMELINE_CHROME.widgetOutline}`,
866
+ font: 'inherit',
867
+ }}
868
+ >
869
+ <option value="">All kinds</option>
870
+ {[...new Set([...tail.kinds, ...(kind === '' ? [] : [kind])])].map((one) => <option key={one} value={one}>{one}</option>)}
871
+ </select>
872
+ <span style={{ color: TIMELINE_CHROME.widgetText }}>
873
+ {tail.total === 0 ? 'Nothing logged yet' : `${tail.total} written${tail.total > LOG_LINES ? ` · newest ${LOG_LINES} shown` : ''}`}
874
+ </span>
875
+ </div>
876
+ <div
877
+ ref={list}
878
+ data-testid="model-play-log-entries"
879
+ data-newest={newest}
880
+ onScroll={(event) => {
881
+ const element = event.currentTarget;
882
+ following.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 4;
883
+ }}
884
+ style={{
885
+ flex: 1,
886
+ minHeight: 0,
887
+ overflowY: 'auto',
888
+ font: '12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
889
+ background: TIMELINE_THEME.header,
890
+ border: `1px solid ${TIMELINE_CHROME.rule}`,
891
+ padding: '2px 6px',
892
+ }}
893
+ >
894
+ {tail.entries.map((entry) => (
895
+ <div
896
+ key={entry.seq}
897
+ data-kind={entry.kind}
898
+ style={{ display: 'flex', gap: 10, whiteSpace: 'nowrap', color: entry.source === 'play' ? TIMELINE_CHROME.widgetText : TIMELINE_CHROME.text }}
899
+ >
900
+ <span style={{ flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' }}>{clockText(entry.simT)}</span>
901
+ <span style={{ flex: '0 0 auto' }}>{entry.kind}</span>
902
+ <span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }} title={factsText(entry.facts)}>{factsText(entry.facts)}</span>
903
+ </div>
904
+ ))}
905
+ </div>
906
+ </div>
907
+ );
908
+ }