@volter/play 0.5.211 → 0.5.213

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