@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/README.md +82 -51
- package/contributions/game-panel.tsx +228 -329
- package/contributions/levels.finder.ts +43 -0
- package/contributions/play.page.tsx +56 -174
- package/contributions/play.verbs.ts +74 -0
- package/contributions/{std-scene.document.tsx → std-level.document.tsx} +154 -22
- package/dist-node/serving.mjs +81 -39
- package/package.json +76 -54
- package/serving/index.ts +1 -1
- package/serving/std-dev.ts +86 -53
- package/serving/std-scene-runtime.js +128 -7
- package/serving/std-scene.ts +67 -24
- package/src/editor-blender.ts +4 -19
- package/src/editor-threejs.ts +1 -1
- package/src/game-controls.ts +111 -0
- package/src/game-run.ts +165 -0
- package/src/game-view.tsx +219 -0
- package/src/play-clock.ts +4 -4
- package/src/play-log.ts +57 -73
- package/src/std-controls.ts +7 -7
- package/src/std-export.ts +0 -8
- package/src/std-game-adapter.ts +10 -4
- package/src/std-game.ts +320 -191
- package/src/std-level-files.ts +18 -1
- package/src/std-level.ts +4 -4
- package/src/std-view-bar.tsx +7 -17
- package/contributions/model-play.verbs.ts +0 -44
- package/src/model-play.ts +0 -149
- package/src/std-scene-level.ts +0 -59
package/src/std-game.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A STD GAME, PLAYED IN THE
|
|
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`)
|
|
21
|
-
* on
|
|
22
|
-
* open Player N
|
|
23
|
-
* it, and its keys are the game's; a press on the view bar gives it back to the editor. A
|
|
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
|
-
*
|
|
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 (`
|
|
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 {
|
|
50
|
-
import {
|
|
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
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
100
|
-
*
|
|
101
|
-
|
|
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>;
|
|
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
|
|
153
|
-
* the run's mark on (where
|
|
154
|
-
|
|
155
|
-
readonly tag: string; readonly host: HTMLElement; readonly stage:
|
|
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
|
|
170
|
-
export function stdRun(
|
|
171
|
-
return run && !run.stopped
|
|
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 =
|
|
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.
|
|
186
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
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` (
|
|
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):
|
|
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
|
|
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.
|
|
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:
|
|
301
|
+
/** The views in their Tab order: Scene, each player, Grid. */
|
|
252
302
|
function stdViews(): StdView[] {
|
|
253
|
-
return run ? ['
|
|
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[];
|
|
269
|
-
readonly
|
|
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
|
|
273
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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 (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
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
|
-
/**
|
|
318
|
-
let
|
|
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
|
-
/**
|
|
321
|
-
function
|
|
322
|
-
if (
|
|
323
|
-
const { run: started, abort } =
|
|
324
|
-
|
|
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 (
|
|
327
|
-
started.
|
|
328
|
-
started.log.append('play', 'autoplay-end', { goal: started.
|
|
329
|
-
|
|
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
|
|
339
|
-
*
|
|
340
|
-
*
|
|
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
|
|
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
|
|
347
|
-
|
|
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
|
-
|
|
350
|
-
current.
|
|
351
|
-
current.log.append('play', 'autoplay-on', { goal, device
|
|
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 &&
|
|
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
|
-
|
|
458
|
+
stdPlay = null;
|
|
376
459
|
const why = ok ? null : failures[0] ?? lastLine ?? 'std\'s play ended failed and said nothing';
|
|
377
|
-
current.
|
|
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
|
-
|
|
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
|
|
428
|
-
*
|
|
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(
|
|
431
|
-
const current = run && !run.stopped
|
|
432
|
-
if (!current) throw new Error(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
444
|
-
return clockAll(
|
|
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(
|
|
449
|
-
return clockAll(
|
|
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(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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 } =
|
|
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
|
-
/**
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
if (
|
|
615
|
-
|
|
616
|
-
current.
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
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
|
|
679
|
-
|
|
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.
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
current.
|
|
685
|
-
if (read
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 = {
|
|
763
|
-
log, adding: Promise.resolve(), failure: null, stopped: false, lastError: null, errorsSaid: new Set(),
|
|
764
|
-
headlessTraced: new Map(),
|
|
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<
|
|
786
|
-
if (mark !== null) mine.
|
|
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 =
|
|
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
|
|
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) {
|
|
809
|
-
|
|
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 =
|
|
942
|
+
const arm = takeGameArm();
|
|
813
943
|
if (arm !== null)
|
|
814
|
-
setStdAutoplay(true, '
|
|
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
|
-
|
|
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
|
|
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();
|