@volter/play 0.5.212 → 0.5.214

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/std-game.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A STD GAME, PLAYED IN THE MODEL DOCUMENT'S STAGE (volter-ai/std, design/circle-1.md, "Devices"): a project with
2
+ * A STD GAME, PLAYED IN THE GAME VIEW (`game-view.tsx`; volter-ai/std, design/circle-1.md, "Devices"): a project with
3
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
4
  * client is one a page. Play starts the game's dev server where none runs (`../serving/std-dev.ts`) and stands each
5
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,
@@ -10,26 +10,30 @@
10
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
11
  * hostname (`editor-<key>.localhost`), so it is the same site as the page and std's receiver takes its trace (its
12
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.
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.
14
16
  *
15
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
16
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
17
19
  * names the player's slot (`/pages`), read again as the run goes, so a page that loads again (a new page, a new
18
20
  * device) is found again, and a page of another editor's is never taken for one of these.
19
21
  *
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.
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.
24
27
  *
25
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
26
29
  * own, which std's page hears itself. Every other player is the game's bot ("autopilot"): its page is told `autoplay`
27
30
  * 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.
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.
30
34
  *
31
35
  * 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
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
33
37
  * runs on, as Colyseus has no pause for a room.
34
38
  *
35
39
  * THE AGENT'S DOORS. Each player's frame registers its driver (`@volter/sdk/framed-document-driver`): while it is the
@@ -42,13 +46,11 @@
42
46
  */
43
47
  import { createElement } from 'react';
44
48
  import { createRoot, type Root } from 'react-dom/client';
45
- import type { DocumentPlayEditArea, DocumentPlayStage } from '@volter/editor-blender/api';
46
49
  import { getCurrentProject, onProjectChange } from '@volter/sdk/active-project';
47
50
  import { editorHost } from '@volter/sdk/host';
48
51
  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 { beginPlayLog, type PlayLogRun } from './play-log';
53
+ import { gameClock, setGameClock, setGameFailure, setGameSpeed, setGameTraceStep, takeGameArm } from './game-run';
52
54
  import { askPlay, stdAct, stdCapture, stdCaptured, stdClock, stdDrive, stdInspect, type StdClock, type StdClockAction } from './std-controls';
53
55
  import { StdGridTiles, StdViewBar } from './std-view-bar';
54
56
 
@@ -59,11 +61,57 @@ const JOIN_MS = 60_000;
59
61
  const LOOK_MS = 500;
60
62
  /** Steps a second of a std trace's clock (std's trace format 3, `s`). */
61
63
  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;
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
+ }
67
115
 
68
116
  export interface StdPlayer {
69
117
  /** Its number in the run (Player 1, 2, …). */
@@ -74,9 +122,8 @@ export interface StdPlayer {
74
122
  /** Its page (std's page id) and that page's device, once its page has joined. */
75
123
  page: string | null;
76
124
  device: string | null;
125
+ /** Whether the game's bot drives it, playing the run's goal: every player but the viewed one. */
77
126
  autopilot: boolean;
78
- /** The goal its bot plays while it drives: one Autoplay named, or the run's. */
79
- goal: string | null;
80
127
  /** How far its page's trace has been read. */
81
128
  traced: number;
82
129
  /** Withdraws its frame's driver (`@volter/sdk/framed-document-driver`). */
@@ -84,7 +131,6 @@ export interface StdPlayer {
84
131
  }
85
132
 
86
133
  export interface StdRun {
87
- readonly documentId: string;
88
134
  readonly players: readonly StdPlayer[];
89
135
  readonly view: StdView;
90
136
  /** The goal the game's bots play unless Autoplay names another (its `play` script's `--goal`, else its first goal);
@@ -96,9 +142,18 @@ export interface StdRun {
96
142
  readonly url: string | null;
97
143
  /** The newest error a player's page threw (its trace's `error` line), with its player: the Game panel says it. */
98
144
  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;
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;
102
157
  }
103
158
 
104
159
  /** The game's goals as the last run read them, which Autoplay offers while stopped (an arm names one). */
@@ -146,18 +201,15 @@ export function stdGameNow(): Promise<boolean> {
146
201
 
147
202
  /** A run as this module keeps it. */
148
203
  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'];
204
+ lastError: string | null; readonly errorsSaid: Set<string>; played: StdRun['played'];
150
205
  /** How far each headless bot page's trace has been read, by its page (std's play's slots, `server-<n>`, `p2p-<n>`). */
151
206
  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>;
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>;
156
211
  failure: string | null; stopped: boolean };
157
212
  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
213
  const runListeners = new Set<() => void>();
162
214
  let runVersion = 0;
163
215
  const publishRun = (): void => { runVersion += 1; for (const listener of [...runListeners]) listener(); };
@@ -166,49 +218,47 @@ export function subscribeStdRun(listener: () => void): () => void {
166
218
  return () => { runListeners.delete(listener); };
167
219
  }
168
220
  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;
221
+ /** The std game's run, or null while none runs. */
222
+ export function stdRun(): StdRun | null {
223
+ return run && !run.stopped ? run : null;
172
224
  }
173
225
 
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
226
  /** Says to a player's page who drives it: its bot (`autoplay`, its slot and its goal) or the person (`manual`). */
178
227
  function tellDriver(current: LiveRun, player: StdPlayer): void {
179
- const goal = goalOf(current, player);
228
+ const goal = current.goal;
180
229
  if (player.device === null || (player.autopilot && goal === null)) return;
181
230
  void stdDrive(player.device, player.autopilot && goal !== null ? { slot: player.slot, goal } : null)
182
231
  .catch((error: unknown) => current.log.append('play', 'script-error', { phase: 'autoplay', slot: player.slot, message: String(error) }));
183
232
  }
184
233
 
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 {
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 {
188
236
  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;
237
+ if (!current || player.autopilot === autopilot || (autopilot && current.goal === null)) return;
193
238
  player.autopilot = autopilot;
194
- current.log.append('play', autopilot ? 'autoplay-on' : 'autoplay-off', { slot: player.slot, by, ...(autopilot ? { goal: goalOf(current, player) } : {}) });
239
+ current.log.append('play', autopilot ? 'autoplay-on' : 'autoplay-off', { slot: player.slot, by: 'view', ...(autopilot ? { goal: current.goal } : {}) });
195
240
  tellDriver(current, player);
196
241
  publishRun();
197
242
  }
198
243
 
199
- /** Who drives each player for the view: the viewed one the person, every other the bot. */
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. */
200
250
  function driveForView(): void {
201
251
  if (!run) return;
202
- run.players.forEach((player, index) => drive(player, index !== run!.view, 'view'));
252
+ run.players.forEach((player, index) => { if (!heldByStd(run!, player)) drive(player, index !== run!.view); });
203
253
  }
204
254
 
205
255
  /**
206
- * The grid's tile `tile` of `count` (Edit first, then each player), in fractions of the stage. A tile is its view
256
+ * The grid's tile `tile` of `count` (the Scene first, then each player), in fractions of the stage. A tile is its view
207
257
  * whole, at the stage's own proportions scaled down alike (by `width`, which `height` equals), as Unity's Multiplayer
208
258
  * Play Mode shows each player's whole window: centred in its cell, with a short last row centred, so no cell stands
209
259
  * empty.
210
260
  */
211
- export function stdTile(tile: number, count: number): Exclude<DocumentPlayEditArea, 'live' | 'hidden'> {
261
+ export function stdTile(tile: number, count: number): StdTileBox {
212
262
  const columns = Math.ceil(Math.sqrt(count)), rows = Math.ceil(count / columns);
213
263
  const scale = 1 / columns;
214
264
  const row = Math.floor(tile / columns);
@@ -217,7 +267,7 @@ export function stdTile(tile: number, count: number): Exclude<DocumentPlayEditAr
217
267
  return { left, top: row / rows + (1 / rows - scale) / 2, width: scale, height: scale };
218
268
  }
219
269
 
220
- /** Each frame placed for the view (full size, or tiled), and the document's own area: live, its tile, or hidden. */
270
+ /** Each frame placed for the view (full size, or tiled), and the live Scene: the whole stage, its tile, or nowhere. */
221
271
  function place(): void {
222
272
  if (!run) return;
223
273
  const { players, view } = run;
@@ -234,7 +284,7 @@ function place(): void {
234
284
  zIndex: view === index ? '1' : '0',
235
285
  });
236
286
  });
237
- run.stage.edit?.(view === 'edit' ? 'live' : view === 'grid' ? stdTile(0, players.length + 1) : 'hidden');
287
+ run.stage.scene(view === 'scene' ? 'whole' : view === 'grid' ? stdTile(0, players.length + 1) : null);
238
288
  }
239
289
 
240
290
  /** Opens a view: the frames placed, its player the person's and the rest the bot's, the keyboard in its page. */
@@ -248,9 +298,9 @@ export function setStdView(view: StdView): void {
248
298
  publishRun();
249
299
  }
250
300
 
251
- /** The views in their Tab order: Edit, each player, Grid. */
301
+ /** The views in their Tab order: Scene, each player, Grid. */
252
302
  function stdViews(): StdView[] {
253
- return run ? ['edit', ...run.players.map((_, index) => index), 'grid'] : [];
303
+ return run ? ['scene', ...run.players.map((_, index) => index), 'grid'] : [];
254
304
  }
255
305
 
256
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
@@ -265,15 +315,17 @@ function autoplayUnavailable(player: StdPlayer | undefined, on = true, goal?: st
265
315
 
266
316
  /** Whether the viewed player's bot drives (Autoplay's state), the goal it plays, the game's goals, and why it cannot
267
317
  * 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'] } {
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'] } {
270
320
  const player = run && typeof run.view === 'number' ? run.players[run.view] : undefined;
321
+ const on = run?.played?.state === 'playing';
271
322
  return {
272
- on: player?.autopilot ?? false,
273
- goal: run && player ? goalOf(run, player) : run?.goal ?? null,
323
+ on,
324
+ driving: run !== null && player !== undefined && heldByStd(run, player),
325
+ goal: on ? run!.played!.goal : run?.goal ?? null,
274
326
  goals: run?.goals ?? knownGoals,
275
327
  unavailable: autoplayUnavailable(player),
276
- headless: run?.headless ?? null,
328
+ played: run?.played ?? null,
277
329
  };
278
330
  }
279
331
 
@@ -286,75 +338,104 @@ async function refreshGoals(current: LiveRun): Promise<void> {
286
338
  }
287
339
 
288
340
  /**
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.
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`).
293
348
  */
294
- export async function setStdAutoplay(on: boolean, by: 'panel' | 'cli', goal?: string): Promise<void> {
349
+ export async function setStdAutoplay(on: boolean, by: 'panel' | 'cli' | 'arm', goal?: string): Promise<void> {
295
350
  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);
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');
303
362
  return;
304
363
  }
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;
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);
311
375
  }
312
- const refused = autoplayUnavailable(player, on, goal);
313
- if (refused !== null) throw new Error(refused);
314
- drive(player!, on, by, on ? goal : undefined);
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);
315
380
  }
316
381
 
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;
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;
319
387
 
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;
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;
325
393
  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();
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);
330
399
  }
331
400
  }
332
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
+
333
410
  /** A bot's line as std's play says it (play.ts `said`): `<slot> (<device>, <mode>): done | failed: <why> | no outcome`,
334
411
  * then what else it noted. */
335
412
  const BOT_LINE = /^(\S+) \(([^,]+), ([^)]+)\): (done|failed: (.*?)|no outcome)(?:; (.*))?$/;
336
413
 
337
414
  /**
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`
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`
341
419
  * 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`.
420
+ * Game panel's line: `<goal>: achieved`, or `<goal>: failed — why`, with how its checks went, as std said it.
343
421
  */
344
- function playGoalHeadless(current: LiveRun, goal: string, by: 'panel' | 'cli'): void {
422
+ function playGoal(current: LiveRun, goal: string, device: string, by: 'panel' | 'cli' | 'arm'): void {
345
423
  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();
424
+ // (one play by std a session at a time: a new one ends the one before, whichever run started it)
425
+ endStdPlay();
348
426
  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 });
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 });
352
430
  publishRun();
353
431
  const failures: string[] = [];
354
432
  // (what std said last: a play that failed before any bot ended, its own error among them, says why there)
355
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;
356
437
  // (an ended run, or a play another has replaced, hears nothing more of it)
357
- const live = (): boolean => !current.stopped && headlessPlay?.abort === abort;
438
+ const live = (): boolean => !current.stopped && stdPlay?.abort === abort;
358
439
  const said = (line: string): void => {
359
440
  if (!live()) return;
360
441
  const bot = BOT_LINE.exec(line);
@@ -366,21 +447,23 @@ function playGoalHeadless(current: LiveRun, goal: string, by: 'panel' | 'cli'):
366
447
  failures.push(line);
367
448
  current.log.append('play', 'check', { goal, ok: false, failure: line.slice('check failed: '.length) });
368
449
  } else {
450
+ if (line.startsWith('checks of ')) checks = line;
451
+ if (line.startsWith('the report: ')) report = line.slice('the report: '.length);
369
452
  lastLine = line;
370
453
  current.log.append('play', 'play-line', { goal, line });
371
454
  }
372
455
  };
373
456
  const ended = (ok: boolean): void => {
374
457
  if (!live()) return;
375
- headlessPlay = null;
458
+ stdPlay = null;
376
459
  const why = ok ? null : failures[0] ?? lastLine ?? 'std\'s play ended failed and said nothing';
377
- current.headless = { goal, state: ok ? 'achieved' : 'failed', why };
460
+ current.played = { goal, device, state: ok ? 'achieved' : 'failed', why, checks, report };
378
461
  current.log.append('play', 'autoplay-end', { goal, achieved: ok, ...(why === null ? {} : { why }) });
379
- publishRun();
462
+ released(current);
380
463
  };
381
464
  void (async () => {
382
465
  // (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 });
466
+ const response = await fetch('/__editor/play/std/play', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ goal, device }), signal: abort.signal });
384
467
  if (!response.ok || response.body === null) {
385
468
  said(((await response.json().catch(() => ({}))) as { error?: string }).error ?? `std's play answered ${response.status}`);
386
469
  ended(false);
@@ -424,41 +507,49 @@ export async function captureStdView(): Promise<string> {
424
507
 
425
508
  /**
426
509
  * 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.
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.
429
512
  */
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.`);
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.');
433
516
  const joined = current.players.filter((player) => player.device !== null);
434
517
  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 }))));
518
+ // (an answer is its run's: one that comes after Stop or Restart ended that run is no reading of the game now, and a
519
+ // page that run's end disconnected refuses with std's own "no device" before it can answer)
520
+ const ended = (): Error => new Error('The run ended (Stop or Restart) before its players\' pages answered.');
521
+ const answers = await Promise.all(joined.map((player) => stdClock(player.device!, asked).then((clock) => ({ player, clock }))))
522
+ .catch((error: unknown) => { throw current.stopped ? ended() : error; });
523
+ if (current.stopped) throw ended();
436
524
  const said = answers.find(({ player }) => player === answering(current)) ?? answers[0]!;
437
525
  current.log.append('play', asked.action, { ...asked, ...said.clock });
438
- setModelPlayClock(documentId, said.clock);
526
+ setGameClock(said.clock);
439
527
  return said.clock;
440
528
  }
441
529
 
442
530
  /** 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' });
531
+ export function setStdPaused(paused: boolean): Promise<StdClock> {
532
+ return clockAll({ action: paused ? 'pause' : 'resume' });
445
533
  }
446
534
 
447
535
  /** 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 });
536
+ export function stepStd(frames: number): Promise<StdClock> {
537
+ return clockAll({ action: 'step', frames });
450
538
  }
451
539
 
452
540
  /** 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 });
541
+ export async function setStdSpeed(speed: number): Promise<void> {
542
+ setGameSpeed(speed);
543
+ const current = run;
544
+ // (the speed is the person's and is kept whatever the run's pages answer: a run that ended while they answered says
545
+ // nothing against it, and the next run's pages take it as they join)
546
+ if (current && !current.stopped && current.players.some((player) => player.device !== null))
547
+ await clockAll({ action: 'speed', speed }).catch((error: unknown) => { if (!current.stopped) throw error; });
457
548
  }
458
549
 
459
550
  /** A page that has just joined runs on the run's clock: held where the run is paused, at the person's speed. */
460
551
  function keepClock(current: LiveRun, player: StdPlayer): void {
461
- const { paused, speed } = modelPlayClock(current.documentId);
552
+ const { paused, speed } = gameClock();
462
553
  const device = player.device;
463
554
  if (device === null) return;
464
555
  const said = (asked: StdClockAction): Promise<void> => stdClock(device, asked).then(() => {},
@@ -555,18 +646,49 @@ function retire(current: LiveRun, player: StdPlayer): void {
555
646
  place();
556
647
  }
557
648
 
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);
649
+ /**
650
+ * HOW MANY PLAY: the project's setting (`volter.play.players`), as the person (Players [-][+]) or the agent
651
+ * (`play players <n>`) sets it, from one to eight, written to the project's settings as any setting is; a running game's
652
+ * players join or leave to match whenever the setting changes, whoever changed it. Each step of the matching reads the
653
+ * setting as it is then, so counts set in quick succession end at the last. Settles once the setting is written and in
654
+ * force, and refuses, with where to read why, when the settings did not take it.
655
+ */
656
+ export const setStdPlayers = (count: number): Promise<void> => writePlayers(() => count);
657
+
658
+ /** One player more or fewer than the setting: a step counts from the write before it, so two quick [+] are two more. */
659
+ export const stepStdPlayers = (by: 1 | -1): Promise<void> => writePlayers(() => playersSet() + by);
660
+
661
+ /** The setting's writes, one after another: each reads the count it writes once the write before it is in force. */
662
+ let playersWrites: Promise<void> = Promise.resolve();
663
+ function writePlayers(count: () => number): Promise<void> {
664
+ const write = playersWrites.then(async () => {
665
+ const asked = Math.min(MOST_PLAYERS, Math.max(1, Math.round(count())));
666
+ await editorHost().settings.set(PLAYERS_SETTING, asked, 'project');
667
+ if (playersSet() !== asked)
668
+ throw new Error(`The project's settings did not take ${PLAYERS_SETTING} = ${asked}; the console says why.`);
669
+ });
670
+ playersWrites = write.catch(() => {});
671
+ return write;
672
+ }
673
+
674
+ /** The running game's players matched to the setting, and the Game panel told. */
675
+ function matchPlayers(): void {
676
+ publishRun();
677
+ const current = run;
678
+ if (!current) return;
679
+ current.adding = current.adding.then(() => {
680
+ if (current.stopped) return;
681
+ for (let n = current.players.length; n > stdPlayersAsked(); n--) leave(current);
682
+ for (let n = current.players.length; n < stdPlayersAsked(); n++) join(current);
683
+ });
563
684
  }
564
685
 
565
686
  /** One more player: a page of its own, a real client joining, joined before the next is added. A page that never
566
687
  * joins, or whose join cannot be read, is taken out again, its failure kept as the run's. */
567
688
  function join(current: LiveRun): void {
568
689
  current.adding = current.adding.then(async () => {
569
- if (current.stopped || current.url === null || current.players.length >= MOST_PLAYERS) return;
690
+ // (as many as asked when this join's turn comes: a count lowered meanwhile wants it no longer)
691
+ if (current.stopped || current.url === null || current.players.length >= stdPlayersAsked()) return;
570
692
  const number = Math.max(0, ...current.players.map((player) => player.number)) + 1;
571
693
  const slot = `${current.tag}-${number}`;
572
694
  const frame = document.createElement('iframe');
@@ -578,7 +700,7 @@ function join(current: LiveRun): void {
578
700
  at.hostname = location.hostname;
579
701
  at.searchParams.set('slot', slot);
580
702
  frame.src = at.href;
581
- const player: StdPlayer = { number, slot, frame, page: null, device: null, autopilot: false, goal: null, traced: 0, undrive: () => {} };
703
+ const player: StdPlayer = { number, slot, frame, page: null, device: null, autopilot: false, traced: 0, undrive: () => {} };
582
704
  Object.assign(player, { undrive: registerFramedDriver(frame, frameDriver(player)) });
583
705
  current.players.push(player);
584
706
  current.host.appendChild(frame);
@@ -608,19 +730,14 @@ function join(current: LiveRun): void {
608
730
  });
609
731
  }
610
732
 
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
- });
733
+ /** The last player gone: its page unloaded, its client leaving. */
734
+ function leave(current: LiveRun): void {
735
+ const player = current.players.at(-1);
736
+ if (player === undefined || current.players.length <= 1) return;
737
+ retire(current, player);
738
+ current.log.append('play', 'player-left', { slot: player.slot, device: player.device });
739
+ driveForView();
740
+ publishRun();
624
741
  }
625
742
 
626
743
  /**
@@ -643,6 +760,17 @@ function pageError(current: LiveRun, player: StdPlayer, facts: Record<string, un
643
760
  editorHost().console.error(`The game threw in Player ${player.number}'s page (${player.slot}): ${said}${stack}`, 'play');
644
761
  }
645
762
 
763
+ /** Output lines in blocks: a line and the indented lines under it, blank lines left out. */
764
+ function blocksOf(lines: readonly string[]): string[][] {
765
+ const blocks: string[][] = [];
766
+ for (const line of lines) {
767
+ if (line.trim() === '') continue;
768
+ if (/^\s/.test(line) && blocks.length > 0) blocks.at(-1)!.push(line);
769
+ else blocks.push([line]);
770
+ }
771
+ return blocks;
772
+ }
773
+
646
774
  /**
647
775
  * WHAT THE DEV SERVER SAID ON ITS ERROR OUTPUT, in blocks (a line and the indented lines under it: an exception and its
648
776
  * stack). The room's own exceptions are Colyseus's to log there, never in a page's trace: a block that names an
@@ -651,13 +779,7 @@ function pageError(current: LiveRun, player: StdPlayer, facts: Record<string, un
651
779
  * console from Play. Anything else there (Vite's and Node's warnings, npm's notices) is the play log's `server-output`.
652
780
  */
653
781
  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) {
782
+ for (const block of blocksOf(lines)) {
661
783
  const said = block.join('\n');
662
784
  // (a block that starts indented is the rest of one a look before read: its error was said there)
663
785
  if (/^\s/.test(block[0]!) || (!/Error\b/.test(block[0]!) && !block.some((line) => /^\s+at\s/.test(line)))) {
@@ -675,14 +797,25 @@ function serverSaid(current: LiveRun, lines: readonly string[]): void {
675
797
  }
676
798
  }
677
799
 
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. */
800
+ /** The dev server's two streams as `/output` answers them: each stream's lines from where it was asked, and its next. */
801
+ interface ServerOutput {
802
+ readonly server: number | null;
803
+ readonly stdout: { readonly lines: string[]; readonly next: number };
804
+ readonly stderr: { readonly lines: string[]; readonly next: number };
805
+ }
806
+
807
+ /** 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
808
+ * start couldn't), into the play log: each line of its standard output (the room's own `console.log`, Vite's notices)
809
+ * a `server-output` as it is, and its error output read in blocks (`serverSaid`). */
680
810
  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);
811
+ const read = current.serverOutput;
812
+ const output = await askPlay<ServerOutput>(read === null ? '/output' : `/output?${new URLSearchParams({
813
+ server: String(read.server ?? ''), stdout: String(read.stdout), stderr: String(read.stderr) })}`);
814
+ current.serverOutput = { server: output.server, stdout: output.stdout.next, stderr: output.stderr.next };
815
+ if (read === null) return;
816
+ // (a line and the indented lines under it one entry, as a logged object or a warning's trace prints)
817
+ for (const block of blocksOf(output.stdout.lines)) current.log.append('play', 'server-output', { message: block.join('\n') });
818
+ serverSaid(current, output.stderr.lines);
686
819
  }
687
820
 
688
821
  /** Each player's page bound again, and its trace read on from where it was, into the play log. */
@@ -693,10 +826,10 @@ async function follow(current: LiveRun): Promise<void> {
693
826
  const said = `server-output: ${String(error)}`;
694
827
  if (current.errorsSaid.has(said)) return;
695
828
  current.errorsSaid.add(said);
696
- current.log.append('play', 'server-output', { message: `Play could not read the dev server's error output: ${String(error)}` });
829
+ current.log.append('play', 'server-output', { message: `Play could not read the dev server's output: ${String(error)}` });
697
830
  });
698
831
  // (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) {
832
+ if (current.played?.device === 'headless' || current.headlessTraced.size > 0) {
700
833
  for (const [page, slot] of Object.entries(await askPlay<Record<string, string>>('/pages')))
701
834
  if (/^(server|p2p)-\d+$/.test(slot) && !current.headlessTraced.has(page)) current.headlessTraced.set(page, { slot, traced: 0 });
702
835
  for (const [page, read] of current.headlessTraced) {
@@ -713,27 +846,32 @@ async function follow(current: LiveRun): Promise<void> {
713
846
  if (player.page === null) continue;
714
847
  const { lines, next } = await askPlay<{ lines: string[]; next: number }>(`/trace?page=${player.page}&from=${player.traced}`);
715
848
  player.traced = next;
849
+ let step: number | null = null;
716
850
  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)
851
+ // (std's trace format 3: `s` the line's step, sixty a second of its page's game clock, which is its
852
+ // `performance.now` on a live page)
718
853
  const { k, s, ...facts } = JSON.parse(line) as { k?: unknown; s?: unknown } & Record<string, unknown>;
719
854
  current.log.append('script', typeof k === 'string' ? k : 'trace', { slot: player.slot, ...facts },
720
855
  typeof s === 'number' && Number.isFinite(s) ? { simT: s / TRACE_STEPS_PER_SECOND, tick: s } : undefined);
856
+ if (typeof s === 'number' && Number.isFinite(s)) step = s;
721
857
  if (k === 'error') pageError(current, player, facts);
722
858
  }
859
+ // (the viewed player's newest traced step: the run's clock while the game runs, and only while this run is the one
860
+ // running, since a read in flight when Stop or Restart ended it lands after)
861
+ if (step !== null && !current.stopped && player === answering(current)) setGameTraceStep(step, TRACE_STEPS_PER_SECOND);
723
862
  }
724
863
  }
725
864
 
726
- /** The keys of the view bar, while the editor holds the keyboard: 1 to 4 a player, Tab the next view, Escape Edit. */
865
+ /** 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
866
+ * the next view. */
727
867
  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;
868
+ if (!run || !run.stage.keys() || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return;
731
869
  const target = event.target as HTMLElement | null;
732
870
  if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
733
871
  const views = stdViews();
734
872
  const next: StdView | undefined = /^Digit[1-4]$/.test(event.code) ? Number(event.code.slice(5)) - 1
735
873
  : 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;
874
+ : undefined;
737
875
  if (next === undefined || (typeof next === 'number' && !run.players[next])) return;
738
876
  event.preventDefault();
739
877
  event.stopPropagation();
@@ -741,8 +879,7 @@ function viewKey(event: KeyboardEvent): void {
741
879
  }
742
880
 
743
881
  /** 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;
882
+ export function runStdGame(stage: GameStage, report: (title: string, failure: string) => void, before: Promise<void>): () => void {
746
883
  const host = document.createElement('div');
747
884
  host.dataset['testid'] = 'std-players';
748
885
  Object.assign(host.style, { position: 'absolute', inset: '0' });
@@ -753,21 +890,15 @@ export function runStdGame(stage: DocumentPlayStage, report: (title: string, fai
753
890
  // (the bar stands above the tiles, and its row passes the presses the bar doesn't hold to the tile beneath)
754
891
  Object.assign(bar.style, { position: 'relative', zIndex: '1', pointerEvents: 'none' });
755
892
  stage.container.appendChild(host);
756
- (stage.overlay ?? stage.container).append(tiles, bar);
893
+ stage.overlay.append(tiles, bar);
757
894
  const roots: Root[] = [createRoot(bar), createRoot(tiles)];
758
895
  roots[0]!.render(createElement(StdViewBar));
759
896
  roots[1]!.render(createElement(StdGridTiles));
760
- const log = beginModelPlayLog(documentId, STD_DEV);
897
+ const log = beginPlayLog(STD_DEV);
761
898
  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);
899
+ const mine: LiveRun = run = { players: [], view: 0, goal: game?.goal ?? null, goals: knownGoals, url: null, tag, host, stage,
900
+ log, adding: Promise.resolve(), failure: null, stopped: false, lastError: null, errorsSaid: new Set(), played: null,
901
+ headlessTraced: new Map(), serverOutput: null };
771
902
  window.addEventListener('keydown', viewKey, true);
772
903
  // (each look after the last has answered, so no two read one trace from the same byte)
773
904
  let looking: ReturnType<typeof setTimeout> | null = null;
@@ -782,8 +913,8 @@ export function runStdGame(stage: DocumentPlayStage, report: (title: string, fai
782
913
  // (the run's mark, before the dev server starts: one started now is read from its first line, one running already
783
914
  // from where its output stands)
784
915
  // (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 };
916
+ const mark = await askPlay<ServerOutput>('/output').catch(() => null);
917
+ if (mark !== null) mine.serverOutput = { server: mark.server, stdout: mark.stdout.next, stderr: mark.stderr.next };
787
918
  }).then(() => Promise.all([
788
919
  askPlay<{ url: string }>('/start', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }),
789
920
  // (the game's goals, read again for each run: its autopilot/ program is the author's to change)
@@ -800,34 +931,32 @@ export function runStdGame(stage: DocumentPlayStage, report: (title: string, fai
800
931
  // (the traces read from the dev server's start, whether or not a player's page joins: std's headless play's bots
801
932
  // play while the editor is not seen, and its players' pages do not)
802
933
  lookOn();
803
- for (let n = playerCounts.get(documentId) ?? FIRST_PLAYERS; n > 0; n--) join(mine);
934
+ for (let n = stdPlayersAsked(); n > 0; n--) join(mine);
804
935
  void mine.adding.then(() => {
805
936
  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)
937
+ // (a run with a player who never joined stands with its failure)
807
938
  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();
939
+ if (failure !== null) { setGameFailure(failure); return; }
940
+ setGameFailure(null);
811
941
  // (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);
942
+ const arm = takeGameArm();
813
943
  if (arm !== null)
814
- setStdAutoplay(true, 'panel', arm.goal ?? undefined)
944
+ setStdAutoplay(true, 'arm', arm.goal ?? undefined)
815
945
  .catch((error: unknown) => log.append('play', 'autoplay-refused', { message: error instanceof Error ? error.message : String(error) }));
816
946
  });
817
947
  publishRun();
818
948
  }, (error: unknown) => {
819
949
  if (mine.stopped) return;
820
950
  const failure = error instanceof Error ? error.message : String(error);
821
- setModelPlayFailure(documentId, failure);
951
+ setGameFailure(failure);
822
952
  report('The game\'s dev server could not be started', failure);
823
953
  });
824
954
  publishRun();
825
955
  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);
956
+ // (Stop, Restart, the Game view's close and a project change all end here: std's play of a goal goes with the run)
828
957
  mine.stopped = true;
958
+ endStdPlay(mine);
829
959
  if (looking !== null) clearTimeout(looking);
830
- window.removeEventListener('blur', tookOver);
831
960
  window.removeEventListener('keydown', viewKey, true);
832
961
  for (const root of roots) root.unmount();
833
962
  for (const player of mine.players) player.undrive();