@volter/play 0.0.0-stage → 0.5.204

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * RECOLOURING THE DETACHED COPY — `play.tint` and `play.setOpacity` (`play-script.ts`).
3
+ *
4
+ * Changing a presented mesh's colour the three.js way does not work on the copy, for reasons
5
+ * the presenter has and a script cannot see:
6
+ *
7
+ * - A Blender mesh's `material` is an ARRAY, one entry per material slot (a lone material only
8
+ * for an object with no slots, which wears the presenter's grey fallback), so
9
+ * `mesh.material.color` is `undefined`.
10
+ * - The entries are SHARED: one material per Blender material, worn by every object with it
11
+ * in a slot. Recolouring one in place recolours them all.
12
+ * - The presenter OWNS `mesh.material`: whenever it re-applies shading it assigns every mesh's
13
+ * slots again from its own table, so materials a script assigned are put back.
14
+ * - `clone()` drops the presenter's draw hooks (its shader, private uniforms and node graph).
15
+ * - A material whose Base Color or Alpha a node graph drives never reads `color` or `opacity`:
16
+ * the graph's output replaces them in the shader.
17
+ *
18
+ * So an override is per OBJECT: the object's slots are swapped for copies of their own (the
19
+ * document's `ownMaterial`, which keeps its hooks and draws the material's constants, its
20
+ * image texture included), the copies carry the tint and opacity, and the slots the presenter
21
+ * gave are remembered. A slot the document cannot copy (no `ownMaterial`, or a material that
22
+ * is not the document's) leaves the object as it is and says so through `unsupported`; a
23
+ * `clone()` would draw without the presenter's hooks.
24
+ *
25
+ * The presenter's slots are re-read whenever they may have moved: an object whose slots the
26
+ * presenter has put back is given copies of the slots it now has, both each frame and before
27
+ * any change, so clearing returns the presenter's current slots, never a stale set. An object
28
+ * the script removed from the copy is let go on the next frame. That is heard, not searched
29
+ * for: each overridden mesh and its parents up to the copy's root report their own removal
30
+ * (three's `removed` event, which `remove`, `add` elsewhere and `attach` all dispatch), and only
31
+ * a mesh one of them reported is walked up to see whether it is still in the copy.
32
+ */
33
+ import type * as THREE from 'three';
34
+
35
+ type Slots = THREE.Material | THREE.Material[];
36
+ type Colored = THREE.Material & { color?: THREE.Color; emissive?: THREE.Color };
37
+
38
+ interface Override {
39
+ readonly mesh: THREE.Mesh;
40
+ authored: Slots;
41
+ shown: Slots;
42
+ copies: Colored[];
43
+ color: THREE.ColorRepresentation | null;
44
+ opacity: number | null;
45
+ /** The mesh and its parents below the copy's root, each told to report its removal. */
46
+ watched: THREE.Object3D[];
47
+ /** One of them was removed from its parent since the last frame. */
48
+ moved: boolean;
49
+ readonly onRemoved: () => void;
50
+ }
51
+
52
+ const slotsOf = (slots: Slots): THREE.Material[] => Array.isArray(slots) ? slots : [slots];
53
+ const isColor = (value: unknown): value is THREE.Color =>
54
+ typeof value === 'object' && value !== null && (value as THREE.Color).isColor === true;
55
+
56
+ export interface MaterialOverrides {
57
+ tint(target: THREE.Object3D, color: THREE.ColorRepresentation | null): void;
58
+ setOpacity(target: THREE.Object3D, opacity: number | null): void;
59
+ /** After the script's update: let go of removed objects, and re-wear the copies on any
60
+ * object the presenter re-dressed. */
61
+ frame(): void;
62
+ /** Return every object the presenter's slots and release the copies; later calls do nothing. */
63
+ dispose(): void;
64
+ }
65
+
66
+ /**
67
+ * `ownMaterial` answers the document's copy of one of its materials, or null for one that is
68
+ * not the document's. `unsupported` hears of an object that could not be overridden.
69
+ */
70
+ export function materialOverrides(options: {
71
+ readonly root: THREE.Object3D;
72
+ readonly ownMaterial: ((material: THREE.Material) => THREE.Material | null) | undefined;
73
+ readonly unsupported: (mesh: THREE.Mesh, material: THREE.Material) => void;
74
+ }): MaterialOverrides {
75
+ const { root, ownMaterial } = options;
76
+ const overrides = new Map<THREE.Mesh, Override>();
77
+ let disposed = false;
78
+ const release = (override: Override): void => {
79
+ for (const copy of override.copies) copy.dispose();
80
+ override.copies = [];
81
+ };
82
+ const unwatch = (override: Override): void => {
83
+ for (const object of override.watched) object.removeEventListener('removed', override.onRemoved);
84
+ override.watched = [];
85
+ };
86
+ /** Hear the mesh or a parent leave; a mesh not under the copy's root is let go next frame. */
87
+ const watch = (override: Override): void => {
88
+ unwatch(override);
89
+ let at: THREE.Object3D | null = override.mesh;
90
+ for (; at && at !== root; at = at.parent) {
91
+ at.addEventListener('removed', override.onRemoved);
92
+ override.watched.push(at);
93
+ }
94
+ override.moved = at !== root;
95
+ };
96
+ /** Wear copies of the object's slots; false (and nothing worn) when one cannot be copied. */
97
+ const dress = (override: Override): boolean => {
98
+ release(override);
99
+ const copies: Colored[] = [];
100
+ for (const material of slotsOf(override.authored)) {
101
+ const copy = ownMaterial?.(material) ?? null;
102
+ if (!copy) {
103
+ for (const made of copies) made.dispose();
104
+ override.mesh.material = override.authored;
105
+ options.unsupported(override.mesh, material);
106
+ return false;
107
+ }
108
+ copies.push(copy as Colored);
109
+ }
110
+ override.copies = copies;
111
+ override.shown = Array.isArray(override.authored) ? copies : copies[0]!;
112
+ override.mesh.material = override.shown;
113
+ return true;
114
+ };
115
+ // Values only: the copies were made from the authored slots, so each starts from its own.
116
+ const paint = (override: Override): void => {
117
+ const authored = slotsOf(override.authored) as Colored[];
118
+ override.copies.forEach((copy, index) => {
119
+ const source = authored[index]!;
120
+ if (isColor(copy.color) && isColor(source.color)) {
121
+ if (override.color === null) copy.color.copy(source.color);
122
+ else copy.color.set(override.color);
123
+ }
124
+ // An emitting surface glows in its tint; one that does not stays unlit by it.
125
+ if (isColor(copy.emissive) && isColor(source.emissive)) {
126
+ const emits = source.emissive.r + source.emissive.g + source.emissive.b > 0;
127
+ if (override.color === null || !emits) copy.emissive.copy(source.emissive);
128
+ else copy.emissive.set(override.color);
129
+ }
130
+ copy.opacity = override.opacity ?? source.opacity;
131
+ const transparent = source.transparent || copy.opacity < 1;
132
+ if (copy.transparent !== transparent) {
133
+ copy.transparent = transparent;
134
+ copy.needsUpdate = true;
135
+ }
136
+ });
137
+ };
138
+ const forget = (override: Override): void => {
139
+ if (override.mesh.material === override.shown) override.mesh.material = override.authored;
140
+ release(override);
141
+ unwatch(override);
142
+ overrides.delete(override.mesh);
143
+ };
144
+ /** The presenter re-assigned the object's slots since it last wore copies: adopt its slots
145
+ * as the authored ones and dress again. False when they cannot be copied. */
146
+ const resync = (override: Override): boolean => {
147
+ if (override.mesh.material === override.shown) return true;
148
+ override.authored = override.mesh.material;
149
+ override.shown = override.mesh.material;
150
+ if (dress(override)) return true;
151
+ release(override);
152
+ unwatch(override);
153
+ overrides.delete(override.mesh);
154
+ return false;
155
+ };
156
+ const change = (target: THREE.Object3D, edit: (override: Override) => void): void => {
157
+ if (disposed) return;
158
+ target.traverse((object) => {
159
+ const mesh = object as THREE.Mesh;
160
+ if (mesh.isMesh !== true || !mesh.material) return;
161
+ let override = overrides.get(mesh);
162
+ if (override && !resync(override)) override = undefined;
163
+ if (!override) {
164
+ const fresh: Override = { mesh, authored: mesh.material, shown: mesh.material, copies: [], color: null, opacity: null,
165
+ watched: [], moved: false, onRemoved: () => { fresh.moved = true; } };
166
+ edit(fresh);
167
+ if (fresh.color === null && fresh.opacity === null) return;
168
+ if (!dress(fresh)) return;
169
+ overrides.set(mesh, fresh);
170
+ watch(fresh);
171
+ override = fresh;
172
+ } else edit(override);
173
+ if (override.color === null && override.opacity === null) forget(override);
174
+ else paint(override);
175
+ });
176
+ };
177
+ return {
178
+ tint(target, color) { change(target, (override) => { override.color = color; }); },
179
+ setOpacity(target, opacity) {
180
+ if (opacity !== null && !Number.isFinite(opacity)) throw new Error(`setOpacity takes a number from 0 to 1, or null; it was given ${String(opacity)}.`);
181
+ const value = opacity === null ? null : Math.min(1, Math.max(0, opacity));
182
+ change(target, (override) => { override.opacity = value; });
183
+ },
184
+ frame() {
185
+ for (const override of [...overrides.values()]) {
186
+ if (override.moved) {
187
+ // Re-walked only after a reported removal; one put back under the copy is watched anew.
188
+ watch(override);
189
+ if (override.moved) { forget(override); continue; }
190
+ }
191
+ if (override.mesh.material !== override.shown && resync(override)) paint(override);
192
+ }
193
+ },
194
+ dispose() {
195
+ disposed = true;
196
+ for (const override of [...overrides.values()]) forget(override);
197
+ },
198
+ };
199
+ }