@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,841 @@
1
+ /**
2
+ * A STD GAME, PLAYED IN THE MODEL DOCUMENT'S STAGE (volter-ai/std, design/circle-1.md, "Devices"): a project with
3
+ * `std.json` that depends on `@volter-ai/std-dev` plays its own dev page, each player a page of its own, since std's
4
+ * client is one a page. Play starts the game's dev server where none runs (`../serving/std-dev.ts`) and stands each
5
+ * player's page in the stage's container as an iframe: each is one device of the dev server (std's page says its kind,
6
+ * a person's `tab`), one player, a real client joining the game's room. Every word said to a page is std's own control
7
+ * (`std-controls.ts`).
8
+ *
9
+ * THE FRAMES. Each is `credentialless`: the workbench is cross-origin isolated (COEP `credentialless`) and the dev
10
+ * server sends no COEP, so a plain frame of it would be refused. Each loads the dev server at the workbench page's own
11
+ * hostname (`editor-<key>.localhost`), so it is the same site as the page and std's receiver takes its trace (its
12
+ * cookie is `SameSite=Strict`); Vite allows `*.localhost` by default. Every frame stays full size and drawn while
13
+ * another is viewed (only its opacity changes), so a browser does not throttle the frames out of view.
14
+ *
15
+ * WHICH PAGE IS WHICH PLAYER. Each frame's address names a slot of this run's own (`?slot=<run>-<n>`; std's bot reads
16
+ * the number at its end), which the page's trace says in its first line. A device is a player's when its page's trace
17
+ * names the player's slot (`/pages`), read again as the run goes, so a page that loads again (a new page, a new
18
+ * device) is found again, and a page of another editor's is never taken for one of these.
19
+ *
20
+ * THE VIEWS (the view bar, `std-view-bar.tsx`): Edit (the document's own area, live for editing, while the game runs
21
+ * on: `DocumentPlayStage.edit`), Player N, and Grid (Edit and every player tiled; a tile clicked opens it). Keys 1 to 4
22
+ * open Player N, Tab the next view and Escape Edit, while the editor holds the keyboard: a viewed player's page holds
23
+ * it, and its keys are the game's; a press on the view bar gives it back to the editor. A view changes at once.
24
+ *
25
+ * WHO DRIVES. The viewed player is the person's ("you"): its page is told `manual`, and a press in it is the person's
26
+ * own, which std's page hears itself. Every other player is the game's bot ("autopilot"): its page is told `autoplay`
27
+ * with its slot and the run's goal: one of the game's goals (its `autopilot/` program's, which the session lists at
28
+ * each run's start, `/goals`), the one its `play` script names where that is among them, else the first. Autoplay
29
+ * turns the viewed player's bot on with one of those goals and off; the person's own press in its page takes it back.
30
+ *
31
+ * THE CLOCK. Pause, step and speed are std's `clock` control, said to every joined player's page, and to each page as
32
+ * it joins; the viewed player's answer is the run's clock (`model-play.ts`). It holds the pages: a room the game hosts
33
+ * runs on, as Colyseus has no pause for a room.
34
+ *
35
+ * THE AGENT'S DOORS. Each player's frame registers its driver (`@volter/sdk/framed-document-driver`): while it is the
36
+ * view, `editor.document.key` presses keys in its page through std's `act`, and `editor.document.query` (its screen's
37
+ * labelled nodes) and `editor.inspect` (its scene's objects and its screen) read its page through std's `inspect`.
38
+ *
39
+ * WHAT IS SAID. Each page's trace is the play log, as the dev server's receiver writes it; Capture is the viewed page's
40
+ * next frame, written by std's receiver and named in the log. A player whose page never joins is taken out and the
41
+ * failure stands: a run none of whose players joined is not running.
42
+ */
43
+ import { createElement } from 'react';
44
+ import { createRoot, type Root } from 'react-dom/client';
45
+ import type { DocumentPlayEditArea, DocumentPlayStage } from '@volter/editor-blender/api';
46
+ import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
47
+ import { editorHost } from '@volter/sdk/host';
48
+ import { registerFramedDriver } from '@volter/sdk/framed-document-driver';
49
+ import { beginModelPlayLog, type ModelPlayRun } from './play-log';
50
+ import { modelPlayClock, setModelPlayClock, setModelPlayFailure, setModelPlaySpeed, takeModelPlayArm } from './model-play';
51
+ import { exportMenuOpen } from './std-export';
52
+ import { askPlay, stdAct, stdCapture, stdCaptured, stdClock, stdDrive, stdInspect, type StdClock, type StdClockAction } from './std-controls';
53
+ import { StdGridTiles, StdViewBar } from './std-view-bar';
54
+
55
+ /** What a std game's Play runs: the game's own dev server (`../serving/std-dev.ts`), its page each player's. */
56
+ export const STD_DEV = 'npm run dev';
57
+ /** How long a page has to join once its frame is made: a first page waits on Vite's optimizer. */
58
+ const JOIN_MS = 60_000;
59
+ const LOOK_MS = 500;
60
+ /** Steps a second of a std trace's clock (std's trace format 3, `s`). */
61
+ const TRACE_STEPS_PER_SECOND = 60;
62
+ /** The players a run starts with: the person's and one bot, the least a multiplayer game is tried with. */
63
+ const FIRST_PLAYERS = 2;
64
+ const MOST_PLAYERS = 8;
65
+
66
+ export type StdView = 'edit' | 'grid' | number;
67
+
68
+ export interface StdPlayer {
69
+ /** Its number in the run (Player 1, 2, …). */
70
+ readonly number: number;
71
+ /** Its slot, `?slot=` and the bot's: the run's own, ending in its number. */
72
+ readonly slot: string;
73
+ readonly frame: HTMLIFrameElement;
74
+ /** Its page (std's page id) and that page's device, once its page has joined. */
75
+ page: string | null;
76
+ device: string | null;
77
+ autopilot: boolean;
78
+ /** The goal its bot plays while it drives: one Autoplay named, or the run's. */
79
+ goal: string | null;
80
+ /** How far its page's trace has been read. */
81
+ traced: number;
82
+ /** Withdraws its frame's driver (`@volter/sdk/framed-document-driver`). */
83
+ readonly undrive: () => void;
84
+ }
85
+
86
+ export interface StdRun {
87
+ readonly documentId: string;
88
+ readonly players: readonly StdPlayer[];
89
+ readonly view: StdView;
90
+ /** The goal the game's bots play unless Autoplay names another (its `play` script's `--goal`, else its first goal);
91
+ * null where it has none. */
92
+ readonly goal: string | null;
93
+ /** The game's goals, its `autopilot/` program's, as the session read them at the run's start. */
94
+ readonly goals: readonly string[];
95
+ /** The dev server's address, once it answers. */
96
+ readonly url: string | null;
97
+ /** The newest error a player's page threw (its trace's `error` line), with its player: the Game panel says it. */
98
+ readonly lastError: string | null;
99
+ /** std's headless play of a goal (`playGoalHeadless`): its goal and where it stands, `playing` until std says how it
100
+ * ended (`achieved`, or `failed` with why); null before any. */
101
+ readonly headless: { readonly goal: string; readonly state: 'playing' | 'achieved' | 'failed'; readonly why: string | null } | null;
102
+ }
103
+
104
+ /** The game's goals as the last run read them, which Autoplay offers while stopped (an arm names one). */
105
+ let knownGoals: readonly string[] = [];
106
+ /** Whether the open project is a std game, as the session answered (undefined while first asked), and when. */
107
+ let game: { readonly root: string; readonly answer: boolean | undefined; readonly goal: string | null; readonly at: number } | null = null;
108
+ /** How long a "not a std game" stands before it is asked again: `cyclotron add-play` makes a project one while its
109
+ * editor is open, which needs no reload. */
110
+ const NOT_STD_MS = 2000;
111
+ const listeners = new Set<() => void>();
112
+ const publish = (): void => { for (const listener of [...listeners]) listener(); };
113
+ export function subscribeStdGame(listener: () => void): () => void {
114
+ listeners.add(listener);
115
+ return () => { listeners.delete(listener); };
116
+ }
117
+ onProjectChange(() => { game = null; knownGoals = []; publish(); });
118
+
119
+ /** Whether the open project is a std game; undefined while the session is first asked (each answer is published). A
120
+ * "no" is asked again once it is {@link NOT_STD_MS} old, and stands meanwhile. */
121
+ export function isStdGame(): boolean | undefined {
122
+ const root = getCurrentProject()?.rootPath;
123
+ if (root === undefined) return false;
124
+ if (game?.root === root && (game.answer !== false || Date.now() - game.at < NOT_STD_MS)) return game.answer;
125
+ void askStdGame(root);
126
+ return game?.root === root ? game.answer : undefined;
127
+ }
128
+
129
+ /** The session asked whether the project in `root` is a std game, the answer kept and published when it changed. */
130
+ function askStdGame(root: string): Promise<boolean> {
131
+ const before = game?.root === root ? game : null;
132
+ game = { root, answer: before?.answer, goal: before?.goal ?? null, at: Date.now() };
133
+ return askPlay<{ game: boolean; goal: string | null }>('').then((said) => said, () => ({ game: false, goal: null })).then((said) => {
134
+ if (game?.root !== root) return said.game;
135
+ game = { root, answer: said.game, goal: said.goal, at: Date.now() };
136
+ if (before?.answer !== said.game || before.goal !== said.goal) publish();
137
+ return said.game;
138
+ });
139
+ }
140
+
141
+ /** Whether the open project is a std game, asked of the session now: Play's start asks so. */
142
+ export function stdGameNow(): Promise<boolean> {
143
+ const root = getCurrentProject()?.rootPath;
144
+ return root === undefined ? Promise.resolve(false) : askStdGame(root);
145
+ }
146
+
147
+ /** A run as this module keeps it. */
148
+ type LiveRun = StdRun & { players: StdPlayer[]; view: StdView; url: string | null; goal: string | null; goals: readonly string[];
149
+ lastError: string | null; readonly errorsSaid: Set<string>; headless: StdRun['headless'];
150
+ /** How far each headless bot page's trace has been read, by its page (std's play's slots, `server-<n>`, `p2p-<n>`). */
151
+ readonly headlessTraced: Map<string, { readonly slot: string; traced: number }>;
152
+ /** How far the dev server's error output has been read (`/errors`): its server's process and the next line, from
153
+ * the run's mark on (where the output stood as the run began); null before the mark. */
154
+ serverErrors: { server: number | null; next: number } | null;
155
+ readonly tag: string; readonly host: HTMLElement; readonly stage: DocumentPlayStage; readonly log: ModelPlayRun; adding: Promise<void>;
156
+ failure: string | null; stopped: boolean };
157
+ let run: LiveRun | null = null;
158
+ /** The players each document is asked to play with (Players [-][+]), kept across its Plays and Restarts whatever
159
+ * joined. */
160
+ const playerCounts = new Map<string, number>();
161
+ const runListeners = new Set<() => void>();
162
+ let runVersion = 0;
163
+ const publishRun = (): void => { runVersion += 1; for (const listener of [...runListeners]) listener(); };
164
+ export function subscribeStdRun(listener: () => void): () => void {
165
+ runListeners.add(listener);
166
+ return () => { runListeners.delete(listener); };
167
+ }
168
+ export const stdRunVersion = (): number => runVersion;
169
+ /** The std game's run of a document, or null while none runs. */
170
+ export function stdRun(documentId?: string): StdRun | null {
171
+ return run && !run.stopped && (documentId === undefined || run.documentId === documentId) ? run : null;
172
+ }
173
+
174
+ /** The goal a player's bot plays: the one Autoplay named for it, or the run's. */
175
+ const goalOf = (current: LiveRun, player: StdPlayer): string | null => player.goal ?? current.goal;
176
+
177
+ /** Says to a player's page who drives it: its bot (`autoplay`, its slot and its goal) or the person (`manual`). */
178
+ function tellDriver(current: LiveRun, player: StdPlayer): void {
179
+ const goal = goalOf(current, player);
180
+ if (player.device === null || (player.autopilot && goal === null)) return;
181
+ void stdDrive(player.device, player.autopilot && goal !== null ? { slot: player.slot, goal } : null)
182
+ .catch((error: unknown) => current.log.append('play', 'script-error', { phase: 'autoplay', slot: player.slot, message: String(error) }));
183
+ }
184
+
185
+ /** The bot on or off for one player, said to its page once it has joined, the change in the log. A goal named switches
186
+ * a driving bot to it. */
187
+ function drive(player: StdPlayer, autopilot: boolean, by: 'view' | 'panel' | 'cli' | 'takeover', goal?: string): void {
188
+ const current = run;
189
+ if (!current) return;
190
+ const changed = goal !== undefined && goal !== player.goal;
191
+ if (goal !== undefined) player.goal = goal;
192
+ if ((autopilot && goalOf(current, player) === null) || (player.autopilot === autopilot && !(autopilot && changed))) return;
193
+ player.autopilot = autopilot;
194
+ current.log.append('play', autopilot ? 'autoplay-on' : 'autoplay-off', { slot: player.slot, by, ...(autopilot ? { goal: goalOf(current, player) } : {}) });
195
+ tellDriver(current, player);
196
+ publishRun();
197
+ }
198
+
199
+ /** Who drives each player for the view: the viewed one the person, every other the bot. */
200
+ function driveForView(): void {
201
+ if (!run) return;
202
+ run.players.forEach((player, index) => drive(player, index !== run!.view, 'view'));
203
+ }
204
+
205
+ /**
206
+ * The grid's tile `tile` of `count` (Edit first, then each player), in fractions of the stage. A tile is its view
207
+ * whole, at the stage's own proportions scaled down alike (by `width`, which `height` equals), as Unity's Multiplayer
208
+ * Play Mode shows each player's whole window: centred in its cell, with a short last row centred, so no cell stands
209
+ * empty.
210
+ */
211
+ export function stdTile(tile: number, count: number): Exclude<DocumentPlayEditArea, 'live' | 'hidden'> {
212
+ const columns = Math.ceil(Math.sqrt(count)), rows = Math.ceil(count / columns);
213
+ const scale = 1 / columns;
214
+ const row = Math.floor(tile / columns);
215
+ const inRow = row === rows - 1 ? count - row * columns : columns;
216
+ const left = (tile % columns + (columns - inRow) / 2) / columns;
217
+ return { left, top: row / rows + (1 / rows - scale) / 2, width: scale, height: scale };
218
+ }
219
+
220
+ /** Each frame placed for the view (full size, or tiled), and the document's own area: live, its tile, or hidden. */
221
+ function place(): void {
222
+ if (!run) return;
223
+ const { players, view } = run;
224
+ players.forEach((player, index) => {
225
+ const box = view === 'grid' ? stdTile(index + 1, players.length + 1) : { left: 0, top: 0, width: 1, height: 1 };
226
+ Object.assign(player.frame.style, {
227
+ // (the page lays out at the stage's size in every view, and a tile draws it smaller: its HUD keeps its size
228
+ // against the game, as in the player's own view)
229
+ left: `${box.left * 100}%`, top: `${box.top * 100}%`, width: '100%', height: '100%',
230
+ transform: box.width === 1 ? '' : `scale(${box.width})`, transformOrigin: '0 0',
231
+ opacity: view === 'grid' || view === index ? '1' : '0',
232
+ // (a tile is a button: the frame takes no press until it is the view)
233
+ pointerEvents: view === index ? 'auto' : 'none',
234
+ zIndex: view === index ? '1' : '0',
235
+ });
236
+ });
237
+ run.stage.edit?.(view === 'edit' ? 'live' : view === 'grid' ? stdTile(0, players.length + 1) : 'hidden');
238
+ }
239
+
240
+ /** Opens a view: the frames placed, its player the person's and the rest the bot's, the keyboard in its page. */
241
+ export function setStdView(view: StdView): void {
242
+ if (!run || (typeof view === 'number' && !run.players[view])) return;
243
+ run.view = view;
244
+ run.log.append('play', 'view', { view: typeof view === 'number' ? `player ${run.players[view]!.number}` : view });
245
+ place();
246
+ driveForView();
247
+ if (typeof view === 'number') run.players[view]!.frame.focus();
248
+ publishRun();
249
+ }
250
+
251
+ /** The views in their Tab order: Edit, each player, Grid. */
252
+ function stdViews(): StdView[] {
253
+ return run ? ['edit', ...run.players.map((_, index) => index), 'grid'] : [];
254
+ }
255
+
256
+ /** Why the viewed player's bot cannot be switched on (or off) now, null when it can: a goal of the game's (switching
257
+ * on), a player viewed and its page joined. */
258
+ function autoplayUnavailable(player: StdPlayer | undefined, on = true, goal?: string): string | null {
259
+ if (on && run && run.goals.length === 0 && run.goal === null) return 'No autoplay: the game\'s autopilot/ program offers no goal for its bot.';
260
+ if (on && run && goal !== undefined && run.goals.length > 0 && !run.goals.includes(goal))
261
+ return `This game's bot has no goal "${goal}"; its goals are ${run.goals.map((one) => `"${one}"`).join(', ')}.`;
262
+ if (!player) return 'Autoplay drives the viewed player: open one (1 to 4); the others\' autopilots drive on their own.';
263
+ return player.device === null ? `Autoplay drives Player ${player.number} once its page has joined.` : null;
264
+ }
265
+
266
+ /** Whether the viewed player's bot drives (Autoplay's state), the goal it plays, the game's goals, and why it cannot
267
+ * be switched on, null when it can. */
268
+ export function stdAutoplay(): { readonly on: boolean; readonly goal: string | null; readonly goals: readonly string[]; readonly unavailable: string | null;
269
+ readonly headless: StdRun['headless'] } {
270
+ const player = run && typeof run.view === 'number' ? run.players[run.view] : undefined;
271
+ return {
272
+ on: player?.autopilot ?? false,
273
+ goal: run && player ? goalOf(run, player) : run?.goal ?? null,
274
+ goals: run?.goals ?? knownGoals,
275
+ unavailable: autoplayUnavailable(player),
276
+ headless: run?.headless ?? null,
277
+ };
278
+ }
279
+
280
+ /** The game's goals read again (its `autopilot/` program is the author's to change), the run's goal kept among them. */
281
+ async function refreshGoals(current: LiveRun): Promise<void> {
282
+ const { goals } = await askPlay<{ goals: string[] }>('/goals');
283
+ current.goals = knownGoals = goals;
284
+ if (current.goal === null || (goals.length > 0 && !goals.includes(current.goal))) current.goal = goals[0] ?? null;
285
+ publishRun();
286
+ }
287
+
288
+ /**
289
+ * AUTOPLAY ON OR OFF, on with `goal` where one is named, the goals read again first; refused with the reason where it
290
+ * cannot be. Asked by an agent (`cli`), or while the editor is not seen (a hidden page draws no frames, so its players'
291
+ * pages run nothing), it is std's own play of the goal on its headless device (`playGoalHeadless`), whose pages play
292
+ * whether or not the editor is seen; from the panel the person sees, it is the viewed player's bot.
293
+ */
294
+ export async function setStdAutoplay(on: boolean, by: 'panel' | 'cli', goal?: string): Promise<void> {
295
+ const current = run;
296
+ if (on && current) await refreshGoals(current);
297
+ if (on && current && (by === 'cli' || document.visibilityState === 'hidden')) {
298
+ const chosen = goal ?? current.goal;
299
+ if (chosen === null) throw new Error('No autoplay: the game\'s autopilot/ program offers no goal for its bot.');
300
+ if (current.goals.length > 0 && !current.goals.includes(chosen))
301
+ throw new Error(`This game's bot has no goal "${chosen}"; its goals are ${current.goals.map((one) => `"${one}"`).join(', ')}.`);
302
+ playGoalHeadless(current, chosen, by);
303
+ return;
304
+ }
305
+ const player = run && typeof run.view === 'number' ? run.players[run.view] : undefined;
306
+ // (off from here ends the headless play on from here started, and the viewed player's bot where it drives)
307
+ if (!on && by === 'cli' && headlessPlay !== null) {
308
+ endHeadlessPlay(undefined, 'ended by `autoplay off` before std said how it ended');
309
+ if (player?.autopilot === true) drive(player, false, by);
310
+ return;
311
+ }
312
+ const refused = autoplayUnavailable(player, on, goal);
313
+ if (refused !== null) throw new Error(refused);
314
+ drive(player!, on, by, on ? goal : undefined);
315
+ }
316
+
317
+ /** The session's one headless play of a goal, and the run that started it. */
318
+ let headlessPlay: { readonly run: LiveRun; readonly abort: AbortController } | null = null;
319
+
320
+ /** The headless play ended where one plays (`run` given: only that run's), its socket to std closed. */
321
+ function endHeadlessPlay(run?: LiveRun, why = 'ended by Play before std said how it ended'): void {
322
+ if (headlessPlay === null || (run !== undefined && headlessPlay.run !== run)) return;
323
+ const { run: started, abort } = headlessPlay;
324
+ headlessPlay = null;
325
+ abort.abort();
326
+ if (!started.stopped && started.headless?.state === 'playing') {
327
+ started.headless = { ...started.headless, state: 'failed', why };
328
+ started.log.append('play', 'autoplay-end', { goal: started.headless.goal, achieved: false, why: started.headless.why });
329
+ publishRun();
330
+ }
331
+ }
332
+
333
+ /** A bot's line as std's play says it (play.ts `said`): `<slot> (<device>, <mode>): done | failed: <why> | no outcome`,
334
+ * then what else it noted. */
335
+ const BOT_LINE = /^(\S+) \(([^,]+), ([^)]+)\): (done|failed: (.*?)|no outcome)(?:; (.*))?$/;
336
+
337
+ /**
338
+ * STD'S OWN PLAY OF A GOAL, on its headless device (`../serving/std-dev.ts`'s `/play`, std's `/__std/play`): its bots
339
+ * play the goal to its end on pages std opens, recorded in the run's folder, then the goal's checks read the
340
+ * recording. Each line std says is the play log's (`outcome` for a bot's end, `check` for a failed check, `play-line`
341
+ * otherwise), the bots' own traces too (their intents among them: `follow`), and its end is `autoplay-end` and the
342
+ * Game panel's line: `<goal>: achieved`, or `<goal>: failed — why`.
343
+ */
344
+ function playGoalHeadless(current: LiveRun, goal: string, by: 'panel' | 'cli'): void {
345
+ if (current.url === null) throw new Error('The game\'s dev server is still starting: autoplay once Play is running.');
346
+ // (one headless play a session at a time: a new one ends the one before, whichever run started it)
347
+ endHeadlessPlay();
348
+ const abort = new AbortController();
349
+ headlessPlay = { run: current, abort };
350
+ current.headless = { goal, state: 'playing', why: null };
351
+ current.log.append('play', 'autoplay-on', { goal, device: 'headless', by });
352
+ publishRun();
353
+ const failures: string[] = [];
354
+ // (what std said last: a play that failed before any bot ended, its own error among them, says why there)
355
+ let lastLine: string | null = null;
356
+ // (an ended run, or a play another has replaced, hears nothing more of it)
357
+ const live = (): boolean => !current.stopped && headlessPlay?.abort === abort;
358
+ const said = (line: string): void => {
359
+ if (!live()) return;
360
+ const bot = BOT_LINE.exec(line);
361
+ if (bot) {
362
+ const why = bot[5] ?? (bot[4] === 'no outcome' ? 'no outcome' : null);
363
+ if (bot[4] !== 'done') failures.push(`${bot[1]!}: ${why ?? ''}${bot[6] ? `; ${bot[6]}` : ''}`);
364
+ current.log.append('play', 'outcome', { slot: bot[1], goal, achieved: bot[4] === 'done', ...(why ? { why } : {}), device: bot[2], mode: bot[3], ...(bot[6] ? { noted: bot[6] } : {}) });
365
+ } else if (line.startsWith('check failed: ')) {
366
+ failures.push(line);
367
+ current.log.append('play', 'check', { goal, ok: false, failure: line.slice('check failed: '.length) });
368
+ } else {
369
+ lastLine = line;
370
+ current.log.append('play', 'play-line', { goal, line });
371
+ }
372
+ };
373
+ const ended = (ok: boolean): void => {
374
+ if (!live()) return;
375
+ headlessPlay = null;
376
+ const why = ok ? null : failures[0] ?? lastLine ?? 'std\'s play ended failed and said nothing';
377
+ current.headless = { goal, state: ok ? 'achieved' : 'failed', why };
378
+ current.log.append('play', 'autoplay-end', { goal, achieved: ok, ...(why === null ? {} : { why }) });
379
+ publishRun();
380
+ };
381
+ void (async () => {
382
+ // (aborted, the route closes its socket to std's play, which ends it)
383
+ const response = await fetch('/__editor/play/std/play', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal }), signal: abort.signal });
384
+ if (!response.ok || response.body === null) {
385
+ said(((await response.json().catch(() => ({}))) as { error?: string }).error ?? `std's play answered ${response.status}`);
386
+ ended(false);
387
+ return;
388
+ }
389
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
390
+ let rest = '';
391
+ for (;;) {
392
+ const { value, done } = await reader.read();
393
+ if (done) break;
394
+ rest += value;
395
+ for (let at = rest.indexOf('\n'); at >= 0; at = rest.indexOf('\n')) {
396
+ const message = JSON.parse(rest.slice(0, at)) as { line?: string; end?: { ok?: boolean } };
397
+ rest = rest.slice(at + 1);
398
+ if (message.end !== undefined) { ended(message.end.ok === true); return; }
399
+ if (typeof message.line === 'string') said(message.line);
400
+ }
401
+ }
402
+ said('std\'s play ended before it said how.');
403
+ ended(false);
404
+ })().catch((error: unknown) => {
405
+ said(error instanceof Error ? error.message : String(error));
406
+ ended(false);
407
+ });
408
+ }
409
+
410
+ /** The player whose page answers for the run: the viewed one, else the first joined. */
411
+ function answering(current: LiveRun): StdPlayer | undefined {
412
+ const viewed = typeof current.view === 'number' ? current.players[current.view] : undefined;
413
+ return viewed?.device ? viewed : current.players.find((player) => player.device !== null);
414
+ }
415
+
416
+ /** The viewed player's page's next frame, captured by std's receiver; its file. */
417
+ export async function captureStdView(): Promise<string> {
418
+ const player = run ? answering(run) : undefined;
419
+ if (!player?.device) throw new Error('No player\'s page has joined the game yet.');
420
+ const file = await stdCapture(player.device);
421
+ run?.log.append('play', 'capture', { slot: player.slot, file });
422
+ return file;
423
+ }
424
+
425
+ /**
426
+ * THE CLOCK SAID TO EVERY JOINED PLAYER'S PAGE (std's `clock` control): the game held, released, stepped `frames` or
427
+ * set to a speed on each, the answering player's clock kept as the run's. Refused where nothing of the document's
428
+ * plays, or no page has joined.
429
+ */
430
+ async function clockAll(documentId: string, asked: StdClockAction): Promise<StdClock> {
431
+ const current = run && !run.stopped && run.documentId === documentId ? run : null;
432
+ if (!current) throw new Error(`Nothing is playing in ${documentId}; \`play\` starts it.`);
433
+ const joined = current.players.filter((player) => player.device !== null);
434
+ if (joined.length === 0) throw new Error('No player\'s page has joined the game yet.');
435
+ const answers = await Promise.all(joined.map((player) => stdClock(player.device!, asked).then((clock) => ({ player, clock }))));
436
+ const said = answers.find(({ player }) => player === answering(current)) ?? answers[0]!;
437
+ current.log.append('play', asked.action, { ...asked, ...said.clock });
438
+ setModelPlayClock(documentId, said.clock);
439
+ return said.clock;
440
+ }
441
+
442
+ /** Pause or resume every player's page. */
443
+ export function setStdPaused(documentId: string, paused: boolean): Promise<StdClock> {
444
+ return clockAll(documentId, { action: paused ? 'pause' : 'resume' });
445
+ }
446
+
447
+ /** Step every player's page `frames` frames of 1/60 s, pausing it first. */
448
+ export function stepStd(documentId: string, frames: number): Promise<StdClock> {
449
+ return clockAll(documentId, { action: 'step', frames });
450
+ }
451
+
452
+ /** The person's speed, kept for the page's life, and said to every player's page that has joined. */
453
+ export async function setStdSpeed(documentId: string, speed: number): Promise<void> {
454
+ setModelPlaySpeed(documentId, speed);
455
+ if (run && !run.stopped && run.documentId === documentId && run.players.some((player) => player.device !== null))
456
+ await clockAll(documentId, { action: 'speed', speed });
457
+ }
458
+
459
+ /** A page that has just joined runs on the run's clock: held where the run is paused, at the person's speed. */
460
+ function keepClock(current: LiveRun, player: StdPlayer): void {
461
+ const { paused, speed } = modelPlayClock(current.documentId);
462
+ const device = player.device;
463
+ if (device === null) return;
464
+ const said = (asked: StdClockAction): Promise<void> => stdClock(device, asked).then(() => {},
465
+ (error: unknown) => current.log.append('play', 'script-error', { phase: 'clock', slot: player.slot, message: String(error) }));
466
+ void (speed !== 1 ? said({ action: 'speed', speed }) : Promise.resolve()).then(() => (paused ? said({ action: 'pause' }) : undefined));
467
+ }
468
+
469
+ /** How long a page has to answer one of the agent's doors (`act`, `inspect`) before the door says it did not. */
470
+ const FRAMED_MS = 3000;
471
+ /** How long a capture has: the page's next frame drawn, posted and written, then read back. */
472
+ const CAPTURE_MS = 8000;
473
+
474
+ /** A player's frame's driver: its page's keys, labelled nodes and inspection, through std's `act` and `inspect`. Each
475
+ * answer is the page's within {@link FRAMED_MS}, or a refusal saying why not: not joined, refused by std, or slow. */
476
+ function frameDriver(player: StdPlayer): Parameters<typeof registerFramedDriver>[1] {
477
+ const title = `Player ${player.number}'s page`;
478
+ const device = (): string => {
479
+ if (player.device === null) throw new Error(`${title} has not joined the game yet.`);
480
+ return player.device;
481
+ };
482
+ const bounded = <T>(what: string, asked: Promise<T>, ms = FRAMED_MS): Promise<T> => {
483
+ let timer: ReturnType<typeof setTimeout> | undefined;
484
+ const late = new Promise<never>((_, fail) => {
485
+ timer = setTimeout(() => fail(new Error(`${title} did not answer ${what} within ${ms / 1000} s.`)), ms);
486
+ });
487
+ return Promise.race([asked.catch((error: unknown) => {
488
+ throw new Error(`${title} refused ${what}: ${error instanceof Error ? error.message : String(error)}`);
489
+ }), late]).finally(() => clearTimeout(timer));
490
+ };
491
+ return {
492
+ title: () => title,
493
+ async key({ code, holdMs }) {
494
+ const at = device();
495
+ // (the release goes whatever the press answered: a press that lands late must not leave the key held)
496
+ try {
497
+ await bounded(`the key ${code}`, stdAct(at, [{ key: { key: code, down: true } }]));
498
+ if (holdMs > 0) await new Promise((done) => setTimeout(done, holdMs));
499
+ } finally {
500
+ await bounded(`the key ${code}'s release`, stdAct(at, [{ key: { key: code, down: false } }]));
501
+ }
502
+ },
503
+ async press({ x, y }) {
504
+ const at = device();
505
+ // (the press and its release, which std's pointer makes a click of, in one act: the release goes with it)
506
+ await bounded('a press', stdAct(at, [{ pointer: { x, y, button: 0, down: true } }, { pointer: { x, y, button: 0, down: false } }]));
507
+ },
508
+ async nodes(limit) {
509
+ const { screen, truncated } = await bounded('inspect', stdInspect(device()));
510
+ return { matched: screen.length + (truncated?.screen ?? 0), nodes: screen.slice(0, limit) };
511
+ },
512
+ inspect: () => bounded('inspect', stdInspect(device())),
513
+ // (the page's own frame, written by std's receiver: what its canvas drew, as std's `capture` takes it)
514
+ capture: () => bounded('capture', (async () => {
515
+ const file = await stdCapture(device());
516
+ run?.log.append('play', 'capture', { slot: player.slot, file });
517
+ return stdCaptured(file);
518
+ })(), CAPTURE_MS),
519
+ };
520
+ }
521
+
522
+ /**
523
+ * EACH PLAYER BOUND TO ITS PAGE AS IT IS NOW: the device whose page's trace names the player's slot, the
524
+ * newest where a page that loaded again is still listed beside its old one. A player whose page changed is told who
525
+ * drives it again (a new page starts with nobody's bot), and its trace is read from the new page's start.
526
+ */
527
+ async function bind(current: LiveRun): Promise<void> {
528
+ const [devices, slots] = await Promise.all([
529
+ askPlay<{ id: string; kind: string }[]>('/devices'),
530
+ askPlay<Record<string, string>>('/pages'),
531
+ ]);
532
+ for (const player of current.players) {
533
+ const pages = Object.entries(slots).filter(([, slot]) => slot === player.slot).map(([page]) => page);
534
+ // A page's device is `<kind>-<its id's first 8>` (std-dev's device list), whatever kind the page says it is.
535
+ const deviceOf = (page: string) => devices.find((device) => device.id === `${device.kind}-${page.slice(0, 8)}`)?.id;
536
+ const found = pages.map(deviceOf).filter((id) => id !== undefined).at(-1);
537
+ if (found === undefined || found === player.device) continue;
538
+ const joined = player.device === null;
539
+ player.device = found;
540
+ player.page = pages.find((page) => deviceOf(page) === found)!;
541
+ player.traced = 0;
542
+ current.log.append('play', joined ? 'player-joined' : 'player-page-loaded', { slot: player.slot, device: found });
543
+ tellDriver(current, player);
544
+ keepClock(current, player);
545
+ publishRun();
546
+ }
547
+ }
548
+
549
+ /** A player taken out of the run: its page unloaded, its client leaving. */
550
+ function retire(current: LiveRun, player: StdPlayer): void {
551
+ current.players.splice(current.players.indexOf(player), 1);
552
+ player.undrive();
553
+ player.frame.remove();
554
+ if (typeof current.view === 'number' && current.view >= current.players.length) current.view = Math.max(0, current.players.length - 1);
555
+ place();
556
+ }
557
+
558
+ /** One more player, as the person asks (Players [+]). */
559
+ export function addStdPlayer(): void {
560
+ if (!run || run.players.length >= MOST_PLAYERS) return;
561
+ playerCounts.set(run.documentId, Math.min(MOST_PLAYERS, (playerCounts.get(run.documentId) ?? FIRST_PLAYERS) + 1));
562
+ join(run);
563
+ }
564
+
565
+ /** One more player: a page of its own, a real client joining, joined before the next is added. A page that never
566
+ * joins, or whose join cannot be read, is taken out again, its failure kept as the run's. */
567
+ function join(current: LiveRun): void {
568
+ current.adding = current.adding.then(async () => {
569
+ if (current.stopped || current.url === null || current.players.length >= MOST_PLAYERS) return;
570
+ const number = Math.max(0, ...current.players.map((player) => player.number)) + 1;
571
+ const slot = `${current.tag}-${number}`;
572
+ const frame = document.createElement('iframe');
573
+ frame.setAttribute('credentialless', '');
574
+ frame.title = `Player ${number}`;
575
+ frame.dataset['testid'] = `std-player-${number}`;
576
+ Object.assign(frame.style, { position: 'absolute', border: '0', background: '#000' });
577
+ const at = new URL(current.url);
578
+ at.hostname = location.hostname;
579
+ at.searchParams.set('slot', slot);
580
+ frame.src = at.href;
581
+ const player: StdPlayer = { number, slot, frame, page: null, device: null, autopilot: false, goal: null, traced: 0, undrive: () => {} };
582
+ Object.assign(player, { undrive: registerFramedDriver(frame, frameDriver(player)) });
583
+ current.players.push(player);
584
+ current.host.appendChild(frame);
585
+ place();
586
+ publishRun();
587
+ const bound = Date.now() + JOIN_MS;
588
+ try {
589
+ while (player.device === null) {
590
+ if (current.stopped) return;
591
+ if (Date.now() > bound) throw new Error(`Player ${number}'s page did not join the game's dev server within ${JOIN_MS / 1000} s.`);
592
+ await new Promise((done) => setTimeout(done, LOOK_MS));
593
+ await bind(current);
594
+ }
595
+ } catch (error) {
596
+ if (!current.stopped) retire(current, player);
597
+ throw error;
598
+ }
599
+ driveForView();
600
+ publishRun();
601
+ }).catch((error: unknown) => {
602
+ if (current.stopped) return;
603
+ const failure = error instanceof Error ? error.message : String(error);
604
+ current.failure = failure;
605
+ current.log.append('play', 'script-error', { phase: 'player', message: failure });
606
+ current.stage.report('A player could not join', failure);
607
+ publishRun();
608
+ });
609
+ }
610
+
611
+ /** One player fewer, as the person asks (Players [-]): the last one's page unloaded, its client leaving. */
612
+ export function removeStdPlayer(): void {
613
+ const current = run;
614
+ if (!current) return;
615
+ playerCounts.set(current.documentId, Math.max(1, (playerCounts.get(current.documentId) ?? FIRST_PLAYERS) - 1));
616
+ current.adding = current.adding.then(() => {
617
+ if (current.stopped || current.players.length <= 1) return;
618
+ const player = current.players.at(-1)!;
619
+ retire(current, player);
620
+ current.log.append('play', 'player-left', { slot: player.slot, device: player.device });
621
+ driveForView();
622
+ publishRun();
623
+ });
624
+ }
625
+
626
+ /**
627
+ * A PLAYER'S PAGE THREW (its trace's `error` line, std's error fields: `label`, `source`, `stack`; `file`, `line`,
628
+ * `column` where the page knew them): the game's uncaught error is the editor's to show. Each distinct one (by its
629
+ * message and where) is an error in the editor's console from Play, naming its player, and the newest is the Game
630
+ * panel's status.
631
+ */
632
+ function pageError(current: LiveRun, player: StdPlayer, facts: Record<string, unknown>): void {
633
+ const label = typeof facts['label'] === 'string' ? facts['label'] : 'an error with no message';
634
+ const at = typeof facts['file'] === 'string'
635
+ ? ` (${facts['file'].replace(/^https?:\/\/[^/]+/, '').replace(/\?[^:]*/, '')}${facts['line'] !== undefined ? `:${String(facts['line'])}` : ''}${facts['column'] !== undefined ? `:${String(facts['column'])}` : ''})`
636
+ : '';
637
+ const said = `${label}${at}`;
638
+ current.lastError = `Player ${player.number}: ${said}`;
639
+ publishRun();
640
+ if (current.errorsSaid.has(said)) return;
641
+ current.errorsSaid.add(said);
642
+ const stack = typeof facts['stack'] === 'string' ? `\n${facts['stack'].split('\n').slice(1, 5).join('\n')}` : '';
643
+ editorHost().console.error(`The game threw in Player ${player.number}'s page (${player.slot}): ${said}${stack}`, 'play');
644
+ }
645
+
646
+ /**
647
+ * WHAT THE DEV SERVER SAID ON ITS ERROR OUTPUT, in blocks (a line and the indented lines under it: an exception and its
648
+ * stack). The room's own exceptions are Colyseus's to log there, never in a page's trace: a block that names an
649
+ * `Error` or carries a stack is a `script-error` in the play log (phase `server`), the Game panel's newest error, and,
650
+ * once each (by its first line, so a handler throwing at every message says it once), an error in the editor's
651
+ * console from Play. Anything else there (Vite's and Node's warnings, npm's notices) is the play log's `server-output`.
652
+ */
653
+ function serverSaid(current: LiveRun, lines: readonly string[]): void {
654
+ const blocks: string[][] = [];
655
+ for (const line of lines) {
656
+ if (line.trim() === '') continue;
657
+ if (/^\s/.test(line) && blocks.length > 0) blocks.at(-1)!.push(line);
658
+ else blocks.push([line]);
659
+ }
660
+ for (const block of blocks) {
661
+ const said = block.join('\n');
662
+ // (a block that starts indented is the rest of one a look before read: its error was said there)
663
+ if (/^\s/.test(block[0]!) || (!/Error\b/.test(block[0]!) && !block.some((line) => /^\s+at\s/.test(line)))) {
664
+ current.log.append('play', 'server-output', { message: said });
665
+ continue;
666
+ }
667
+ current.log.append('play', 'script-error', { phase: 'server', message: said });
668
+ current.lastError = `The game's dev server: ${block[0]!}`;
669
+ publishRun();
670
+ // (by the error's own words: Vite starts each line with the time it said it, which no two of a repeating error share)
671
+ const key = `server: ${block[0]!.replace(/^\d{1,2}:\d{2}:\d{2}(?:\s?[AP]M)?\s+/i, '')}`;
672
+ if (current.errorsSaid.has(key)) continue;
673
+ current.errorsSaid.add(key);
674
+ editorHost().console.error(`The game's dev server threw:\n${block.slice(0, 6).join('\n')}`, 'play');
675
+ }
676
+ }
677
+
678
+ /** The dev server's error output read on from the run's mark (where it stood as the run began; taken now, where the
679
+ * run's start couldn't), into the play log. */
680
+ async function followServer(current: LiveRun): Promise<void> {
681
+ const read = current.serverErrors;
682
+ const errors = await askPlay<{ lines: string[]; next: number; server: number | null }>(read === null
683
+ ? '/errors' : `/errors?${new URLSearchParams({ server: String(read.server ?? ''), from: String(read.next) })}`);
684
+ current.serverErrors = { server: errors.server, next: errors.next };
685
+ if (read !== null) serverSaid(current, errors.lines);
686
+ }
687
+
688
+ /** Each player's page bound again, and its trace read on from where it was, into the play log. */
689
+ async function follow(current: LiveRun): Promise<void> {
690
+ await bind(current);
691
+ // (its own failure the play log's, apart from the traces read after it)
692
+ await followServer(current).catch((error: unknown) => {
693
+ const said = `server-output: ${String(error)}`;
694
+ if (current.errorsSaid.has(said)) return;
695
+ current.errorsSaid.add(said);
696
+ current.log.append('play', 'server-output', { message: `Play could not read the dev server's error output: ${String(error)}` });
697
+ });
698
+ // (std's headless bots' pages, from the run's first headless play on: their slots are std's play's own)
699
+ if (current.headless !== null) {
700
+ for (const [page, slot] of Object.entries(await askPlay<Record<string, string>>('/pages')))
701
+ if (/^(server|p2p)-\d+$/.test(slot) && !current.headlessTraced.has(page)) current.headlessTraced.set(page, { slot, traced: 0 });
702
+ for (const [page, read] of current.headlessTraced) {
703
+ const { lines, next } = await askPlay<{ lines: string[]; next: number }>(`/trace?page=${page}&from=${read.traced}`);
704
+ read.traced = next;
705
+ for (const line of lines) {
706
+ const { k, s, ...facts } = JSON.parse(line) as { k?: unknown; s?: unknown } & Record<string, unknown>;
707
+ current.log.append('script', typeof k === 'string' ? k : 'trace', { slot: read.slot, ...facts },
708
+ typeof s === 'number' && Number.isFinite(s) ? { simT: s / TRACE_STEPS_PER_SECOND, tick: s } : undefined);
709
+ }
710
+ }
711
+ }
712
+ for (const player of current.players) {
713
+ if (player.page === null) continue;
714
+ const { lines, next } = await askPlay<{ lines: string[]; next: number }>(`/trace?page=${player.page}&from=${player.traced}`);
715
+ player.traced = next;
716
+ for (const line of lines) {
717
+ // (std's trace format 3: `s` the line's step, sixty a second of its page's steady clock)
718
+ const { k, s, ...facts } = JSON.parse(line) as { k?: unknown; s?: unknown } & Record<string, unknown>;
719
+ current.log.append('script', typeof k === 'string' ? k : 'trace', { slot: player.slot, ...facts },
720
+ typeof s === 'number' && Number.isFinite(s) ? { simT: s / TRACE_STEPS_PER_SECOND, tick: s } : undefined);
721
+ if (k === 'error') pageError(current, player, facts);
722
+ }
723
+ }
724
+ }
725
+
726
+ /** The keys of the view bar, while the editor holds the keyboard: 1 to 4 a player, Tab the next view, Escape Edit. */
727
+ function viewKey(event: KeyboardEvent): void {
728
+ if (!run || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
729
+ // (the Export menu's Escape is the menu's)
730
+ if (event.code === 'Escape' && exportMenuOpen()) return;
731
+ const target = event.target as HTMLElement | null;
732
+ if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
733
+ const views = stdViews();
734
+ const next: StdView | undefined = /^Digit[1-4]$/.test(event.code) ? Number(event.code.slice(5)) - 1
735
+ : event.code === 'Tab' ? views[(views.indexOf(run.view) + (event.shiftKey ? views.length - 1 : 1)) % views.length]
736
+ : event.code === 'Escape' && run.view !== 'edit' ? 'edit' : undefined;
737
+ if (next === undefined || (typeof next === 'number' && !run.players[next])) return;
738
+ event.preventDefault();
739
+ event.stopPropagation();
740
+ setStdView(next);
741
+ }
742
+
743
+ /** Run the std game in the stage; the answer stops it (every player's page unloaded, its client leaving). */
744
+ export function runStdGame(stage: DocumentPlayStage, report: (title: string, failure: string) => void, before: Promise<void>): () => void {
745
+ const { documentId } = stage;
746
+ const host = document.createElement('div');
747
+ host.dataset['testid'] = 'std-players';
748
+ Object.assign(host.style, { position: 'absolute', inset: '0' });
749
+ // (the bar and the grid's tiles in the stage's overlay, over the document's own area too)
750
+ const bar = document.createElement('div');
751
+ const tiles = document.createElement('div');
752
+ Object.assign(tiles.style, { position: 'absolute', inset: '0', pointerEvents: 'none' });
753
+ // (the bar stands above the tiles, and its row passes the presses the bar doesn't hold to the tile beneath)
754
+ Object.assign(bar.style, { position: 'relative', zIndex: '1', pointerEvents: 'none' });
755
+ stage.container.appendChild(host);
756
+ (stage.overlay ?? stage.container).append(tiles, bar);
757
+ const roots: Root[] = [createRoot(bar), createRoot(tiles)];
758
+ roots[0]!.render(createElement(StdViewBar));
759
+ roots[1]!.render(createElement(StdGridTiles));
760
+ const log = beginModelPlayLog(documentId, STD_DEV);
761
+ const tag = Array.from(crypto.getRandomValues(new Uint8Array(3)), (byte) => byte.toString(16).padStart(2, '0')).join('');
762
+ const mine: LiveRun = run = { documentId, players: [], view: 0, goal: game?.goal ?? null, goals: knownGoals, url: null, tag, host, stage,
763
+ log, adding: Promise.resolve(), failure: null, stopped: false, lastError: null, errorsSaid: new Set(), headless: null,
764
+ headlessTraced: new Map(), serverErrors: null };
765
+ // (a press in the viewed page is the person's: the editor's window loses the keyboard to it)
766
+ const tookOver = (): void => {
767
+ const player = typeof mine.view === 'number' ? mine.players[mine.view] : undefined;
768
+ if (player && document.activeElement === player.frame) drive(player, false, 'takeover');
769
+ };
770
+ window.addEventListener('blur', tookOver);
771
+ window.addEventListener('keydown', viewKey, true);
772
+ // (each look after the last has answered, so no two read one trace from the same byte)
773
+ let looking: ReturnType<typeof setTimeout> | null = null;
774
+ const lookOn = (): void => {
775
+ looking = setTimeout(() => {
776
+ follow(mine).catch((error: unknown) => log.append('play', 'script-error', { phase: 'trace', message: String(error) }))
777
+ .finally(() => { if (!mine.stopped) lookOn(); });
778
+ }, LOOK_MS);
779
+ };
780
+ // (the level saved first, so the game plays the current edit: its export has run when the pages load)
781
+ void before.then(async () => {
782
+ // (the run's mark, before the dev server starts: one started now is read from its first line, one running already
783
+ // from where its output stands)
784
+ // (a mark not answered is taken at the run's first look, where the output stands then)
785
+ const mark = await askPlay<{ next: number; server: number | null }>('/errors').catch(() => null);
786
+ if (mark !== null) mine.serverErrors = { server: mark.server, next: mark.next };
787
+ }).then(() => Promise.all([
788
+ askPlay<{ url: string }>('/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }),
789
+ // (the game's goals, read again for each run: its autopilot/ program is the author's to change)
790
+ askPlay<{ goals: string[] }>('/goals').then(({ goals }) => goals, (error: unknown) => {
791
+ log.append('play', 'script-error', { phase: 'goals', message: error instanceof Error ? error.message : String(error) });
792
+ return [] as string[];
793
+ }),
794
+ ])).then(([{ url }, goals]) => {
795
+ if (mine.stopped) return;
796
+ mine.url = url;
797
+ mine.goals = knownGoals = goals;
798
+ if (mine.goal === null || (goals.length > 0 && !goals.includes(mine.goal))) mine.goal = goals[0] ?? null;
799
+ log.append('play', 'running', { url, goals });
800
+ // (the traces read from the dev server's start, whether or not a player's page joins: std's headless play's bots
801
+ // play while the editor is not seen, and its players' pages do not)
802
+ lookOn();
803
+ for (let n = playerCounts.get(documentId) ?? FIRST_PLAYERS; n > 0; n--) join(mine);
804
+ void mine.adding.then(() => {
805
+ if (mine.stopped) return;
806
+ // (a run with a player who never joined stands with its failure, and draws nothing in front of the document)
807
+ const failure = mine.failure ?? (mine.players.some((player) => player.device !== null) ? null : 'No player\'s page joined the game.');
808
+ if (failure !== null) { setModelPlayFailure(documentId, failure); return; }
809
+ setModelPlayFailure(documentId, null);
810
+ stage.ready();
811
+ // (Autoplay armed while stopped drives the viewed player from the run's start, with the goal it was armed with)
812
+ const arm = takeModelPlayArm(documentId);
813
+ if (arm !== null)
814
+ setStdAutoplay(true, 'panel', arm.goal ?? undefined)
815
+ .catch((error: unknown) => log.append('play', 'autoplay-refused', { message: error instanceof Error ? error.message : String(error) }));
816
+ });
817
+ publishRun();
818
+ }, (error: unknown) => {
819
+ if (mine.stopped) return;
820
+ const failure = error instanceof Error ? error.message : String(error);
821
+ setModelPlayFailure(documentId, failure);
822
+ report('The game\'s dev server could not be started', failure);
823
+ });
824
+ publishRun();
825
+ return () => {
826
+ // (Stop, Restart, the document's close and a project change all end here: std's headless play goes with the run)
827
+ endHeadlessPlay(mine);
828
+ mine.stopped = true;
829
+ if (looking !== null) clearTimeout(looking);
830
+ window.removeEventListener('blur', tookOver);
831
+ window.removeEventListener('keydown', viewKey, true);
832
+ for (const root of roots) root.unmount();
833
+ for (const player of mine.players) player.undrive();
834
+ host.remove();
835
+ bar.remove();
836
+ tiles.remove();
837
+ log.end();
838
+ if (run === mine) run = null;
839
+ publishRun();
840
+ };
841
+ }