@aethervtt/shard-runtime 0.0.1 → 0.1.0

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/dist/app.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface AppOptions {
11
11
  now?: () => number;
12
12
  /** Seed for the `GlobalRng` resource. Default 0. */
13
13
  seed?: number;
14
+ /**
15
+ * A development build: missing assets show loudly (checkers), and dev-only checks and issues
16
+ * run (shader cache misses, color-space mismatches, GL error checks). Read it from `DevMode`.
17
+ * Default false.
18
+ */
19
+ dev?: boolean;
14
20
  }
15
21
  export interface AppDescription {
16
22
  plugins: {
@@ -39,6 +45,39 @@ export interface AppMethod {
39
45
  world: World;
40
46
  }, params: Record<string, unknown>): unknown;
41
47
  }
48
+ /**
49
+ * Code the app runs happens inside its scopes: plugin builds and ready hooks, frames, pumps, and
50
+ * disposal. The render plugin uses one to count GPU objects against the app (0052).
51
+ */
52
+ export interface AppScope {
53
+ enter(): void;
54
+ exit(): void;
55
+ }
56
+ /** What a loop runner gives the app while it drives it (0052). */
57
+ export interface FrameDriver {
58
+ /** Schedules a frame if none is coming (on-demand); a no-op for runners that run every frame. */
59
+ requestFrame(): void;
60
+ /** Stops the loop for good. */
61
+ stop(): void;
62
+ }
63
+ /**
64
+ * Waits until what the last frame drew is on screen (0062), resolving with when that was on the
65
+ * app's clock if it knows better than "now". The render plugin sets one: the frame's GPU work done,
66
+ * then the next animation frame, whose timestamp it resolves with. Without one, a frame counts as
67
+ * presented when it ends.
68
+ */
69
+ export type FramePresenter = () => Promise<number | undefined>;
70
+ /** When the app started and became usable, in ms on its clock (`AppOptions.now`) (0062). */
71
+ export interface StartupTimings {
72
+ /** `init()` was called. NaN before. */
73
+ initStart: number;
74
+ /** `init()` resolved. NaN before. */
75
+ initEnd: number;
76
+ /** The host called `markUsable()`. NaN before. */
77
+ usableMarked: number;
78
+ /** The first frame after `markUsable()` was presented. NaN before. */
79
+ usable: number;
80
+ }
42
81
  /**
43
82
  * Owns the world, plugins, and schedules, and runs frames. Hosts drive it with a runner
44
83
  * (animation frame, headless, or manual `update` calls).
@@ -50,6 +89,11 @@ export declare class App {
50
89
  private readonly now;
51
90
  private readonly plugins;
52
91
  private readonly pending;
92
+ /** Plugins in the order they were built; disposal runs it backwards. */
93
+ private readonly built;
94
+ private readonly scopes;
95
+ private driver;
96
+ private disposing;
53
97
  private readonly schedules;
54
98
  private readonly systemNames;
55
99
  private readonly states;
@@ -59,6 +103,12 @@ export declare class App {
59
103
  private startupDone;
60
104
  private accumulator;
61
105
  private readonly frameListeners;
106
+ private presenter;
107
+ /** Traces stamped since the last frame started; the next frame carries them. */
108
+ private readonly traces;
109
+ private readonly traceListeners;
110
+ private readonly usableWaiters;
111
+ private readonly startup_;
62
112
  private readonly appMethods;
63
113
  /** The plugin whose `build` is running, so registrations can be attributed to it. */
64
114
  private building;
@@ -95,8 +145,68 @@ export declare class App {
95
145
  init(): Promise<void>;
96
146
  /** Initializes, then hands control to the runner (manual if none was set). */
97
147
  run(): Promise<void>;
148
+ /**
149
+ * Stops the runner, waits for every plugin's work in flight (`beforeDispose`), then disposes
150
+ * plugins in reverse build order, so each releases what it created: GPU objects, surfaces,
151
+ * listeners, workers (0052). Idempotent; afterwards any other
152
+ * call throws `runtime/disposed`. A plugin whose dispose throws is logged and the rest still run.
153
+ */
154
+ dispose(): Promise<void>;
155
+ /** True once `dispose()` was called. */
156
+ get disposed(): boolean;
157
+ /**
158
+ * Adds a scope around everything the app runs (see `AppScope`). Returns a function that removes
159
+ * it; don't call that from inside the app's own code (a frame, a plugin's dispose): disposal
160
+ * drops every scope once plugins are done. Scopes enter in the order added and exit in reverse.
161
+ */
162
+ addScope(scope: AppScope): () => void;
163
+ /**
164
+ * Called by a loop runner as it starts. `requestFrame` and `dispose` reach the loop through it.
165
+ */
166
+ attachDriver(driver: FrameDriver): void;
167
+ /**
168
+ * Asks for a frame: an on-demand runner schedules one if none is coming (0052). Host code that
169
+ * changes what's on screen without writing the world (a canvas style, a DOM overlay) calls it.
170
+ */
171
+ requestFrame(): void;
172
+ /** Sets how a frame waits to be on screen (see `FramePresenter`). The render plugin calls it. */
173
+ setPresenter(presenter: FramePresenter | undefined): void;
174
+ /** When init started and ended and when the app became usable, in ms on the app's clock. */
175
+ get startup(): Readonly<StartupTimings>;
176
+ /**
177
+ * The host says the app is usable (for a VTT, once the scene is interactive). The first complete
178
+ * frame presented after this is the first usable frame (`startup.usable`, `whenUsable()`). Later
179
+ * calls do nothing.
180
+ */
181
+ markUsable(): void;
182
+ /** Resolves with `startup.usable` once the first usable frame is presented. */
183
+ whenUsable(): Promise<number>;
184
+ /**
185
+ * Stamps a host write (a patch, a token move) for latency: resolves with the ms from now until the
186
+ * frame that carries it is presented. Call it right after the write. The next frame to start
187
+ * carries every trace stamped before it; a frame that ends still loading (`LOADING_DEMAND`: a
188
+ * pipeline compiling, a mesh not uploaded) hands them on, so the latency runs until the write is
189
+ * visible. `onTrace` listeners see each result.
190
+ */
191
+ trace(label: string): Promise<number>;
192
+ /**
193
+ * Requests a frame and resolves (with the ms waited) once a complete frame is presented, as
194
+ * `trace` does, but no `onTrace` listener sees it: for tools waiting on the screen, not for
195
+ * latency.
196
+ */
197
+ whenPresented(): Promise<number>;
198
+ /** Called with every resolved trace: its label and latency in ms. Returns an unsubscribe function. */
199
+ onTrace(listener: (label: string, ms: number) => void): () => void;
200
+ /**
201
+ * Drops accumulated fixed-step time, so the next frame runs FixedUpdate for its own delta only.
202
+ * On-demand runners call it on waking, so a scene idle for a minute doesn't simulate the minute.
203
+ */
204
+ resetFixedTime(): void;
98
205
  /** Runs one frame. `delta` is in seconds. */
99
206
  update(delta: number): void;
207
+ private frame;
208
+ /** Resolves traces and the usable mark once this frame is on screen. */
209
+ private present;
100
210
  /** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
101
211
  onFrame(listener: (frame: number) => void): () => void;
102
212
  /**
@@ -106,6 +216,20 @@ export declare class App {
106
216
  pump(): void;
107
217
  runSchedule(label: ScheduleLabel): void;
108
218
  describe(): AppDescription;
219
+ private assertLive;
220
+ /**
221
+ * Runs `fn` inside the app's scopes, as build and ready hooks run. For the part of an async ready
222
+ * hook after its first await, which runs outside them: what it makes counts against the app.
223
+ */
224
+ scoped<T>(fn: () => T): T;
225
+ /** Enters every scope; returns how many, for the matching `exit`. */
226
+ private enter;
227
+ /** Exits the first `n` scopes, so one added inside the section never sees an unmatched exit. */
228
+ private exit;
229
+ private buildPlugin;
230
+ /** Awaits a ready hook; the part that runs before its first await is inside the app's scopes. */
231
+ private readyPlugin;
232
+ private disposeAll;
109
233
  private schedule;
110
234
  private applyStateTransitions;
111
235
  private dependencyError;
package/dist/app.js CHANGED
@@ -14,9 +14,16 @@ import {
14
14
  World
15
15
  } from "@aethervtt/shard-core";
16
16
  import { AppControl, AppControlResource } from "./control.js";
17
+ import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
17
18
  import { Log, LogResource } from "./log.js";
18
19
  import { OnEnter, OnExit } from "./state.js";
19
- import { DisplayRate, FixedTime, GlobalRng, Time } from "./time.js";
20
+ import {
21
+ DevMode,
22
+ DisplayRate,
23
+ FixedTime,
24
+ GlobalRng,
25
+ Time
26
+ } from "./time.js";
20
27
  const FRAME_SCHEDULES = [Startup, First, PreUpdate, FixedUpdate, Update, PostUpdate, Last];
21
28
  const EPSILON = 1e-9;
22
29
  const TimePlugin = {
@@ -34,6 +41,11 @@ class App {
34
41
  now;
35
42
  plugins = /* @__PURE__ */ new Map();
36
43
  pending = [];
44
+ /** Plugins in the order they were built; disposal runs it backwards. */
45
+ built = [];
46
+ scopes = [];
47
+ driver;
48
+ disposing;
37
49
  schedules = /* @__PURE__ */ new Map();
38
50
  systemNames = /* @__PURE__ */ new Set();
39
51
  states = [];
@@ -43,6 +55,17 @@ class App {
43
55
  startupDone = false;
44
56
  accumulator = 0;
45
57
  frameListeners = /* @__PURE__ */ new Set();
58
+ presenter;
59
+ /** Traces stamped since the last frame started; the next frame carries them. */
60
+ traces = [];
61
+ traceListeners = /* @__PURE__ */ new Set();
62
+ usableWaiters = [];
63
+ startup_ = {
64
+ initStart: Number.NaN,
65
+ initEnd: Number.NaN,
66
+ usableMarked: Number.NaN,
67
+ usable: Number.NaN
68
+ };
46
69
  appMethods = /* @__PURE__ */ new Map();
47
70
  /** The plugin whose `build` is running, so registrations can be attributed to it. */
48
71
  building;
@@ -53,15 +76,20 @@ class App {
53
76
  this.now = options.now ?? (() => performance.now());
54
77
  this.world.insertResource(ProfilerResource, this.profiler);
55
78
  this.world.insertResource(GlobalRng, new Rng(options.seed ?? 0));
79
+ this.world.insertResource(DevMode, { enabled: options.dev ?? false });
56
80
  const log = new Log();
57
81
  log.now = () => this.world.tryResource(Time)?.elapsed ?? 0;
58
82
  this.world.insertResource(LogResource, log);
59
- this.world.insertResource(AppControlResource, new AppControl());
83
+ const control = new AppControl();
84
+ control.onRequest = () => this.driver?.requestFrame();
85
+ this.world.insertResource(AppControlResource, control);
86
+ this.world.insertResource(FrameDemand, new FrameDemandState(this.now));
60
87
  this.addPlugin(TimePlugin);
61
88
  }
62
89
  // --- building --------------------------------------------------------------
63
90
  /** Adds plugins. They build in dependency order during `init()`. */
64
91
  addPlugin(...plugins) {
92
+ this.assertLive();
65
93
  for (const plugin of plugins) {
66
94
  if (this.plugins.has(plugin.name)) {
67
95
  throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" was added twice`);
@@ -73,6 +101,7 @@ class App {
73
101
  }
74
102
  /** Adds protocol methods (see `AppMethod`). A second method with a taken name throws. */
75
103
  addMethod(...methods) {
104
+ this.assertLive();
76
105
  for (const method of methods) {
77
106
  if (this.appMethods.has(method.name)) {
78
107
  throw new ShardError("app/duplicate-method", `Method "${method.name}" was added twice`);
@@ -86,6 +115,7 @@ class App {
86
115
  return [...this.appMethods.values()];
87
116
  }
88
117
  addSystems(schedule, ...systems) {
118
+ this.assertLive();
89
119
  const target = this.schedule(schedule);
90
120
  for (const system of systems) {
91
121
  const name = "system" in system ? system.system.name : system.name;
@@ -101,6 +131,7 @@ class App {
101
131
  return this;
102
132
  }
103
133
  configureSets(schedule, ...sets) {
134
+ this.assertLive();
104
135
  const target = this.schedule(schedule);
105
136
  for (const set of sets) {
106
137
  target.configureSet(set);
@@ -135,7 +166,11 @@ class App {
135
166
  * itself. Resources and entities stay. For hot reload; returns the removed system names.
136
167
  */
137
168
  unloadPlugin(name) {
169
+ this.assertLive();
138
170
  const owned = this.owned.get(name);
171
+ const plugin = this.plugins.get(name);
172
+ const at = plugin ? this.built.indexOf(plugin) : -1;
173
+ if (at !== -1) this.built.splice(at, 1);
139
174
  this.plugins.delete(name);
140
175
  this.owned.delete(name);
141
176
  if (!owned) return [];
@@ -159,37 +194,38 @@ class App {
159
194
  * first run. Startup systems it adds don't run: startup already happened.
160
195
  */
161
196
  async loadPlugin(plugin) {
197
+ this.assertLive();
162
198
  if (this.plugins.has(plugin.name)) {
163
199
  throw new ShardError("app/duplicate-plugin", `Plugin "${plugin.name}" is already loaded`, {
164
200
  hint: "Unload it first (unloadPlugin)."
165
201
  });
166
202
  }
167
203
  this.plugins.set(plugin.name, plugin);
168
- this.building = plugin.name;
169
- try {
170
- plugin.build(this);
171
- } finally {
172
- this.building = void 0;
173
- }
174
- await plugin.ready?.(this);
204
+ this.buildPlugin(plugin);
205
+ await this.readyPlugin(plugin);
175
206
  }
176
207
  insertResource(def, value) {
208
+ this.assertLive();
177
209
  this.world.insertResource(def, value);
178
210
  return this;
179
211
  }
180
212
  /** Registers a state machine. `OnEnter(initial)` runs at the start of the first frame. */
181
213
  initState(state, initial = state.values[0]) {
214
+ this.assertLive();
182
215
  this.world.insertResource(state.resource, { current: initial, next: void 0 });
183
216
  this.states.push({ state, entered: false });
184
217
  return this;
185
218
  }
186
219
  setRunner(runner) {
220
+ this.assertLive();
187
221
  this.runner = runner;
188
222
  return this;
189
223
  }
190
224
  /** Builds plugins in dependency order, then awaits their `ready` hooks. */
191
225
  async init() {
226
+ this.assertLive();
192
227
  if (this.initialized) return;
228
+ if (Number.isNaN(this.startup_.initStart)) this.startup_.initStart = this.now();
193
229
  const built = [];
194
230
  const builtNames = /* @__PURE__ */ new Set();
195
231
  while (this.pending.length > 0) {
@@ -198,32 +234,145 @@ class App {
198
234
  );
199
235
  if (index === -1) throw this.dependencyError(builtNames);
200
236
  const [plugin] = this.pending.splice(index, 1);
201
- this.building = plugin.name;
202
- try {
203
- plugin.build(this);
204
- } finally {
205
- this.building = void 0;
206
- }
237
+ this.buildPlugin(plugin);
207
238
  built.push(plugin);
208
239
  builtNames.add(plugin.name);
209
240
  }
210
- for (const plugin of built) await plugin.ready?.(this);
241
+ for (const plugin of built) await this.readyPlugin(plugin);
211
242
  this.initialized = true;
243
+ this.startup_.initEnd = this.now();
212
244
  }
213
245
  /** Initializes, then hands control to the runner (manual if none was set). */
214
246
  async run() {
215
247
  await this.init();
216
248
  await this.runner?.(this);
217
249
  }
250
+ /**
251
+ * Stops the runner, waits for every plugin's work in flight (`beforeDispose`), then disposes
252
+ * plugins in reverse build order, so each releases what it created: GPU objects, surfaces,
253
+ * listeners, workers (0052). Idempotent; afterwards any other
254
+ * call throws `runtime/disposed`. A plugin whose dispose throws is logged and the rest still run.
255
+ */
256
+ dispose() {
257
+ this.disposing ??= this.disposeAll();
258
+ return this.disposing;
259
+ }
260
+ /** True once `dispose()` was called. */
261
+ get disposed() {
262
+ return this.disposing !== void 0;
263
+ }
264
+ /**
265
+ * Adds a scope around everything the app runs (see `AppScope`). Returns a function that removes
266
+ * it; don't call that from inside the app's own code (a frame, a plugin's dispose): disposal
267
+ * drops every scope once plugins are done. Scopes enter in the order added and exit in reverse.
268
+ */
269
+ addScope(scope) {
270
+ this.assertLive();
271
+ this.scopes.push(scope);
272
+ return () => {
273
+ const i = this.scopes.indexOf(scope);
274
+ if (i !== -1) this.scopes.splice(i, 1);
275
+ };
276
+ }
277
+ /**
278
+ * Called by a loop runner as it starts. `requestFrame` and `dispose` reach the loop through it.
279
+ */
280
+ attachDriver(driver) {
281
+ this.assertLive();
282
+ this.driver = driver;
283
+ }
284
+ /**
285
+ * Asks for a frame: an on-demand runner schedules one if none is coming (0052). Host code that
286
+ * changes what's on screen without writing the world (a canvas style, a DOM overlay) calls it.
287
+ */
288
+ requestFrame() {
289
+ this.assertLive();
290
+ this.driver?.requestFrame();
291
+ }
292
+ // --- presentation (0062) ----------------------------------------------------
293
+ /** Sets how a frame waits to be on screen (see `FramePresenter`). The render plugin calls it. */
294
+ setPresenter(presenter) {
295
+ this.presenter = presenter;
296
+ }
297
+ /** When init started and ended and when the app became usable, in ms on the app's clock. */
298
+ get startup() {
299
+ return this.startup_;
300
+ }
301
+ /**
302
+ * The host says the app is usable (for a VTT, once the scene is interactive). The first complete
303
+ * frame presented after this is the first usable frame (`startup.usable`, `whenUsable()`). Later
304
+ * calls do nothing.
305
+ */
306
+ markUsable() {
307
+ this.assertLive();
308
+ if (!Number.isNaN(this.startup_.usableMarked)) return;
309
+ this.startup_.usableMarked = this.now();
310
+ this.driver?.requestFrame();
311
+ }
312
+ /** Resolves with `startup.usable` once the first usable frame is presented. */
313
+ whenUsable() {
314
+ if (!Number.isNaN(this.startup_.usable)) return Promise.resolve(this.startup_.usable);
315
+ return new Promise((resolve) => this.usableWaiters.push(resolve));
316
+ }
317
+ /**
318
+ * Stamps a host write (a patch, a token move) for latency: resolves with the ms from now until the
319
+ * frame that carries it is presented. Call it right after the write. The next frame to start
320
+ * carries every trace stamped before it; a frame that ends still loading (`LOADING_DEMAND`: a
321
+ * pipeline compiling, a mesh not uploaded) hands them on, so the latency runs until the write is
322
+ * visible. `onTrace` listeners see each result.
323
+ */
324
+ trace(label) {
325
+ this.assertLive();
326
+ return new Promise((resolve) => {
327
+ this.traces.push({ label, at: this.now(), resolve });
328
+ this.driver?.requestFrame();
329
+ });
330
+ }
331
+ /**
332
+ * Requests a frame and resolves (with the ms waited) once a complete frame is presented, as
333
+ * `trace` does, but no `onTrace` listener sees it: for tools waiting on the screen, not for
334
+ * latency.
335
+ */
336
+ whenPresented() {
337
+ this.assertLive();
338
+ return new Promise((resolve) => {
339
+ this.traces.push({ label: void 0, at: this.now(), resolve });
340
+ this.driver?.requestFrame();
341
+ });
342
+ }
343
+ /** Called with every resolved trace: its label and latency in ms. Returns an unsubscribe function. */
344
+ onTrace(listener) {
345
+ this.assertLive();
346
+ this.traceListeners.add(listener);
347
+ return () => this.traceListeners.delete(listener);
348
+ }
349
+ /**
350
+ * Drops accumulated fixed-step time, so the next frame runs FixedUpdate for its own delta only.
351
+ * On-demand runners call it on waking, so a scene idle for a minute doesn't simulate the minute.
352
+ */
353
+ resetFixedTime() {
354
+ this.accumulator = 0;
355
+ }
218
356
  // --- running ---------------------------------------------------------------
219
357
  /** Runs one frame. `delta` is in seconds. */
220
358
  update(delta) {
359
+ this.assertLive();
221
360
  if (!this.initialized) {
222
361
  throw new ShardError("app/not-initialized", "App.update() was called before init()", {
223
362
  hint: "Await app.init() (or app.run()) first."
224
363
  });
225
364
  }
365
+ const scoped = this.enter();
366
+ try {
367
+ this.frame(delta);
368
+ } finally {
369
+ this.exit(scoped);
370
+ }
371
+ }
372
+ frame(delta) {
226
373
  const world = this.world;
374
+ const traces = this.traces.length > 0 ? this.traces.splice(0) : void 0;
375
+ const usable = Number.isNaN(this.startup_.usable) && !Number.isNaN(this.startup_.usableMarked);
227
376
  if (!this.startupDone) {
228
377
  this.runSchedule(Startup);
229
378
  this.startupDone = true;
@@ -252,9 +401,39 @@ class App {
252
401
  this.runSchedule(Last);
253
402
  time.frame++;
254
403
  for (const listener of this.frameListeners) listener(time.frame);
404
+ if (!traces && !usable) return;
405
+ if (world.resource(FrameDemand).isHeld(LOADING_DEMAND)) {
406
+ if (traces) this.traces.unshift(...traces);
407
+ return;
408
+ }
409
+ this.present(traces, usable);
410
+ }
411
+ /** Resolves traces and the usable mark once this frame is on screen. */
412
+ present(traces, usable) {
413
+ const done = (when) => {
414
+ const at = when ?? this.now();
415
+ if (usable && Number.isNaN(this.startup_.usable)) {
416
+ this.startup_.usable = at;
417
+ for (const resolve of this.usableWaiters.splice(0)) resolve(at);
418
+ }
419
+ if (!traces) return;
420
+ for (const trace of traces) {
421
+ const ms = Math.max(0, at - trace.at);
422
+ trace.resolve(ms);
423
+ const label = trace.label;
424
+ if (label !== void 0) for (const listener of this.traceListeners) listener(label, ms);
425
+ }
426
+ };
427
+ const presenter = this.presenter;
428
+ if (!presenter) {
429
+ done();
430
+ return;
431
+ }
432
+ presenter().then(done, () => done());
255
433
  }
256
434
  /** Called after every frame with the number of frames completed. Returns an unsubscribe function. */
257
435
  onFrame(listener) {
436
+ this.assertLive();
258
437
  this.frameListeners.add(listener);
259
438
  return () => this.frameListeners.delete(listener);
260
439
  }
@@ -263,6 +442,7 @@ class App {
263
442
  * without a frame loop (headless servers); loop-driven runners step one frame per tick instead.
264
443
  */
265
444
  pump() {
445
+ this.assertLive();
266
446
  const control = this.world.resource(AppControlResource);
267
447
  while (control.pendingSteps > 0) {
268
448
  try {
@@ -301,6 +481,113 @@ class App {
301
481
  };
302
482
  }
303
483
  // --- internals -------------------------------------------------------------
484
+ assertLive() {
485
+ if (this.disposing === void 0) return;
486
+ throw new ShardError("runtime/disposed", "This app was disposed", {
487
+ hint: "Create a new App; a disposed one has released its GPU objects and listeners."
488
+ });
489
+ }
490
+ /**
491
+ * Runs `fn` inside the app's scopes, as build and ready hooks run. For the part of an async ready
492
+ * hook after its first await, which runs outside them: what it makes counts against the app.
493
+ */
494
+ scoped(fn) {
495
+ const n = this.enter();
496
+ try {
497
+ return fn();
498
+ } finally {
499
+ this.exit(n);
500
+ }
501
+ }
502
+ /** Enters every scope; returns how many, for the matching `exit`. */
503
+ enter() {
504
+ const n = this.scopes.length;
505
+ for (let i = 0; i < n; i++) this.scopes[i].enter();
506
+ return n;
507
+ }
508
+ /** Exits the first `n` scopes, so one added inside the section never sees an unmatched exit. */
509
+ exit(n) {
510
+ for (let i = Math.min(n, this.scopes.length) - 1; i >= 0; i--) this.scopes[i].exit();
511
+ }
512
+ buildPlugin(plugin) {
513
+ this.building = plugin.name;
514
+ const scoped = this.enter();
515
+ try {
516
+ plugin.build(this);
517
+ } finally {
518
+ this.exit(scoped);
519
+ this.building = void 0;
520
+ }
521
+ this.built.push(plugin);
522
+ }
523
+ /** Awaits a ready hook; the part that runs before its first await is inside the app's scopes. */
524
+ async readyPlugin(plugin) {
525
+ if (!plugin.ready) return;
526
+ const scoped = this.enter();
527
+ let result;
528
+ try {
529
+ result = plugin.ready(this);
530
+ } finally {
531
+ this.exit(scoped);
532
+ }
533
+ await result;
534
+ }
535
+ async disposeAll() {
536
+ const driver = this.driver;
537
+ this.driver = void 0;
538
+ driver?.stop();
539
+ this.frameListeners.clear();
540
+ this.traceListeners.clear();
541
+ this.presenter = void 0;
542
+ this.world.onWake = void 0;
543
+ this.world.asleep = false;
544
+ const log = this.world.tryResource(LogResource);
545
+ let first;
546
+ for (const plugin of this.built) {
547
+ if (!plugin.beforeDispose) continue;
548
+ const scoped = this.enter();
549
+ let result;
550
+ try {
551
+ result = plugin.beforeDispose(this);
552
+ } catch (err) {
553
+ first ??= err;
554
+ log?.error(err);
555
+ continue;
556
+ } finally {
557
+ this.exit(scoped);
558
+ }
559
+ try {
560
+ await result;
561
+ } catch (err) {
562
+ first ??= err;
563
+ log?.error(err);
564
+ }
565
+ }
566
+ for (let i = this.built.length - 1; i >= 0; i--) {
567
+ const plugin = this.built[i];
568
+ if (!plugin.dispose) continue;
569
+ const scoped = this.enter();
570
+ let result;
571
+ try {
572
+ result = plugin.dispose(this);
573
+ } catch (err) {
574
+ first ??= err;
575
+ log?.error(err);
576
+ continue;
577
+ } finally {
578
+ this.exit(scoped);
579
+ }
580
+ try {
581
+ await result;
582
+ } catch (err) {
583
+ first ??= err;
584
+ log?.error(err);
585
+ }
586
+ }
587
+ this.built.length = 0;
588
+ this.scopes.length = 0;
589
+ if (first !== void 0) throw first;
590
+ }
304
591
  schedule(label) {
305
592
  let schedule = this.schedules.get(label);
306
593
  if (!schedule) {
package/dist/control.d.ts CHANGED
@@ -6,6 +6,8 @@ export declare class AppControl {
6
6
  paused: boolean;
7
7
  /** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
8
8
  pausedByError: boolean;
9
+ /** Called when steps are queued, so an idle on-demand runner wakes to run them (0052). */
10
+ onRequest: (() => void) | undefined;
9
11
  /** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
10
12
  private requested;
11
13
  private completed;
package/dist/control.js CHANGED
@@ -3,6 +3,8 @@ class AppControl {
3
3
  paused = false;
4
4
  /** Set when a failing frame paused the app, so a fix (e.g. a hot reload) can resume it. */
5
5
  pausedByError = false;
6
+ /** Called when steps are queued, so an idle on-demand runner wakes to run them (0052). */
7
+ onRequest = void 0;
6
8
  /** Stepped frames ever requested / ever run. Both only grow; the difference is what's pending. */
7
9
  requested = 0;
8
10
  completed = 0;
@@ -16,7 +18,11 @@ class AppControl {
16
18
  if (frames <= 0) return Promise.resolve();
17
19
  this.requested += frames;
18
20
  const target = this.requested;
19
- return new Promise((resolve, reject) => this.waiters.push({ target, resolve, reject }));
21
+ const done = new Promise(
22
+ (resolve, reject) => this.waiters.push({ target, resolve, reject })
23
+ );
24
+ this.onRequest?.();
25
+ return done;
20
26
  }
21
27
  /** A stepped frame threw: drops the pending steps and rejects everyone waiting on them. */
22
28
  abort(error) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The frame demand a renderer holds while a frame skipped draws (pipelines compiling, meshes or
3
+ * materials still loading, 0052). Such a frame isn't complete: traces and the first usable frame
4
+ * wait for one that is (0062).
5
+ */
6
+ export declare const LOADING_DEMAND = "render/loading";
7
+ /** How the app's frames are driven: every display refresh, only when needed, or by hand. */
8
+ export type FrameMode = 'continuous' | 'on-demand' | 'manual';
9
+ /**
10
+ * What keeps an on-demand runner rendering (0052). Holders are named, so `render.describe` can say
11
+ * who is keeping frames running: awake physics bodies, live particles, playing animations, the
12
+ * RenderScale controller while probing, or anything a game holds itself.
13
+ */
14
+ export declare class FrameDemandState {
15
+ /** Set by the runner. 'manual' until a loop runner starts. */
16
+ mode: FrameMode;
17
+ /** Clock time (ms, the app's clock) of the earliest frame `after` asked for; Infinity if none. */
18
+ dueAt: number;
19
+ /** Set by the on-demand runner: called when a new demand appears, so an idle runner wakes. */
20
+ onDemand: (() => void) | undefined;
21
+ private readonly holders;
22
+ private readonly now;
23
+ constructor(now?: () => number);
24
+ /** Keeps frames running until `release(key)`. Holding a key twice is one hold. */
25
+ hold(key: string): void;
26
+ release(key: string): void;
27
+ /** Holds `key` while `on` is true: one call per frame from a system that tracks a condition. */
28
+ set(key: string, on: boolean): void;
29
+ /** Asks for one frame `ms` from now, for ambient loops at low rates. The earliest ask wins. */
30
+ after(ms: number): void;
31
+ /** Ms until the frame `after` asked for (0 if overdue); Infinity if none was asked for. */
32
+ dueIn(): number;
33
+ /** @internal The runner calls this as a frame starts: a frame that was due is now running. */
34
+ takeDue(): void;
35
+ /** Whether anything holds frames running. */
36
+ get active(): boolean;
37
+ isHeld(key: string): boolean;
38
+ /** The keys holding frames running, sorted. */
39
+ held(): string[];
40
+ /** The runner's mode, what holds frames, and when the next timed frame is due. For agents. */
41
+ describe(): {
42
+ mode: FrameMode;
43
+ demands: string[];
44
+ dueInMs: number | null;
45
+ };
46
+ }
47
+ export declare const FrameDemand: import("@aethervtt/shard-core").ResourceDef<FrameDemandState>;
package/dist/demand.js ADDED
@@ -0,0 +1,68 @@
1
+ import { defineResource } from "@aethervtt/shard-core";
2
+ const LOADING_DEMAND = "render/loading";
3
+ class FrameDemandState {
4
+ /** Set by the runner. 'manual' until a loop runner starts. */
5
+ mode = "manual";
6
+ /** Clock time (ms, the app's clock) of the earliest frame `after` asked for; Infinity if none. */
7
+ dueAt = Number.POSITIVE_INFINITY;
8
+ /** Set by the on-demand runner: called when a new demand appears, so an idle runner wakes. */
9
+ onDemand = void 0;
10
+ holders = /* @__PURE__ */ new Set();
11
+ now;
12
+ constructor(now = () => performance.now()) {
13
+ this.now = now;
14
+ }
15
+ /** Keeps frames running until `release(key)`. Holding a key twice is one hold. */
16
+ hold(key) {
17
+ if (this.holders.has(key)) return;
18
+ this.holders.add(key);
19
+ this.onDemand?.();
20
+ }
21
+ release(key) {
22
+ this.holders.delete(key);
23
+ }
24
+ /** Holds `key` while `on` is true: one call per frame from a system that tracks a condition. */
25
+ set(key, on) {
26
+ if (on) this.hold(key);
27
+ else this.holders.delete(key);
28
+ }
29
+ /** Asks for one frame `ms` from now, for ambient loops at low rates. The earliest ask wins. */
30
+ after(ms) {
31
+ const at = this.now() + Math.max(0, ms);
32
+ if (at >= this.dueAt) return;
33
+ this.dueAt = at;
34
+ this.onDemand?.();
35
+ }
36
+ /** Ms until the frame `after` asked for (0 if overdue); Infinity if none was asked for. */
37
+ dueIn() {
38
+ return this.dueAt === Number.POSITIVE_INFINITY ? this.dueAt : Math.max(0, this.dueAt - this.now());
39
+ }
40
+ /** @internal The runner calls this as a frame starts: a frame that was due is now running. */
41
+ takeDue() {
42
+ if (this.dueAt <= this.now() + 1) this.dueAt = Number.POSITIVE_INFINITY;
43
+ }
44
+ /** Whether anything holds frames running. */
45
+ get active() {
46
+ return this.holders.size > 0;
47
+ }
48
+ isHeld(key) {
49
+ return this.holders.has(key);
50
+ }
51
+ /** The keys holding frames running, sorted. */
52
+ held() {
53
+ return [...this.holders].sort();
54
+ }
55
+ /** The runner's mode, what holds frames, and when the next timed frame is due. For agents. */
56
+ describe() {
57
+ const due = this.dueIn();
58
+ return { mode: this.mode, demands: this.held(), dueInMs: due === Infinity ? null : due };
59
+ }
60
+ }
61
+ const FrameDemand = defineResource("core/FrameDemand", {
62
+ description: "What keeps an on-demand runner rendering: hold(key) / release(key) while something must run each frame, after(ms) for one frame later."
63
+ });
64
+ export {
65
+ FrameDemand,
66
+ FrameDemandState,
67
+ LOADING_DEMAND
68
+ };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- export { App, type AppDescription, type AppMethod, type AppOptions } from './app.js';
1
+ export { App, type AppDescription, type AppMethod, type AppOptions, type AppScope, type FrameDriver, type FramePresenter, type StartupTimings, } from './app.js';
2
2
  export { AppControl, AppControlResource } from './control.js';
3
+ export { FrameDemand, FrameDemandState, type FrameMode, LOADING_DEMAND } from './demand.js';
3
4
  export { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from './display.js';
4
5
  export { Log, type LogEntry, type LogLevel, LogResource } from './log.js';
5
6
  export { definePlugin, type Plugin } from './plugin.js';
6
7
  export { type AnimationFrameOptions, animationFrameRunner, type HeadlessOptions, headlessRunner, type Runner, } from './runners.js';
7
8
  export { defineState, inState, OnEnter, OnExit, type StateDef, type StateValue, setState, } from './state.js';
8
- export { DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
9
+ export { DevMode, DisplayRate, type DisplayRateData, FixedTime, type FixedTimeData, GlobalRng, Time, type TimeData, } from './time.js';
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
- import { App } from "./app.js";
1
+ import {
2
+ App
3
+ } from "./app.js";
2
4
  import { AppControl, AppControlResource } from "./control.js";
5
+ import { FrameDemand, FrameDemandState, LOADING_DEMAND } from "./demand.js";
3
6
  import { COMMON_RATES, RefreshMeter, rateFromIntervals, snapRate } from "./display.js";
4
7
  import { Log, LogResource } from "./log.js";
5
8
  import { definePlugin } from "./plugin.js";
@@ -15,6 +18,7 @@ import {
15
18
  setState
16
19
  } from "./state.js";
17
20
  import {
21
+ DevMode,
18
22
  DisplayRate,
19
23
  FixedTime,
20
24
  GlobalRng,
@@ -25,9 +29,13 @@ export {
25
29
  AppControl,
26
30
  AppControlResource,
27
31
  COMMON_RATES,
32
+ DevMode,
28
33
  DisplayRate,
29
34
  FixedTime,
35
+ FrameDemand,
36
+ FrameDemandState,
30
37
  GlobalRng,
38
+ LOADING_DEMAND,
31
39
  Log,
32
40
  LogResource,
33
41
  OnEnter,
package/dist/plugin.d.ts CHANGED
@@ -14,5 +14,17 @@ export interface Plugin {
14
14
  build(app: App): void;
15
15
  /** Optional async setup (e.g. loading WASM), awaited before Startup. */
16
16
  ready?(app: App): Promise<void> | void;
17
+ /**
18
+ * Releases what `build` and `ready` created that outlives the app: GPU objects, surfaces, DOM
19
+ * listeners, observers, workers. `app.dispose()` calls it in reverse build order, after the
20
+ * runner stopped (0052).
21
+ */
22
+ dispose?(app: App): Promise<void> | void;
23
+ /**
24
+ * Waits for work the plugin started that is still in flight (GPU commands submitted but not
25
+ * done). `app.dispose()` awaits it on every plugin before any plugin's `dispose`, so nothing is
26
+ * released while still in use: render's dispose runs last, after others freed their buffers.
27
+ */
28
+ beforeDispose?(app: App): Promise<void> | void;
17
29
  }
18
30
  export declare function definePlugin(plugin: Plugin): Plugin;
package/dist/runners.d.ts CHANGED
@@ -11,13 +11,28 @@ export declare function headlessRunner(options: HeadlessOptions): Runner;
11
11
  export interface AnimationFrameOptions {
12
12
  /** Longest delta passed to update, in seconds, e.g. after the tab was hidden. Default 0.25. */
13
13
  maxDelta?: number;
14
- /** Stops the loop when aborted. */
14
+ /** Stops the loop when aborted. `app.dispose()` stops it too. */
15
15
  signal?: AbortSignal;
16
16
  /**
17
17
  * Time a few idle frames before the first update to measure the display's refresh rate into
18
18
  * DisplayRate (about 12 refreshes: 0.1 s at 120 Hz). Default true.
19
19
  */
20
20
  measureRefresh?: boolean;
21
+ /**
22
+ * continuous (default): a frame every display refresh. on-demand: a frame only when something
23
+ * changed (0052): a world write, a FrameDemand holder or timer, input, a surface resize, queued
24
+ * steps, or `app.requestFrame()`. Idle, it requests no animation frames at all.
25
+ */
26
+ mode?: 'continuous' | 'on-demand';
27
+ /**
28
+ * On-demand, for dev builds: every second while idle, compare the plain-object resources marked
29
+ * `hostWritable` with their values when the app went idle, and log
30
+ * `runtime/unmarked-resource-write` for one that changed without waking it. Default false.
31
+ */
32
+ checkResourceWrites?: boolean;
21
33
  }
22
- /** Runs a frame per `requestAnimationFrame`. For the browser and Studio. */
34
+ /**
35
+ * Runs frames on `requestAnimationFrame`, for the browser and Studio: one per display refresh, or
36
+ * in `mode: 'on-demand'` only while something needs them (0052).
37
+ */
23
38
  export declare function animationFrameRunner(options?: AnimationFrameOptions): Runner;
package/dist/runners.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AppControlResource } from "./control.js";
2
+ import { FrameDemand } from "./demand.js";
2
3
  import { RefreshMeter, rateFromIntervals } from "./display.js";
3
4
  import { LogResource } from "./log.js";
4
5
  import { DisplayRate } from "./time.js";
@@ -9,76 +10,157 @@ function headlessRunner(options) {
9
10
  };
10
11
  }
11
12
  const PROBE_INTERVALS = 12;
12
- function probeRefresh() {
13
+ function probeRefresh(probe) {
13
14
  return new Promise((resolve) => {
14
15
  if (document.hidden) return resolve(0);
15
16
  const intervals = new Float64Array(PROBE_INTERVALS);
16
17
  let last = -1;
17
18
  let n = 0;
18
19
  const tick = (t) => {
19
- if (document.hidden) return resolve(0);
20
+ if (probe.stopped || document.hidden) return resolve(0);
20
21
  if (last >= 0) intervals[n++] = t - last;
21
22
  last = t;
22
- if (n < PROBE_INTERVALS) requestAnimationFrame(tick);
23
+ if (n < PROBE_INTERVALS) probe.handle = requestAnimationFrame(tick);
23
24
  else resolve(rateFromIntervals(intervals));
24
25
  };
25
- requestAnimationFrame(tick);
26
+ probe.handle = requestAnimationFrame(tick);
26
27
  });
27
28
  }
28
29
  function animationFrameRunner(options = {}) {
29
30
  const maxDelta = options.maxDelta ?? 0.25;
31
+ const onDemand = options.mode === "on-demand";
30
32
  return async (app) => {
31
- const display = app.world.tryResource(DisplayRate);
32
- if (display && options.measureRefresh !== false) {
33
- const hz = await probeRefresh();
33
+ const world = app.world;
34
+ const demand = world.resource(FrameDemand);
35
+ const control = world.resource(AppControlResource);
36
+ const display = world.tryResource(DisplayRate);
37
+ const probe = { handle: 0, stopped: false };
38
+ let handle = 0;
39
+ let pending = false;
40
+ let idle = true;
41
+ let last;
42
+ let timer;
43
+ let writes;
44
+ let finish = () => {
45
+ };
46
+ const finished = new Promise((resolve) => {
47
+ finish = resolve;
48
+ });
49
+ const schedule = () => {
50
+ if (pending || probe.stopped) return;
51
+ pending = true;
52
+ handle = requestAnimationFrame(frame);
53
+ };
54
+ const onTimer = () => {
55
+ timer = void 0;
56
+ schedule();
57
+ };
58
+ const armTimer = () => {
59
+ if (timer !== void 0) clearTimeout(timer);
60
+ timer = void 0;
61
+ const due = demand.dueIn();
62
+ if (due !== Number.POSITIVE_INFINITY) timer = setTimeout(onTimer, due);
63
+ };
64
+ const goIdle = () => {
65
+ idle = true;
66
+ world.asleep = true;
67
+ armTimer();
68
+ writes?.start();
69
+ };
70
+ const onVisibility = () => {
71
+ if (document.hidden) last = void 0;
72
+ };
73
+ const stop = () => {
74
+ if (probe.stopped) return;
75
+ probe.stopped = true;
76
+ cancelAnimationFrame(probe.handle);
77
+ if (pending) cancelAnimationFrame(handle);
78
+ pending = false;
79
+ if (timer !== void 0) clearTimeout(timer);
80
+ timer = void 0;
81
+ writes?.stop();
82
+ document.removeEventListener("visibilitychange", onVisibility);
83
+ options.signal?.removeEventListener("abort", stop);
84
+ if (onDemand) {
85
+ world.onWake = void 0;
86
+ world.asleep = false;
87
+ demand.onDemand = void 0;
88
+ }
89
+ demand.mode = "manual";
90
+ finish();
91
+ };
92
+ const meter = new RefreshMeter(display?.periodMs ?? 1e3 / 60);
93
+ const frame = (timestamp) => {
94
+ pending = false;
95
+ if (probe.stopped) return;
96
+ const woke = idle;
97
+ idle = false;
98
+ writes?.stop();
99
+ let delta = last === void 0 ? 0 : Math.min((timestamp - last) / 1e3, maxDelta);
100
+ if (onDemand) {
101
+ world.asleep = false;
102
+ demand.takeDue();
103
+ if (woke && last !== void 0) {
104
+ delta = 1 / app.fixedHz;
105
+ app.resetFixedTime();
106
+ }
107
+ }
108
+ if (display && last !== void 0 && !woke) {
109
+ const hz = meter.sample(timestamp - last);
110
+ if (hz > 0) {
111
+ display.hz = hz;
112
+ display.periodMs = 1e3 / hz;
113
+ display.source = "measured";
114
+ }
115
+ }
116
+ last = timestamp;
117
+ try {
118
+ if (!control.paused) app.update(delta);
119
+ else if (control.pendingSteps > 0) {
120
+ app.update(1 / app.fixedHz);
121
+ control.stepped();
122
+ }
123
+ } catch (err) {
124
+ world.resource(LogResource).error(err);
125
+ control.paused = true;
126
+ control.pausedByError = true;
127
+ control.abort(err);
128
+ console.error(err);
129
+ }
130
+ if (probe.stopped) return;
131
+ if (!onDemand || demand.active || control.pendingSteps > 0) schedule();
132
+ if (!pending) goIdle();
133
+ };
134
+ options.signal?.addEventListener("abort", stop);
135
+ app.attachDriver({ requestFrame: onDemand ? schedule : () => {
136
+ }, stop });
137
+ demand.mode = onDemand ? "on-demand" : "continuous";
138
+ if (options.signal?.aborted) stop();
139
+ if (display && options.measureRefresh !== false && !probe.stopped) {
140
+ const hz = await probeRefresh(probe);
34
141
  if (hz > 0) {
35
142
  display.hz = hz;
36
143
  display.periodMs = 1e3 / hz;
37
144
  display.source = "measured";
145
+ meter.periodMs = display.periodMs;
38
146
  }
39
147
  }
40
- const meter = new RefreshMeter(display?.periodMs ?? 1e3 / 60);
41
- return new Promise((resolve) => {
42
- let last;
43
- let handle = 0;
44
- const onVisibility = () => {
45
- if (document.hidden) last = void 0;
46
- };
47
- const frame = (timestamp) => {
48
- const delta = last === void 0 ? 0 : Math.min((timestamp - last) / 1e3, maxDelta);
49
- if (display && last !== void 0) {
50
- const hz = meter.sample(timestamp - last);
51
- if (hz > 0) {
52
- display.hz = hz;
53
- display.periodMs = 1e3 / hz;
54
- display.source = "measured";
55
- }
56
- }
57
- last = timestamp;
58
- const control = app.world.resource(AppControlResource);
59
- try {
60
- if (!control.paused) app.update(delta);
61
- else if (control.pendingSteps > 0) {
62
- app.update(1 / app.fixedHz);
63
- control.stepped();
64
- }
65
- } catch (err) {
66
- app.world.resource(LogResource).error(err);
67
- control.paused = true;
68
- control.pausedByError = true;
69
- control.abort(err);
70
- console.error(err);
71
- }
72
- handle = requestAnimationFrame(frame);
148
+ if (onDemand && options.checkResourceWrites) {
149
+ const { ResourceWriteCheck } = await import("./write-check.js");
150
+ writes = new ResourceWriteCheck(world, world.tryResource(LogResource), () => idle && !pending);
151
+ }
152
+ if (probe.stopped) return;
153
+ if (onDemand) {
154
+ world.onWake = schedule;
155
+ demand.onDemand = () => {
156
+ if (!idle || probe.stopped) return;
157
+ if (demand.active) schedule();
158
+ else armTimer();
73
159
  };
74
- document.addEventListener("visibilitychange", onVisibility);
75
- options.signal?.addEventListener("abort", () => {
76
- cancelAnimationFrame(handle);
77
- document.removeEventListener("visibilitychange", onVisibility);
78
- resolve();
79
- });
80
- handle = requestAnimationFrame(frame);
81
- });
160
+ }
161
+ document.addEventListener("visibilitychange", onVisibility);
162
+ schedule();
163
+ return finished;
82
164
  };
83
165
  }
84
166
  export {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A stand-in for the browser's frame loop, so `animationFrameRunner` runs headless in tests: it
3
+ * installs `requestAnimationFrame`, `cancelAnimationFrame`, and (when there's none) a visible
4
+ * `document` on globalThis. Frames only run when the test ticks.
5
+ */
6
+ export interface FakeFrames {
7
+ /** Animation frames requested so far. */
8
+ readonly requested: number;
9
+ /** Callbacks waiting for the next frame. */
10
+ readonly pending: number;
11
+ /** The timestamp the last tick passed, in ms. */
12
+ readonly now: number;
13
+ /** `visibilitychange` listeners on the fake document. */
14
+ readonly listeners: number;
15
+ /** Advances the clock by `ms` (default one 60 Hz refresh) and runs what was waiting. */
16
+ tick(ms?: number): number;
17
+ /** Ticks until nothing waits or `max` frames ran; returns the frames run. */
18
+ runUntilIdle(max?: number, ms?: number): number;
19
+ /** Puts the real globals back. */
20
+ restore(): void;
21
+ }
22
+ export declare function fakeAnimationFrames(): FakeFrames;
@@ -0,0 +1,64 @@
1
+ function fakeAnimationFrames() {
2
+ const g = globalThis;
3
+ const saved = {
4
+ raf: g.requestAnimationFrame,
5
+ caf: g.cancelAnimationFrame,
6
+ document: g.document
7
+ };
8
+ let waiting = /* @__PURE__ */ new Map();
9
+ let nextId = 1;
10
+ let requested = 0;
11
+ let now = 0;
12
+ let listeners = 0;
13
+ g.requestAnimationFrame = (fn) => {
14
+ const id = nextId++;
15
+ requested++;
16
+ waiting.set(id, fn);
17
+ return id;
18
+ };
19
+ g.cancelAnimationFrame = (id) => {
20
+ waiting.delete(id);
21
+ };
22
+ if (saved.document === void 0) {
23
+ g.document = {
24
+ hidden: false,
25
+ addEventListener: () => void listeners++,
26
+ removeEventListener: () => void listeners--
27
+ };
28
+ }
29
+ const tick = (ms = 1e3 / 60) => {
30
+ now += ms;
31
+ const run = waiting;
32
+ waiting = /* @__PURE__ */ new Map();
33
+ for (const fn of run.values()) fn(now);
34
+ return run.size;
35
+ };
36
+ return {
37
+ get requested() {
38
+ return requested;
39
+ },
40
+ get pending() {
41
+ return waiting.size;
42
+ },
43
+ get now() {
44
+ return now;
45
+ },
46
+ get listeners() {
47
+ return listeners;
48
+ },
49
+ tick,
50
+ runUntilIdle(max = 1e4, ms) {
51
+ let frames = 0;
52
+ while (waiting.size > 0 && frames < max) frames += tick(ms);
53
+ return frames;
54
+ },
55
+ restore() {
56
+ g.requestAnimationFrame = saved.raf;
57
+ g.cancelAnimationFrame = saved.caf;
58
+ g.document = saved.document;
59
+ }
60
+ };
61
+ }
62
+ export {
63
+ fakeAnimationFrames
64
+ };
package/dist/time.d.ts CHANGED
@@ -32,3 +32,7 @@ export interface DisplayRateData {
32
32
  }
33
33
  export declare const DisplayRate: import("@aethervtt/shard-core").ResourceDef<DisplayRateData>;
34
34
  export declare const GlobalRng: import("@aethervtt/shard-core").ResourceDef<Rng>;
35
+ /** Whether this is a development build (`new App({ dev: true })`). */
36
+ export declare const DevMode: import("@aethervtt/shard-core").ResourceDef<{
37
+ enabled: boolean;
38
+ }>;
package/dist/time.js CHANGED
@@ -11,7 +11,12 @@ const DisplayRate = defineResource("core/DisplayRate", {
11
11
  const GlobalRng = defineResource("core/GlobalRng", {
12
12
  description: "The app root random stream, seeded from AppOptions.seed. Fork it per system."
13
13
  });
14
+ const DevMode = defineResource("runtime/DevMode", {
15
+ description: "Development build: loud fallbacks and dev-only checks.",
16
+ init: () => ({ enabled: false })
17
+ });
14
18
  export {
19
+ DevMode,
15
20
  DisplayRate,
16
21
  FixedTime,
17
22
  GlobalRng,
@@ -0,0 +1,22 @@
1
+ import { type World } from '@aethervtt/shard-core';
2
+ import type { Log } from './log.js';
3
+ /**
4
+ * The on-demand runner's dev check (0052): while the app is idle, every second, compare the
5
+ * plain-object resources marked `hostWritable` with their values when it went idle, and log
6
+ * `runtime/unmarked-resource-write` for one that changed without waking the app. Loaded only when
7
+ * `checkResourceWrites` is on, so production builds don't carry it.
8
+ */
9
+ export declare class ResourceWriteCheck {
10
+ private readonly snapshots;
11
+ private timer;
12
+ private readonly world;
13
+ private readonly log;
14
+ private readonly quiet;
15
+ /** `quiet`: still idle with no frame requested, so a change nobody announced is a bug. */
16
+ constructor(world: World, log: Log | undefined, quiet: () => boolean);
17
+ /** The app went idle: remember what the resources hold, and check them every second. */
18
+ start(): void;
19
+ /** A frame is running (or the loop stopped): writes are fine again. */
20
+ stop(): void;
21
+ private verify;
22
+ }
@@ -0,0 +1,54 @@
1
+ import { allResources, isPlainObject } from "@aethervtt/shard-core";
2
+ const CHECK_MS = 1e3;
3
+ function hashOf(value) {
4
+ if (!isPlainObject(value)) return void 0;
5
+ try {
6
+ return JSON.stringify(value);
7
+ } catch {
8
+ return void 0;
9
+ }
10
+ }
11
+ class ResourceWriteCheck {
12
+ snapshots = /* @__PURE__ */ new Map();
13
+ timer;
14
+ world;
15
+ log;
16
+ quiet;
17
+ /** `quiet`: still idle with no frame requested, so a change nobody announced is a bug. */
18
+ constructor(world, log, quiet) {
19
+ this.world = world;
20
+ this.log = log;
21
+ this.quiet = quiet;
22
+ }
23
+ /** The app went idle: remember what the resources hold, and check them every second. */
24
+ start() {
25
+ this.snapshots.clear();
26
+ for (const def of allResources()) {
27
+ if (!def.hostWritable) continue;
28
+ const hash = hashOf(this.world.tryResource(def));
29
+ if (hash !== void 0) this.snapshots.set(def, hash);
30
+ }
31
+ this.timer ??= setInterval(() => this.verify(), CHECK_MS);
32
+ }
33
+ /** A frame is running (or the loop stopped): writes are fine again. */
34
+ stop() {
35
+ if (this.timer !== void 0) clearInterval(this.timer);
36
+ this.timer = void 0;
37
+ }
38
+ verify() {
39
+ if (!this.quiet()) return;
40
+ for (const [def, before] of this.snapshots) {
41
+ const now = hashOf(this.world.tryResource(def)) ?? "";
42
+ if (now === before) continue;
43
+ this.snapshots.set(def, now);
44
+ this.log?.log("warn", `Resource "${def.name}" changed without waking the app`, {
45
+ code: "runtime/unmarked-resource-write",
46
+ path: def.name,
47
+ hint: "Outside a frame, write resources with world.patchResource or world.touchResource (or call app.requestFrame())."
48
+ });
49
+ }
50
+ }
51
+ }
52
+ export {
53
+ ResourceWriteCheck
54
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-runtime",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -8,6 +8,10 @@
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
+ },
12
+ "./testing": {
13
+ "types": "./dist/testing.d.ts",
14
+ "default": "./dist/testing.js"
11
15
  }
12
16
  },
13
17
  "files": [
@@ -16,7 +20,7 @@
16
20
  "README.md"
17
21
  ],
18
22
  "dependencies": {
19
- "@aethervtt/shard-core": "0.0.1"
23
+ "@aethervtt/shard-core": "0.1.0"
20
24
  },
21
25
  "repository": {
22
26
  "type": "git",
@@ -28,6 +32,6 @@
28
32
  "provenance": true
29
33
  },
30
34
  "shard": {
31
- "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
35
+ "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
32
36
  }
33
37
  }