@volter/editor-model-play 0.5.189 → 0.5.191
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 +185 -0
- package/contributions/model-play.command.ts +37 -0
- package/contributions/model-play.service.tsx +108 -5
- package/package.json +3 -2
- package/src/camera-transition.ts +13 -3
- package/src/model-play.ts +253 -10
- package/src/play-log.ts +233 -0
- package/src/play-materials.ts +199 -0
- package/src/play-script.ts +357 -21
package/src/model-play.ts
CHANGED
|
@@ -1,25 +1,125 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHETHER A MODEL DOCUMENT IS PLAYING (the
|
|
2
|
+
* WHETHER A MODEL DOCUMENT IS PLAYING (Play in the Game panel, `blender-game-panel.tsx`; drawn by
|
|
3
3
|
* `blender-runtime.document.tsx`). Playing, the document's area shows a detached copy of the
|
|
4
4
|
* model (`BlenderRuntimeView.detach`) that the project's play script moves
|
|
5
5
|
* (`play-script.ts`); the model, its selection and its history stand as they were, and Stop
|
|
6
6
|
* returns to them. Kept for the page's life and never stored: a reload opens the model, not a
|
|
7
7
|
* game.
|
|
8
|
+
*
|
|
9
|
+
* AND HOW ITS CLOCK RUNS. Pause, step, speed and restart are state of the RUN, so they are kept
|
|
10
|
+
* here beside "is it playing", and the runner (`play-script.ts`) reads them on every frame: the
|
|
11
|
+
* `dt` a script's `update` receives is the only time a play script is given, so holding it is
|
|
12
|
+
* what freezes the game and scaling it is what speeds the game up. A script that reads the
|
|
13
|
+
* page's own clock (`performance.now()`, `Date.now()`) instead is outside that reach.
|
|
14
|
+
*
|
|
15
|
+
* AND WHO DRIVES. Autoplay is the editor's switch, not the game's: a script only offers a bot
|
|
16
|
+
* (`play.autoplay(controller)`, `play-script.ts`), and whether that bot drives is kept here. It
|
|
17
|
+
* is off whenever a run begins — Play, Restart, Stop — and only the Game panel's toggle or its
|
|
18
|
+
* verb (`volter.model-play.autoplay`) turns it on. The one carry-over is an ARM: a person may
|
|
19
|
+
* press Autoplay while stopped (there is no bot yet — a script offers it only once it runs), and
|
|
20
|
+
* the next start turns autoplay on the moment its script has run its first update with a bot, or
|
|
21
|
+
* drops the arm if it offers none. The runner turns it off the moment a person
|
|
22
|
+
* presses a key or touches the game (`takeover`), or when the running script stops offering a
|
|
23
|
+
* bot (`script`). Its changes are announced through the clock's subscription, which the panel
|
|
24
|
+
* and the runner already hold.
|
|
8
25
|
*/
|
|
9
26
|
const listeners = new Set<() => void>();
|
|
27
|
+
const clockListeners = new Set<() => void>();
|
|
10
28
|
const playing = new Set<string>();
|
|
11
|
-
const stops = new Map<string, (escape: boolean) => void>();
|
|
29
|
+
const stops = new Map<string, { readonly stop: (escape: boolean) => void; readonly generation: number }>();
|
|
30
|
+
|
|
31
|
+
/** The speeds the transport offers, slowest first (simulation seconds per real second). */
|
|
32
|
+
export const MODEL_PLAY_SPEEDS: readonly number[] = [0.25, 0.5, 1, 2, 4];
|
|
33
|
+
/** The `dt` one Step hands the game: one nominal 60 Hz frame, whatever the speed. */
|
|
34
|
+
export const MODEL_PLAY_STEP_SECONDS = 1 / 60;
|
|
35
|
+
|
|
36
|
+
export interface ModelPlayClock {
|
|
37
|
+
readonly time: number;
|
|
38
|
+
readonly tick: number;
|
|
39
|
+
readonly paused: boolean;
|
|
40
|
+
readonly speed: number;
|
|
41
|
+
/** The run is playing but no game runs: the script failed to start, or threw (`play-script.ts`). */
|
|
42
|
+
readonly failure: string | null;
|
|
43
|
+
/** A game has started in this run and runs (`DocumentPlayClock.running`). */
|
|
44
|
+
readonly running: boolean;
|
|
45
|
+
}
|
|
46
|
+
const STILL: ModelPlayClock = { time: 0, tick: 0, paused: false, speed: 1, failure: null, running: false };
|
|
47
|
+
/** Replaced, never mutated, so a snapshot read by `useSyncExternalStore` changes identity
|
|
48
|
+
* exactly when it changes value. */
|
|
49
|
+
const clocks = new Map<string, ModelPlayClock>();
|
|
50
|
+
/** Steps asked for while paused and not yet run; the runner takes one per drawn frame. */
|
|
51
|
+
const steps = new Map<string, number>();
|
|
52
|
+
/** Bumped by Restart; the document keys its detached copy on it (`DocumentPlayTransport`). */
|
|
53
|
+
const generations = new Map<string, number>();
|
|
54
|
+
/** Documents whose next run was begun by Restart, and so enters without the camera's blend. */
|
|
55
|
+
const restarted = new Set<string>();
|
|
56
|
+
|
|
57
|
+
/** Who last switched autoplay: the panel's toggle, a verb (CLI or `eval`), a person's input
|
|
58
|
+
* in the game, or the running script no longer offering a bot (replaced, failed, or its bot
|
|
59
|
+
* threw). */
|
|
60
|
+
export type ModelPlayAutoplayBy = 'panel' | 'cli' | 'takeover' | 'script';
|
|
61
|
+
export interface ModelPlayAutoplay {
|
|
62
|
+
/** The bot drives: its keys are merged into the keys the script reads. */
|
|
63
|
+
readonly on: boolean;
|
|
64
|
+
/** The running script registered a bot with `play.autoplay`. */
|
|
65
|
+
readonly available: boolean;
|
|
66
|
+
readonly by: ModelPlayAutoplayBy | null;
|
|
67
|
+
/** Pressed while stopped: the next start turns autoplay on once its script offers a bot. */
|
|
68
|
+
readonly armed: boolean;
|
|
69
|
+
}
|
|
70
|
+
const NO_BOT: ModelPlayAutoplay = { on: false, available: false, by: null, armed: false };
|
|
71
|
+
const ARMED: ModelPlayAutoplay = { ...NO_BOT, armed: true };
|
|
72
|
+
/** Replaced, never mutated, as the clocks are. Absent is {@link NO_BOT}: a new run's state. */
|
|
73
|
+
const autoplays = new Map<string, ModelPlayAutoplay>();
|
|
74
|
+
|
|
75
|
+
/** A new run's autoplay: off, no bot yet. Play keeps an arm made while stopped; Stop drops it. */
|
|
76
|
+
function resetAutoplay(documentId: string, keepArm: boolean): void {
|
|
77
|
+
if (keepArm && modelPlayAutoplay(documentId).armed) autoplays.set(documentId, ARMED);
|
|
78
|
+
else autoplays.delete(documentId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function publish(): void {
|
|
82
|
+
for (const listener of [...listeners]) listener();
|
|
83
|
+
}
|
|
84
|
+
function publishClock(): void {
|
|
85
|
+
for (const listener of [...clockListeners]) listener();
|
|
86
|
+
}
|
|
87
|
+
function setClock(documentId: string, next: Partial<ModelPlayClock>): void {
|
|
88
|
+
clocks.set(documentId, { ...modelPlayClock(documentId), ...next });
|
|
89
|
+
publishClock();
|
|
90
|
+
}
|
|
12
91
|
|
|
13
92
|
export function modelPlaying(documentId: string): boolean {
|
|
14
93
|
return playing.has(documentId);
|
|
15
94
|
}
|
|
16
95
|
|
|
17
96
|
export function setModelPlaying(documentId: string, value: boolean): void {
|
|
18
|
-
if (playing.has(documentId) === value)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
97
|
+
if (playing.has(documentId) === value) {
|
|
98
|
+
// Stop with nothing playing is how a closing document lets go of its play: an arm made while
|
|
99
|
+
// stopped goes with it, or reopening the model and pressing Play would still take it.
|
|
100
|
+
if (!value && modelPlayAutoplay(documentId).armed) { resetAutoplay(documentId, false); publishClock(); }
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const stop = value ? null : currentStop(documentId);
|
|
104
|
+
if (stop) { stop(false); return; }
|
|
105
|
+
if (value) {
|
|
106
|
+
playing.add(documentId);
|
|
107
|
+
// A new run starts its clock at zero and running; the speed is the person's and stays.
|
|
108
|
+
steps.delete(documentId);
|
|
109
|
+
resetAutoplay(documentId, true);
|
|
110
|
+
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
111
|
+
} else {
|
|
112
|
+
playing.delete(documentId);
|
|
113
|
+
restarted.delete(documentId);
|
|
114
|
+
steps.delete(documentId);
|
|
115
|
+
resetAutoplay(documentId, false);
|
|
116
|
+
// The clock keeps the stopped run's time and tick, so the panel still says how far it got.
|
|
117
|
+
// Re-issued even unchanged, after `playing` has changed: every way a game stops (Stop,
|
|
118
|
+
// Escape, a deleted script, a mode switch, an agent's `stop`) ends here, and a reader of
|
|
119
|
+
// the clock alone must see the run end too.
|
|
120
|
+
setClock(documentId, { paused: false, failure: null, running: false });
|
|
121
|
+
}
|
|
122
|
+
publish();
|
|
23
123
|
}
|
|
24
124
|
|
|
25
125
|
export function finishModelPlay(documentId: string): void {
|
|
@@ -28,14 +128,36 @@ export function finishModelPlay(documentId: string): void {
|
|
|
28
128
|
}
|
|
29
129
|
|
|
30
130
|
export function escapeModelPlay(documentId: string): void {
|
|
31
|
-
const stop =
|
|
131
|
+
const stop = currentStop(documentId);
|
|
32
132
|
if (stop) stop(true);
|
|
33
133
|
else finishModelPlay(documentId);
|
|
34
134
|
}
|
|
35
135
|
|
|
136
|
+
/**
|
|
137
|
+
* A RUNNER'S STOP, BOUND TO ITS GENERATION. Stop and Escape go to the runner of the CURRENT
|
|
138
|
+
* generation, which blends the camera back first; a stop a Restart has superseded is never
|
|
139
|
+
* asked, because that runner stands still from the moment its generation passes
|
|
140
|
+
* (`play-script.ts`) and would never finish the blend — the stop would be lost. With no current
|
|
141
|
+
* runner (a restarted game still preparing) the play simply ends.
|
|
142
|
+
*/
|
|
36
143
|
export function registerModelPlayStop(documentId: string, stop: (escape: boolean) => void): () => void {
|
|
37
|
-
|
|
38
|
-
|
|
144
|
+
const entry = { stop, generation: modelPlayGeneration(documentId) };
|
|
145
|
+
stops.set(documentId, entry);
|
|
146
|
+
return () => { if (stops.get(documentId) === entry) stops.delete(documentId); };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function currentStop(documentId: string): ((escape: boolean) => void) | null {
|
|
150
|
+
const entry = stops.get(documentId);
|
|
151
|
+
return entry !== undefined && entry.generation === modelPlayGeneration(documentId) ? entry.stop : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The runner's report that no game runs although the run plays (`null` once one does, which
|
|
155
|
+
* is also the moment the run is `running`). */
|
|
156
|
+
export function setModelPlayFailure(documentId: string, failure: string | null): void {
|
|
157
|
+
const clock = modelPlayClock(documentId);
|
|
158
|
+
const running = failure === null;
|
|
159
|
+
if (!playing.has(documentId) || (clock.failure === failure && clock.running === running)) return;
|
|
160
|
+
setClock(documentId, { failure, running });
|
|
39
161
|
}
|
|
40
162
|
|
|
41
163
|
export function subscribeModelPlay(listener: () => void): () => void {
|
|
@@ -44,3 +166,124 @@ export function subscribeModelPlay(listener: () => void): () => void {
|
|
|
44
166
|
listeners.delete(listener);
|
|
45
167
|
};
|
|
46
168
|
}
|
|
169
|
+
|
|
170
|
+
export function modelPlayClock(documentId: string): ModelPlayClock {
|
|
171
|
+
return clocks.get(documentId) ?? STILL;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function subscribeModelPlayClock(listener: () => void): () => void {
|
|
175
|
+
clockListeners.add(listener);
|
|
176
|
+
return () => {
|
|
177
|
+
clockListeners.delete(listener);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Hold or release the game's updates. Only a playing document has updates to hold. */
|
|
182
|
+
export function setModelPlayPaused(documentId: string, paused: boolean): void {
|
|
183
|
+
if (!playing.has(documentId) || modelPlayClock(documentId).paused === paused) return;
|
|
184
|
+
if (!paused) steps.delete(documentId);
|
|
185
|
+
setClock(documentId, { paused });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One update while paused. Steps asked for faster than frames are drawn queue, one per frame,
|
|
189
|
+
* so each one is seen. */
|
|
190
|
+
export function stepModelPlay(documentId: string): void {
|
|
191
|
+
if (!playing.has(documentId) || !modelPlayClock(documentId).paused) return;
|
|
192
|
+
steps.set(documentId, (steps.get(documentId) ?? 0) + 1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The runner's half of {@link stepModelPlay}: whether this frame runs a step. */
|
|
196
|
+
export function takeModelPlayStep(documentId: string): boolean {
|
|
197
|
+
const pending = steps.get(documentId) ?? 0;
|
|
198
|
+
if (pending === 0) return false;
|
|
199
|
+
if (pending === 1) steps.delete(documentId);
|
|
200
|
+
else steps.set(documentId, pending - 1);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function setModelPlaySpeed(documentId: string, speed: number): void {
|
|
205
|
+
if (!MODEL_PLAY_SPEEDS.includes(speed))
|
|
206
|
+
throw new Error(`Play speed ${speed} is not one the transport offers: ${MODEL_PLAY_SPEEDS.join(', ')}.`);
|
|
207
|
+
if (modelPlayClock(documentId).speed !== speed) setClock(documentId, { speed });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The runner's report of the updates it ran this frame. */
|
|
211
|
+
export function advanceModelPlayClock(documentId: string, seconds: number, ticks: number): void {
|
|
212
|
+
if (ticks === 0) return;
|
|
213
|
+
const clock = modelPlayClock(documentId);
|
|
214
|
+
setClock(documentId, { time: clock.time + seconds, tick: clock.tick + ticks });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function modelPlayGeneration(documentId: string): number {
|
|
218
|
+
return generations.get(documentId) ?? 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* BEGIN THE RUN AGAIN ON A FRESH COPY. Playing stays true throughout — nothing returns to the
|
|
223
|
+
* model — and the generation moves, which is what makes the document detach a new copy and mount
|
|
224
|
+
* a new runner over it; the old runner sees it is no longer the current generation and stands
|
|
225
|
+
* down without ending the play. A document that is not playing simply starts.
|
|
226
|
+
*/
|
|
227
|
+
export function restartModelPlay(documentId: string): void {
|
|
228
|
+
if (!playing.has(documentId)) { setModelPlaying(documentId, true); return; }
|
|
229
|
+
generations.set(documentId, modelPlayGeneration(documentId) + 1);
|
|
230
|
+
restarted.add(documentId);
|
|
231
|
+
steps.delete(documentId);
|
|
232
|
+
// An arm not yet taken (the game was still starting) carries to the fresh run.
|
|
233
|
+
resetAutoplay(documentId, true);
|
|
234
|
+
setClock(documentId, { time: 0, tick: 0, paused: false, failure: null, running: false });
|
|
235
|
+
publish();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether the run starting now was begun by Restart (asked once, by that run's runner). */
|
|
239
|
+
export function consumeModelPlayRestart(documentId: string): boolean {
|
|
240
|
+
return restarted.delete(documentId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function modelPlayAutoplay(documentId: string): ModelPlayAutoplay {
|
|
244
|
+
return autoplays.get(documentId) ?? NO_BOT;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function setAutoplay(documentId: string, next: Partial<ModelPlayAutoplay>): void {
|
|
248
|
+
autoplays.set(documentId, { ...modelPlayAutoplay(documentId), ...next });
|
|
249
|
+
publishClock();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Switch the running script's bot on or off. On asks for a playing document whose script
|
|
253
|
+
* registered a bot; off always succeeds, and also drops an arm not yet taken — a person who
|
|
254
|
+
* takes over while the game is still starting is driving, and the arm must not override them. */
|
|
255
|
+
export function setModelPlayAutoplay(documentId: string, on: boolean, by: ModelPlayAutoplayBy): void {
|
|
256
|
+
const now = modelPlayAutoplay(documentId);
|
|
257
|
+
if (on && !playing.has(documentId))
|
|
258
|
+
throw new Error(`Autoplay is available once the game is running, and nothing is playing in ${documentId}; \`play\` starts it, then \`play autoplay on\`.`);
|
|
259
|
+
const clock = modelPlayClock(documentId);
|
|
260
|
+
if (on && !now.available)
|
|
261
|
+
throw new Error(clock.running
|
|
262
|
+
? 'No autoplay: this game doesn’t provide a bot — its play script registers none with `play.autoplay(controller)`.'
|
|
263
|
+
: `Autoplay is available once the game is running, and it is not running yet${clock.failure ? ` (${clock.failure})` : ''}.`);
|
|
264
|
+
if (on ? !now.on : now.on || now.armed) setAutoplay(documentId, on ? { on, by } : { on, by, armed: false });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Arm (or disarm) autoplay for the next start, while stopped: the Game panel's Autoplay button
|
|
268
|
+
* before Play. Disarming always succeeds. */
|
|
269
|
+
export function armModelPlayAutoplay(documentId: string, armed: boolean): void {
|
|
270
|
+
if (armed && playing.has(documentId))
|
|
271
|
+
throw new Error(`${documentId} is already playing; switch autoplay on instead of arming it.`);
|
|
272
|
+
if (modelPlayAutoplay(documentId).armed !== armed) setAutoplay(documentId, { armed });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The runner's report that a script has run its first update, offering a bot or not. An arm is
|
|
276
|
+
* taken here: on with a bot, dropped without one. */
|
|
277
|
+
export function settleModelPlayAutoplay(documentId: string, offered: boolean): void {
|
|
278
|
+
const now = modelPlayAutoplay(documentId);
|
|
279
|
+
if (!playing.has(documentId)) return;
|
|
280
|
+
if (!now.armed) { setModelPlayAutoplayAvailable(documentId, offered); return; }
|
|
281
|
+
setAutoplay(documentId, offered ? { available: true, armed: false, on: true, by: 'panel' } : { available: false, armed: false, by: 'script' });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The runner's report of whether the running script offers a bot. Losing it turns autoplay off. */
|
|
285
|
+
export function setModelPlayAutoplayAvailable(documentId: string, available: boolean): void {
|
|
286
|
+
const now = modelPlayAutoplay(documentId);
|
|
287
|
+
if (now.available === available || !playing.has(documentId)) return;
|
|
288
|
+
setAutoplay(documentId, available || !now.on ? { available } : { available, on: false, by: 'script' });
|
|
289
|
+
}
|
package/src/play-log.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE MODEL PLAY LOG — what a play script says happened, stamped with the run's own clock.
|
|
3
|
+
*
|
|
4
|
+
* The game editor's play log is the receipt of a run (`play-*.jsonl`, `log-format.ts`); this
|
|
5
|
+
* is Model Play's. A script calls `play.log(kind, facts)` (`play-script.ts`) on the
|
|
6
|
+
* transitions that explain a run — a jump, a landing, a death and its cause, autoplay's
|
|
7
|
+
* choice — and an agent reads them back with `cyclotron play-log` or
|
|
8
|
+
* `editor.modelPlayLog()` in `eval`. `console.log` cannot do this job: the session's
|
|
9
|
+
* console feed keeps warnings and errors only.
|
|
10
|
+
*
|
|
11
|
+
* Every entry carries `simT` (the seconds of simulation since Play started: the sum of the
|
|
12
|
+
* `dt`s the runner has handed to `update`) and `tick` (the number of the update in
|
|
13
|
+
* progress; 0 while the script's default export runs), the same two stamps the game
|
|
14
|
+
* editor's entries carry. They are the Game panel's clock too: the runner advances both at
|
|
15
|
+
* the same call (`play-script.ts`), so they stand still while paused and run at the speed. The
|
|
16
|
+
* runner adds its own lifecycle entries (`source: 'play'`): `play-start`, `play-restart` (a
|
|
17
|
+
* Restart's fresh run, right after its `play-start`), `script-reload` with its reason,
|
|
18
|
+
* `script-error`, `tint-unsupported`, `tint-unknown-object`, `pause`, `resume`, `step` (one per
|
|
19
|
+
* stepped update, with its `dt`), `speed` (on a change, and at a start that is not 1×),
|
|
20
|
+
* `autoplay-on` and `autoplay-off` (each with `by`: `panel`, `cli`, `takeover`, `script`),
|
|
21
|
+
* `autoplay-unavailable` (a script ran its first update without offering a bot),
|
|
22
|
+
* `play-stop`.
|
|
23
|
+
*
|
|
24
|
+
* ONE LOG PER MODEL DOCUMENT, each its document's latest run. Two documents playing at once
|
|
25
|
+
* keep separate logs; a read names its document or takes the active Play's (the latest
|
|
26
|
+
* started that still plays, else the latest started). Play starting from a fresh copy
|
|
27
|
+
* replaces its document's log; a script reload does not, and the log outlives Stop so a
|
|
28
|
+
* finished run can be read. The runner writes through the {@link ModelPlayRun} handle Play
|
|
29
|
+
* started: once that run has ended its handle writes nothing, so a stale timer of an old
|
|
30
|
+
* script cannot reach the next run's log.
|
|
31
|
+
*
|
|
32
|
+
* BOUNDED AND PAGE-LIFETIME. The newest {@link MODEL_PLAY_LOG_CAPACITY} entries of a run are
|
|
33
|
+
* kept in a ring (older ones are counted in `dropped`); one entry's facts are kept as JSON of
|
|
34
|
+
* at most {@link MODEL_PLAY_LOG_FACTS_CHARS} characters. It is never stored: a reload of the
|
|
35
|
+
* page forgets it, as it forgets the game.
|
|
36
|
+
*
|
|
37
|
+
* THE GAME PANEL DRAWS IT LIVE: {@link subscribeModelPlayLog} hears every write, and
|
|
38
|
+
* {@link tailModelPlayLog} reads the newest entries of one document without copying the ring.
|
|
39
|
+
*
|
|
40
|
+
* Logging is a read of the script's own values: it never throws into the script and touches
|
|
41
|
+
* neither the clock nor the copy.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Entries a run's ring keeps; the oldest beyond this are dropped and counted. */
|
|
45
|
+
export const MODEL_PLAY_LOG_CAPACITY = 5000;
|
|
46
|
+
/** The longest JSON one entry's facts keep; longer facts are kept truncated, as a string. */
|
|
47
|
+
export const MODEL_PLAY_LOG_FACTS_CHARS = 2048;
|
|
48
|
+
|
|
49
|
+
export interface ModelPlayLogEntry {
|
|
50
|
+
/** The entry's place in this run, from 0: a gap in `seq` is entries the ring dropped. */
|
|
51
|
+
readonly seq: number;
|
|
52
|
+
/** Wall-clock ms. */
|
|
53
|
+
readonly t: number;
|
|
54
|
+
/** Seconds of simulation since Play started, across script reloads. */
|
|
55
|
+
readonly simT: number;
|
|
56
|
+
/** The update in progress (the first is 1); 0 before the first. */
|
|
57
|
+
readonly tick: number;
|
|
58
|
+
readonly kind: string;
|
|
59
|
+
/** `script` for the play script's own entries; `play` for the runner's lifecycle ones. */
|
|
60
|
+
readonly source: 'script' | 'play';
|
|
61
|
+
readonly facts?: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ModelPlayLogReading {
|
|
65
|
+
/** The run is still playing; false after Stop, or when nothing has played. */
|
|
66
|
+
readonly playing: boolean;
|
|
67
|
+
/** The model document and play script of the run read; null when nothing has played. */
|
|
68
|
+
readonly documentId: string | null;
|
|
69
|
+
readonly script: string | null;
|
|
70
|
+
readonly startedAt: number | null;
|
|
71
|
+
/** Every model document with a log, so a read can name another. */
|
|
72
|
+
readonly documents: readonly string[];
|
|
73
|
+
/** The run's clock now. */
|
|
74
|
+
readonly simT: number;
|
|
75
|
+
readonly tick: number;
|
|
76
|
+
readonly capacity: number;
|
|
77
|
+
/** Entries this run has written, and how many of those the ring has dropped. */
|
|
78
|
+
readonly total: number;
|
|
79
|
+
readonly dropped: number;
|
|
80
|
+
/** The kept entries that match the read's filters, oldest first. */
|
|
81
|
+
readonly entries: readonly ModelPlayLogEntry[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ModelPlayLogQuery {
|
|
85
|
+
/** The model document whose log to read; the active Play's when omitted. */
|
|
86
|
+
readonly documentId?: string;
|
|
87
|
+
/** Only entries at or after this simulation time (seconds). */
|
|
88
|
+
readonly since?: number;
|
|
89
|
+
/** Only entries of this kind. */
|
|
90
|
+
readonly kind?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Run {
|
|
94
|
+
readonly documentId: string;
|
|
95
|
+
readonly script: string;
|
|
96
|
+
readonly startedAt: number;
|
|
97
|
+
playing: boolean;
|
|
98
|
+
simT: number;
|
|
99
|
+
tick: number;
|
|
100
|
+
readonly ring: ModelPlayLogEntry[];
|
|
101
|
+
total: number;
|
|
102
|
+
/** Every kind this run has written, for the panel's filter. */
|
|
103
|
+
readonly kinds: Set<string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The runner's door onto its own run's log: inert once the run has ended. */
|
|
107
|
+
export interface ModelPlayRun {
|
|
108
|
+
/** The runner is about to call `update(deltaSeconds)`. */
|
|
109
|
+
advance(deltaSeconds: number): void;
|
|
110
|
+
append(source: ModelPlayLogEntry['source'], kind: string, facts?: unknown): void;
|
|
111
|
+
/** `play-stop`, then nothing more is written; the entries stay readable. */
|
|
112
|
+
end(facts?: Record<string, unknown>): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// One registry per page, whichever copy of this module a contribution loaded it through: the
|
|
116
|
+
// runner writes it from the service, the session verb reads it from the command module.
|
|
117
|
+
const key = Symbol.for('volter.model-play-log');
|
|
118
|
+
const page = globalThis as typeof globalThis & { [key]?: Map<string, Run> };
|
|
119
|
+
const runs: Map<string, Run> = page[key] ??= new Map();
|
|
120
|
+
const listenersKey = Symbol.for('volter.model-play-log.listeners');
|
|
121
|
+
const pageListeners = globalThis as typeof globalThis & { [listenersKey]?: Set<() => void> };
|
|
122
|
+
const listeners: Set<() => void> = pageListeners[listenersKey] ??= new Set();
|
|
123
|
+
|
|
124
|
+
function publish(): void {
|
|
125
|
+
for (const listener of [...listeners]) {
|
|
126
|
+
try { listener(); } catch { /* A reader's failure is not the writer's. */ }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Hear every write to any document's log (several may land in one frame; coalesce). */
|
|
131
|
+
export function subscribeModelPlayLog(listener: () => void): () => void {
|
|
132
|
+
listeners.add(listener);
|
|
133
|
+
return () => { listeners.delete(listener); };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function keptFacts(facts: unknown): Record<string, unknown> | undefined {
|
|
137
|
+
if (facts === undefined || facts === null) return undefined;
|
|
138
|
+
let json: string | undefined;
|
|
139
|
+
try { json = JSON.stringify(facts); }
|
|
140
|
+
catch (error) { return { unserializable: error instanceof Error ? error.message : String(error) }; }
|
|
141
|
+
if (json === undefined) return undefined;
|
|
142
|
+
if (json.length > MODEL_PLAY_LOG_FACTS_CHARS) return { truncated: json.slice(0, MODEL_PLAY_LOG_FACTS_CHARS) };
|
|
143
|
+
// A snapshot, so a script that goes on mutating what it logged does not rewrite history.
|
|
144
|
+
const value: unknown = JSON.parse(json);
|
|
145
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : { value };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function append(run: Run, source: ModelPlayLogEntry['source'], kind: string, facts?: unknown): void {
|
|
149
|
+
try {
|
|
150
|
+
const kept = keptFacts(facts);
|
|
151
|
+
const entry: ModelPlayLogEntry = {
|
|
152
|
+
seq: run.total, t: Date.now(), simT: run.simT, tick: run.tick, kind: String(kind), source,
|
|
153
|
+
...(kept ? { facts: kept } : {}),
|
|
154
|
+
};
|
|
155
|
+
run.ring[run.total % MODEL_PLAY_LOG_CAPACITY] = entry;
|
|
156
|
+
run.total += 1;
|
|
157
|
+
run.kinds.add(entry.kind);
|
|
158
|
+
} catch { /* A log that cannot be written is not the script's failure. */ }
|
|
159
|
+
publish();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Play started from a fresh copy: the document's log replaced by an empty one at zero,
|
|
163
|
+
* opened by `play-start`. */
|
|
164
|
+
export function beginModelPlayLog(documentId: string, script: string): ModelPlayRun {
|
|
165
|
+
const run: Run = { documentId, script, startedAt: Date.now(), playing: true, simT: 0, tick: 0, ring: [], total: 0, kinds: new Set() };
|
|
166
|
+
// Re-inserted, so the map's order is the order Plays started.
|
|
167
|
+
runs.delete(documentId);
|
|
168
|
+
runs.set(documentId, run);
|
|
169
|
+
append(run, 'play', 'play-start', { documentId, script });
|
|
170
|
+
return {
|
|
171
|
+
advance(deltaSeconds) {
|
|
172
|
+
if (!run.playing) return;
|
|
173
|
+
run.tick += 1;
|
|
174
|
+
run.simT += deltaSeconds;
|
|
175
|
+
},
|
|
176
|
+
append(source, kind, facts) { if (run.playing) append(run, source, kind, facts); },
|
|
177
|
+
end(facts) {
|
|
178
|
+
if (!run.playing) return;
|
|
179
|
+
append(run, 'play', 'play-stop', facts);
|
|
180
|
+
run.playing = false;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function activeRun(): Run | undefined {
|
|
186
|
+
const started = [...runs.values()].reverse();
|
|
187
|
+
return started.find((run) => run.playing) ?? started[0];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function readModelPlayLog(query: ModelPlayLogQuery = {}): ModelPlayLogReading {
|
|
191
|
+
const run = query.documentId === undefined ? activeRun() : runs.get(query.documentId);
|
|
192
|
+
const documents = [...runs.keys()];
|
|
193
|
+
if (!run) {
|
|
194
|
+
return {
|
|
195
|
+
playing: false, documentId: query.documentId ?? null, script: null, startedAt: null, documents,
|
|
196
|
+
simT: 0, tick: 0, capacity: MODEL_PLAY_LOG_CAPACITY, total: 0, dropped: 0, entries: [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const dropped = Math.max(0, run.total - MODEL_PLAY_LOG_CAPACITY);
|
|
200
|
+
const entries: ModelPlayLogEntry[] = [];
|
|
201
|
+
for (let seq = dropped; seq < run.total; seq++) {
|
|
202
|
+
const entry = run.ring[seq % MODEL_PLAY_LOG_CAPACITY]!;
|
|
203
|
+
if (query.since !== undefined && entry.simT < query.since) continue;
|
|
204
|
+
if (query.kind !== undefined && entry.kind !== query.kind) continue;
|
|
205
|
+
entries.push(entry);
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
playing: run.playing, documentId: run.documentId, script: run.script, startedAt: run.startedAt, documents,
|
|
209
|
+
simT: run.simT, tick: run.tick, capacity: MODEL_PLAY_LOG_CAPACITY, total: run.total, dropped, entries,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ModelPlayLogTail {
|
|
214
|
+
/** Entries the document's latest run has written; 0 when it has no log. */
|
|
215
|
+
readonly total: number;
|
|
216
|
+
/** Every kind that run has written, in the order first seen. */
|
|
217
|
+
readonly kinds: readonly string[];
|
|
218
|
+
/** The newest kept entries (of the asked kind), at most `last`, oldest first. */
|
|
219
|
+
readonly entries: readonly ModelPlayLogEntry[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The newest `last` entries of one document's log, walked from the newest end. */
|
|
223
|
+
export function tailModelPlayLog(documentId: string, last: number, kind?: string): ModelPlayLogTail {
|
|
224
|
+
const run = runs.get(documentId);
|
|
225
|
+
if (!run) return { total: 0, kinds: [], entries: [] };
|
|
226
|
+
const oldest = Math.max(0, run.total - MODEL_PLAY_LOG_CAPACITY);
|
|
227
|
+
const entries: ModelPlayLogEntry[] = [];
|
|
228
|
+
for (let seq = run.total - 1; seq >= oldest && entries.length < last; seq--) {
|
|
229
|
+
const entry = run.ring[seq % MODEL_PLAY_LOG_CAPACITY]!;
|
|
230
|
+
if (kind === undefined || entry.kind === kind) entries.push(entry);
|
|
231
|
+
}
|
|
232
|
+
return { total: run.total, kinds: [...run.kinds], entries: entries.reverse() };
|
|
233
|
+
}
|