@typeonce/ritmo 0.6.0 → 0.8.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.
Files changed (148) hide show
  1. package/README.md +2 -0
  2. package/dist/Brand.d.ts +9 -4
  3. package/dist/Brand.d.ts.map +1 -1
  4. package/dist/Brand.js +9 -4
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Clock.d.ts +186 -0
  7. package/dist/Clock.d.ts.map +1 -0
  8. package/dist/Clock.js +111 -0
  9. package/dist/Clock.js.map +1 -0
  10. package/dist/Command.d.ts +250 -41
  11. package/dist/Command.d.ts.map +1 -1
  12. package/dist/Command.js +59 -23
  13. package/dist/Command.js.map +1 -1
  14. package/dist/Condition.d.ts +12 -1
  15. package/dist/Condition.d.ts.map +1 -1
  16. package/dist/Condition.js.map +1 -1
  17. package/dist/Debug.d.ts +163 -15
  18. package/dist/Debug.d.ts.map +1 -1
  19. package/dist/Debug.js.map +1 -1
  20. package/dist/Decode.d.ts +1 -1
  21. package/dist/Decode.js +1 -1
  22. package/dist/Descriptor.d.ts +67 -24
  23. package/dist/Descriptor.d.ts.map +1 -1
  24. package/dist/Descriptor.js +32 -15
  25. package/dist/Descriptor.js.map +1 -1
  26. package/dist/Entity.d.ts +22 -5
  27. package/dist/Entity.d.ts.map +1 -1
  28. package/dist/Entity.js +2 -2
  29. package/dist/Entity.js.map +1 -1
  30. package/dist/EntityScope.d.ts +26 -1
  31. package/dist/EntityScope.d.ts.map +1 -1
  32. package/dist/EntityScope.js +14 -2
  33. package/dist/EntityScope.js.map +1 -1
  34. package/dist/HostClock.d.ts +49 -0
  35. package/dist/HostClock.d.ts.map +1 -0
  36. package/dist/HostClock.js +46 -0
  37. package/dist/HostClock.js.map +1 -0
  38. package/dist/Inspector.d.ts +7 -1
  39. package/dist/Inspector.d.ts.map +1 -1
  40. package/dist/Inspector.js.map +1 -1
  41. package/dist/Machine.d.ts +67 -36
  42. package/dist/Machine.d.ts.map +1 -1
  43. package/dist/Machine.js +10 -3
  44. package/dist/Machine.js.map +1 -1
  45. package/dist/Query.d.ts +74 -26
  46. package/dist/Query.d.ts.map +1 -1
  47. package/dist/Query.js +32 -28
  48. package/dist/Query.js.map +1 -1
  49. package/dist/Runtime.d.ts +39 -16
  50. package/dist/Runtime.d.ts.map +1 -1
  51. package/dist/Runtime.js +556 -87
  52. package/dist/Runtime.js.map +1 -1
  53. package/dist/Schedule.d.ts +217 -10
  54. package/dist/Schedule.d.ts.map +1 -1
  55. package/dist/Schedule.js +5 -0
  56. package/dist/Schedule.js.map +1 -1
  57. package/dist/Schema.d.ts +196 -44
  58. package/dist/Schema.d.ts.map +1 -1
  59. package/dist/Schema.js +29 -11
  60. package/dist/Schema.js.map +1 -1
  61. package/dist/Snapshot.d.ts +14 -0
  62. package/dist/Snapshot.d.ts.map +1 -1
  63. package/dist/Snapshot.js.map +1 -1
  64. package/dist/System.d.ts +212 -58
  65. package/dist/System.d.ts.map +1 -1
  66. package/dist/System.js +84 -6
  67. package/dist/System.js.map +1 -1
  68. package/dist/index.d.ts +8 -7
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +8 -7
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/cells.d.ts +3 -3
  73. package/dist/internal/cells.d.ts.map +1 -1
  74. package/dist/internal/cells.js +9 -11
  75. package/dist/internal/cells.js.map +1 -1
  76. package/dist/internal/debug.d.ts +13 -0
  77. package/dist/internal/debug.d.ts.map +1 -1
  78. package/dist/internal/debug.js +192 -7
  79. package/dist/internal/debug.js.map +1 -1
  80. package/dist/internal/equal.d.ts +8 -0
  81. package/dist/internal/equal.d.ts.map +1 -0
  82. package/dist/internal/equal.js +38 -0
  83. package/dist/internal/equal.js.map +1 -0
  84. package/dist/internal/fragments.d.ts.map +1 -1
  85. package/dist/internal/fragments.js +11 -1
  86. package/dist/internal/fragments.js.map +1 -1
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +41 -6
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts +10 -0
  91. package/dist/internal/order.d.ts.map +1 -0
  92. package/dist/internal/order.js +122 -0
  93. package/dist/internal/order.js.map +1 -0
  94. package/dist/internal/queries.d.ts.map +1 -1
  95. package/dist/internal/queries.js +32 -10
  96. package/dist/internal/queries.js.map +1 -1
  97. package/dist/internal/requirements.d.ts +26 -0
  98. package/dist/internal/requirements.d.ts.map +1 -0
  99. package/dist/internal/requirements.js +46 -0
  100. package/dist/internal/requirements.js.map +1 -0
  101. package/package.json +1 -1
  102. package/src/Brand.ts +9 -4
  103. package/src/Clock.ts +311 -0
  104. package/src/Command.ts +407 -89
  105. package/src/Condition.ts +17 -1
  106. package/src/Debug.ts +172 -14
  107. package/src/Decode.ts +1 -1
  108. package/src/Descriptor.ts +92 -37
  109. package/src/Entity.ts +24 -5
  110. package/src/EntityScope.ts +27 -2
  111. package/src/HostClock.ts +64 -0
  112. package/src/Inspector.ts +7 -2
  113. package/src/Machine.ts +81 -37
  114. package/src/Query.ts +110 -38
  115. package/src/Runtime.ts +607 -111
  116. package/src/Schedule.ts +380 -13
  117. package/src/Schema.ts +249 -63
  118. package/src/Snapshot.ts +4 -0
  119. package/src/System.ts +248 -93
  120. package/src/index.ts +8 -7
  121. package/src/internal/cells.ts +10 -12
  122. package/src/internal/debug.ts +208 -12
  123. package/src/internal/equal.ts +31 -0
  124. package/src/internal/fragments.ts +12 -2
  125. package/src/internal/game.ts +61 -7
  126. package/src/internal/order.ts +144 -0
  127. package/src/internal/queries.ts +37 -13
  128. package/src/internal/requirements.ts +71 -0
  129. package/dist/Definition.d.ts +0 -99
  130. package/dist/Definition.d.ts.map +0 -1
  131. package/dist/Definition.js +0 -68
  132. package/dist/Definition.js.map +0 -1
  133. package/dist/Feature.d.ts +0 -106
  134. package/dist/Feature.d.ts.map +0 -1
  135. package/dist/Feature.js +0 -87
  136. package/dist/Feature.js.map +0 -1
  137. package/dist/Fx.d.ts +0 -128
  138. package/dist/Fx.d.ts.map +0 -1
  139. package/dist/Fx.js +0 -112
  140. package/dist/Fx.js.map +0 -1
  141. package/dist/internal/definition.d.ts +0 -17
  142. package/dist/internal/definition.d.ts.map +0 -1
  143. package/dist/internal/definition.js +0 -25
  144. package/dist/internal/definition.js.map +0 -1
  145. package/src/Definition.ts +0 -106
  146. package/src/Feature.ts +0 -241
  147. package/src/Fx.ts +0 -176
  148. package/src/internal/definition.ts +0 -56
package/dist/Runtime.js CHANGED
@@ -55,17 +55,21 @@
55
55
  * @groupDescription Functions
56
56
  * Public runtime constructors and helpers for services, machines, and bootstrap input.
57
57
  */
58
+ import * as HostClockModule from "./HostClock.js";
58
59
  import * as Command from "./Command.js";
59
60
  import * as DescriptorModule from "./Descriptor.js";
60
61
  import * as Cells from "./internal/cells.js";
62
+ import { sameValue } from "./internal/equal.js";
61
63
  import * as DebugInternal from "./internal/debug.js";
62
64
  import { makeQueryEngine } from "./internal/queries.js";
65
+ import { requirementIndex } from "./internal/requirements.js";
63
66
  import * as Streams from "./internal/streams.js";
64
67
  import { ABSENT, makeWorld } from "./internal/world.js";
65
68
  import * as Query from "./Query.js";
66
69
  import * as Relation from "./Relation.js";
67
70
  import * as Result from "./Result.js";
68
71
  import * as Schedule from "./Schedule.js";
72
+ import * as OrderInternal from "./internal/order.js";
69
73
  import * as EntityScopeModule from "./EntityScope.js";
70
74
  import * as Snapshot from "./Snapshot.js";
71
75
  /**
@@ -194,12 +198,12 @@ const makeValidatedRuntime = (options) => {
194
198
  */
195
199
  const pendingCommands = [];
196
200
  /**
197
- * Debug state. With `debug: true`, `pendingOrigins` names the system that
201
+ * Debug state. With `debug`, `pendingOrigins` names the system that
198
202
  * queued each pending command, in step with `pendingCommands`. Tracing is
199
203
  * on only while `observe` listeners are registered; untraced runs pay one
200
204
  * boolean check at each system, schedule, and marker boundary.
201
205
  */
202
- const debugEnabled = options.debug;
206
+ const debugEnabled = options.debug !== undefined;
203
207
  const pendingOrigins = [];
204
208
  const traceListeners = new Set();
205
209
  let tracing = false;
@@ -211,7 +215,9 @@ const makeValidatedRuntime = (options) => {
211
215
  const scheduleNames = new Map();
212
216
  const anonymousScheduleNames = new WeakMap();
213
217
  let anonymousScheduleCount = 0;
214
- const now = () => performance.now();
218
+ // Only a debug runtime times anything; the clock is the one it was given.
219
+ const hostClock = options.debug?.hostClock ?? { now: () => 0 };
220
+ const now = () => hostClock.now();
215
221
  const emit = (event) => {
216
222
  for (const listener of traceListeners)
217
223
  listener(event);
@@ -236,8 +242,52 @@ const makeValidatedRuntime = (options) => {
236
242
  const providedMachines = (options.machines ?? machines());
237
243
  const machineEntries = (options.machines ?? machines())[runtimeMachinesEntries];
238
244
  const machineDefinitionOrder = new Map(options.machineDefinitions.map((machine, index) => [machine.key, index]));
245
+ /** Machines the runtime was given an initial value for. */
246
+ const providedMachineKeys = new Set();
239
247
  for (const provision of machineEntries) {
240
248
  currentMachines.set(provision.machine.key, provision.initial);
249
+ providedMachineKeys.add(provision.machine.key);
250
+ }
251
+ /**
252
+ * Sub-states by parent machine key, in definition order, and the value
253
+ * each enters whenever its parent enters the state it exists in (its
254
+ * runtime-provided value). A sub-state has no current value while its
255
+ * parent is elsewhere, and never one when the runtime does not provide it.
256
+ */
257
+ const subStates = new Map();
258
+ const subStateEntry = new Map();
259
+ for (const machine of options.machineDefinitions) {
260
+ if (machine.within === undefined)
261
+ continue;
262
+ const siblings = subStates.get(machine.within.machine.key);
263
+ if (siblings === undefined)
264
+ subStates.set(machine.within.machine.key, [machine]);
265
+ else
266
+ siblings.push(machine);
267
+ if (!currentMachines.has(machine.key))
268
+ continue;
269
+ subStateEntry.set(machine.key, currentMachines.get(machine.key));
270
+ }
271
+ // Definition order puts a parent before its sub-states, so a sub-state of
272
+ // an inactive sub-state is cleared too.
273
+ for (const machine of options.machineDefinitions) {
274
+ if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
275
+ currentMachines.delete(machine.key);
276
+ }
277
+ }
278
+ /**
279
+ * Scope despawn triggers by machine key, in scope definition order
280
+ * (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
281
+ */
282
+ const scopeTriggers = new Map();
283
+ for (const scope of options.scopeDefinitions) {
284
+ for (const trigger of scope.despawnOn) {
285
+ const triggers = scopeTriggers.get(trigger.machine.key);
286
+ if (triggers === undefined)
287
+ scopeTriggers.set(trigger.machine.key, [{ trigger, scope }]);
288
+ else
289
+ triggers.push({ trigger, scope });
290
+ }
241
291
  }
242
292
  const appendRelationFailure = (relation, operation, sourceId, targetId, error) => {
243
293
  if (tracing) {
@@ -247,29 +297,145 @@ const makeValidatedRuntime = (options) => {
247
297
  Relation.mutationFailure(relation, operation, world.entityIdOf(sourceId), world.entityIdOf(targetId), error)
248
298
  ]);
249
299
  };
300
+ /**
301
+ * With `debug`, the relations commands have tried to relate or
302
+ * reorder, with the systems that queued those commands, for the
303
+ * `relation-failure-never-read` lint. `commandOrigin` names the system whose
304
+ * command is being applied.
305
+ */
306
+ const relationMutations = new Map();
307
+ let commandOrigin = "(unknown)";
308
+ /**
309
+ * With `debug`, what each system's applied commands did, as
310
+ * `kind:name` (`spawns:Enemy`, `despawns:Enemy` for every component the
311
+ * despawned entity had), and the systems that ran: for the
312
+ * `command-permission-unused` lint.
313
+ */
314
+ const commandUses = new Map();
315
+ const ranSystems = new Set();
316
+ const noteCommand = (kind, name) => {
317
+ let uses = commandUses.get(commandOrigin);
318
+ if (uses === undefined) {
319
+ uses = new Set();
320
+ commandUses.set(commandOrigin, uses);
321
+ }
322
+ uses.add(`${kind}:${name}`);
323
+ };
324
+ const noteRelationMutation = (relation) => {
325
+ let systems = relationMutations.get(relation.name);
326
+ if (systems === undefined) {
327
+ systems = new Set();
328
+ relationMutations.set(relation.name, systems);
329
+ }
330
+ systems.add(commandOrigin);
331
+ };
332
+ /**
333
+ * `Schema.requires`, checked when commands are applied. Types reject a
334
+ * breaking command when they can see it; this catches the rest: a query
335
+ * proof gone stale within one flush (another system's command applied in
336
+ * between), erased types, and raw ids. A breaking command is skipped
337
+ * whole, traced, and reported by the `requirement-violated` lint.
338
+ */
339
+ const requirements = requirementIndex(options.schema);
340
+ /** With `debug`, skipped commands by system, command, and requirement, for the lint. */
341
+ const requirementViolations = new Map();
342
+ const violateRequirement = (command, entity, [component, missing]) => {
343
+ if (tracing)
344
+ recordEffect({ kind: "requirementViolated", entity, command, component, missing });
345
+ if (debugEnabled) {
346
+ const key = `${commandOrigin}/${command}/${component}/${missing}`;
347
+ const seen = requirementViolations.get(key);
348
+ requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 });
349
+ }
350
+ };
351
+ const holds = (id) => {
352
+ const record = world.records.get(id);
353
+ return (descriptor) => record !== undefined && world.has(record, world.ordinalOf(descriptor));
354
+ };
355
+ /** The first requirement `components` break, as `[requirer, missing]`. */
356
+ const missingIn = (components, alsoHeld) => {
357
+ for (const [descriptor] of components) {
358
+ for (const required of requirements.requires.get(descriptor.name) ?? []) {
359
+ if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
360
+ return [descriptor.name, required.name];
361
+ }
362
+ }
363
+ }
364
+ return undefined;
365
+ };
250
366
  /**
251
367
  * World adapter used by deferred commands.
252
368
  */
253
369
  const commandWorld = {
254
370
  spawnEntity(id, components) {
371
+ if (!requirements.empty) {
372
+ const missing = missingIn(components, () => false);
373
+ if (missing !== undefined) {
374
+ violateRequirement("spawn", id, missing);
375
+ return false;
376
+ }
377
+ }
378
+ if (debugEnabled)
379
+ for (const [descriptor] of components)
380
+ noteCommand("spawns", descriptor.name);
255
381
  world.spawnEntity(id, components);
382
+ return true;
256
383
  },
257
384
  destroyEntity(id) {
385
+ if (debugEnabled) {
386
+ const record = world.records.get(id);
387
+ record?.values.forEach((value, ordinal) => {
388
+ if (value !== ABSENT)
389
+ noteCommand("despawns", world.descriptorAt(ordinal).name);
390
+ });
391
+ }
258
392
  world.destroyEntity(id);
259
393
  },
260
394
  assignEntityScope(id, scope) {
261
395
  world.assignEntityScope(id, scope.key);
262
396
  },
263
397
  destroyEntityScope(scope) {
398
+ if (debugEnabled)
399
+ noteCommand("despawnsScope", scope.name);
264
400
  world.destroyEntityScope(scope.key);
265
401
  },
266
- removeComponent(id, descriptor) {
267
- world.removeComponent(id, descriptor);
402
+ removeComponents(id, descriptors) {
403
+ if (!requirements.empty && world.records.has(id)) {
404
+ const held = holds(id);
405
+ for (const descriptor of descriptors) {
406
+ for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
407
+ if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
408
+ violateRequirement("remove", id, [requirer.name, descriptor.name]);
409
+ return;
410
+ }
411
+ }
412
+ }
413
+ }
414
+ for (const descriptor of descriptors) {
415
+ if (debugEnabled)
416
+ noteCommand("removes", descriptor.name);
417
+ world.removeComponent(id, descriptor);
418
+ }
268
419
  },
269
- writeComponent(id, descriptor, value) {
270
- world.writeComponent(id, descriptor, value);
420
+ insertComponents(id, entries) {
421
+ if (!requirements.empty && world.records.has(id)) {
422
+ const missing = missingIn(entries, holds(id));
423
+ if (missing !== undefined) {
424
+ violateRequirement("insert", id, missing);
425
+ return;
426
+ }
427
+ }
428
+ for (const [descriptor, value] of entries) {
429
+ if (debugEnabled)
430
+ noteCommand("inserts", descriptor.name);
431
+ world.writeComponent(id, descriptor, value);
432
+ }
271
433
  },
272
434
  tryRelate(id, relation, target) {
435
+ if (debugEnabled) {
436
+ noteRelationMutation(relation);
437
+ noteCommand("relates", relation.name);
438
+ }
273
439
  const result = world.tryRelate(id, relation, target);
274
440
  if (!result.ok) {
275
441
  appendRelationFailure(relation, "relate", id, target, result.error);
@@ -277,9 +443,15 @@ const makeValidatedRuntime = (options) => {
277
443
  return result;
278
444
  },
279
445
  unrelate(id, relation) {
446
+ if (debugEnabled)
447
+ noteCommand("relates", relation.name);
280
448
  world.unrelate(id, relation);
281
449
  },
282
450
  reorderChildren(id, relation, children) {
451
+ if (debugEnabled) {
452
+ noteRelationMutation(relation);
453
+ noteCommand("relates", relation.name);
454
+ }
283
455
  const result = world.reorderChildren(id, relation, children.map((child) => child));
284
456
  if (!result.ok) {
285
457
  const targetId = result.error._tag === "MissingChildEntity"
@@ -306,12 +478,6 @@ const makeValidatedRuntime = (options) => {
306
478
  };
307
479
  const makeLookup = (reader) => {
308
480
  const lookup = {
309
- get(target, query) {
310
- return resolve(target, query, reader);
311
- },
312
- getHandle(handle, query) {
313
- return resolve(handle, query, reader);
314
- },
315
481
  related: relatedTarget,
316
482
  relatedSources(target, relation) {
317
483
  if (!world.records.has(target)) {
@@ -319,19 +485,6 @@ const makeValidatedRuntime = (options) => {
319
485
  }
320
486
  return Result.success(world.relatedSourceIds(relation, target).map(entityId));
321
487
  },
322
- childMatches(target, relation, query) {
323
- if (!world.records.has(target)) {
324
- return Result.failure(Relation.missingEntityError(target));
325
- }
326
- const matches = [];
327
- for (const sourceId of world.relatedSourceIds(relation, target)) {
328
- const resolved = resolve(sourceId, query, reader);
329
- if (resolved.ok) {
330
- matches.push(resolved.value);
331
- }
332
- }
333
- return Result.success(matches);
334
- },
335
488
  parent: relatedTarget,
336
489
  ancestors(target, relation) {
337
490
  if (!world.records.has(target)) {
@@ -371,20 +524,6 @@ const makeValidatedRuntime = (options) => {
371
524
  }
372
525
  return Result.success(descendants);
373
526
  },
374
- descendantMatches(target, relation, query, options) {
375
- const descendants = lookup.descendants(target, relation, options);
376
- if (!descendants.ok) {
377
- return descendants;
378
- }
379
- const matches = [];
380
- for (const descendantId of descendants.value) {
381
- const resolved = resolve(descendantId, query, reader);
382
- if (resolved.ok) {
383
- matches.push(resolved.value);
384
- }
385
- }
386
- return Result.success(matches);
387
- },
388
527
  root(target, relation) {
389
528
  if (!world.records.has(target)) {
390
529
  return Result.failure(Relation.missingEntityError(target));
@@ -400,13 +539,34 @@ const makeValidatedRuntime = (options) => {
400
539
  };
401
540
  return lookup;
402
541
  };
403
- const makeQueryHandle = (query, reader) => {
542
+ const makeQueryHandle = (query, reader, lookup) => {
404
543
  const each = () => queries.each(query, reader.since);
544
+ const matching = (ids) => {
545
+ const matches = [];
546
+ for (const id of ids) {
547
+ const resolved = resolve(id, query, reader);
548
+ if (resolved.ok)
549
+ matches.push(resolved.value);
550
+ }
551
+ return matches;
552
+ };
405
553
  return {
406
554
  each,
407
555
  get(target) {
408
556
  return resolve(target, query, reader);
409
557
  },
558
+ getHandle(handle) {
559
+ return resolve(handle, query, reader);
560
+ },
561
+ children(target, relation) {
562
+ if (!world.records.has(target))
563
+ return Result.failure(Relation.missingEntityError(target));
564
+ return Result.success(matching(world.relatedSourceIds(relation, target).map(entityId)));
565
+ },
566
+ descendants(target, relation, options) {
567
+ const ids = lookup.descendants(target, relation, options);
568
+ return ids.ok ? Result.success(matching(ids.value)) : ids;
569
+ },
410
570
  single() {
411
571
  const matches = each();
412
572
  if (matches.length === 0) {
@@ -444,6 +604,14 @@ const makeValidatedRuntime = (options) => {
444
604
  store.set(key, value);
445
605
  };
446
606
  const writeResource = journalStoreWrite(resources, resourceOriginals);
607
+ /**
608
+ * Resource change ticks for `changedResource` readers. Only resources some
609
+ * system reads with `changedResource` are compared at commit; a reader's
610
+ * first run reports a change anyway, so writes before it registers are not
611
+ * needed.
612
+ */
613
+ const watchedResources = new Set();
614
+ const resourceChangedAt = new Map();
447
615
  const journalMachine = (key) => {
448
616
  if (!machineOriginals.has(key)) {
449
617
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue);
@@ -454,6 +622,15 @@ const makeValidatedRuntime = (options) => {
454
622
  };
455
623
  const commitSystemTransaction = () => {
456
624
  world.commitTransaction();
625
+ if (watchedResources.size > 0) {
626
+ for (const [key, before] of resourceOriginals) {
627
+ if (!watchedResources.has(key))
628
+ continue;
629
+ if (before === absentValue || !sameValue(before, resources.get(key))) {
630
+ resourceChangedAt.set(key, world.currentTick());
631
+ }
632
+ }
633
+ }
457
634
  if (emittedEvents.size > 0) {
458
635
  const published = world.advanceTick();
459
636
  for (const [key, values] of emittedEvents) {
@@ -490,6 +667,16 @@ const makeValidatedRuntime = (options) => {
490
667
  emittedEvents.clear();
491
668
  };
492
669
  const makeResourceWriteView = (descriptor, store, write) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor));
670
+ const makeResourceChangedView = (descriptor, reader) => {
671
+ const key = descriptor.key;
672
+ watchedResources.add(key);
673
+ const read = Cells.storeRead(resources, key);
674
+ return {
675
+ get: () => read.get(),
676
+ // `since` is the previous completed run; 0 before the first one.
677
+ changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
678
+ };
679
+ };
493
680
  const makeEventReadView = (descriptorKey, reader) => ({
494
681
  all() {
495
682
  return events.since(descriptorKey, reader.streamSince);
@@ -522,12 +709,18 @@ const makeValidatedRuntime = (options) => {
522
709
  return {
523
710
  all() {
524
711
  return world.removedSince(ordinal, reader.since).map(entityId);
712
+ },
713
+ lagged() {
714
+ return world.removedLagged(ordinal, reader.since, reader.registeredAt);
525
715
  }
526
716
  };
527
717
  };
528
718
  const makeDespawnedReadView = (reader) => ({
529
719
  all() {
530
720
  return world.despawnedSince(reader.since).map(entityId);
721
+ },
722
+ lagged() {
723
+ return world.despawnedLagged(reader.since, reader.registeredAt);
531
724
  }
532
725
  });
533
726
  const makeRelationFailureReadView = (relation, reader) => ({
@@ -542,15 +735,28 @@ const makeValidatedRuntime = (options) => {
542
735
  get: () => currentMachines.get(stateMachine.key),
543
736
  is: (value) => currentMachines.get(stateMachine.key) === value
544
737
  });
738
+ /** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
739
+ const queueMachine = (stateMachine, value, skipIfSame) => {
740
+ const within = stateMachine.within;
741
+ if (within !== undefined && !currentMachines.has(stateMachine.key)) {
742
+ return Result.failure({
743
+ _tag: "SubStateInactive",
744
+ machine: stateMachine.name,
745
+ parent: within.machine.name,
746
+ requires: within.state
747
+ });
748
+ }
749
+ journalMachine(stateMachine.key);
750
+ pendingMachines.set(stateMachine.key, { value, skipIfSame });
751
+ return (within === undefined ? undefined : succeeded);
752
+ };
545
753
  const makeNextMachineWriteView = (stateMachine) => ({
546
754
  getPending: () => pendingMachines.get(stateMachine.key)?.value,
547
755
  set(value) {
548
- journalMachine(stateMachine.key);
549
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false });
756
+ return queueMachine(stateMachine, value, false);
550
757
  },
551
758
  setIfChanged(value) {
552
- journalMachine(stateMachine.key);
553
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true });
759
+ return queueMachine(stateMachine, value, true);
554
760
  },
555
761
  reset() {
556
762
  journalMachine(stateMachine.key);
@@ -595,15 +801,18 @@ const makeValidatedRuntime = (options) => {
595
801
  * views the implementation is allowed to see. Views read live storage, so
596
802
  * one context is built per system and reused for every run.
597
803
  */
598
- const makeContext = (system, reader) => {
804
+ const makeContext = (system, reader, queue) => {
599
805
  const spec = system.spec;
806
+ const lookup = makeLookup(reader);
600
807
  return {
601
808
  local: spec.local?.(),
602
- queries: mapRecord(spec.queries, (query) => makeQueryHandle(query, reader)),
603
- lookup: makeLookup(reader),
809
+ queries: mapRecord(spec.queries, (query) => makeQueryHandle(query, reader, lookup)),
810
+ lookup,
604
811
  resources: mapRecord(spec.resources, (access) => access.mode === "read"
605
812
  ? Cells.storeRead(resources, access.descriptor.key)
606
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
813
+ : access.mode === "changed"
814
+ ? makeResourceChangedView(access.descriptor, reader)
815
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
607
816
  events: mapRecord(spec.events, (access) => access.mode === "read"
608
817
  ? makeEventReadView(access.descriptor.key, reader)
609
818
  : makeEventWriteView(access.descriptor.key)),
@@ -615,7 +824,8 @@ const makeValidatedRuntime = (options) => {
615
824
  despawned: mapRecord(spec.despawned, () => makeDespawnedReadView(reader)),
616
825
  relationFailures: mapRecord(spec.relationFailures, (access) => makeRelationFailureReadView(access.relation, reader)),
617
826
  services: mapRecord(spec.services, (access) => providedServices[access.descriptor.name]),
618
- commands: Command.makeCommands(() => world.allocateEntity())
827
+ // The queue serves every declared permission set; the spec's types narrow it.
828
+ commands: queue
619
829
  };
620
830
  };
621
831
  const slots = new WeakMap();
@@ -686,7 +896,7 @@ const makeValidatedRuntime = (options) => {
686
896
  system: system.name,
687
897
  outcome,
688
898
  ...(outcome === "ok" ? {} : { error }),
689
- ms: now() - started,
899
+ ms: HostClockModule.elapsed(started, now()),
690
900
  writes,
691
901
  resources: resourceWrites,
692
902
  events: emitted,
@@ -748,7 +958,8 @@ const makeValidatedRuntime = (options) => {
748
958
  let slot = slots.get(stateKey(system));
749
959
  if (!slot) {
750
960
  const reader = { since: 0, lastRun: 0, streamSince: 0, streamLastRun: 0, registeredAt: world.currentTick() };
751
- slot = { context: makeContext(system, reader), reader };
961
+ const commands = Command.makeCommands(() => world.allocateEntity());
962
+ slot = { context: makeContext(system, reader, commands), commands, reader };
752
963
  slots.set(stateKey(system), slot);
753
964
  if (holdsStreams)
754
965
  registerStreamReader(system, slot.reader);
@@ -789,7 +1000,9 @@ const makeValidatedRuntime = (options) => {
789
1000
  return succeeded;
790
1001
  }
791
1002
  }
792
- const { context, reader } = slotOf(system, true);
1003
+ const { context, commands, reader } = slotOf(system, true);
1004
+ if (debugEnabled)
1005
+ ranSystems.add(system.name);
793
1006
  // Changes stamped after the previous completed run are visible to this run.
794
1007
  reader.since = reader.lastRun;
795
1008
  reader.streamSince = reader.streamLastRun;
@@ -798,26 +1011,24 @@ const makeValidatedRuntime = (options) => {
798
1011
  beginSystemTransaction();
799
1012
  let outcome;
800
1013
  try {
801
- // A system that returns nothing cannot fail. Otherwise run its effect,
802
- // equivalent to `Fx.runSync(Fx.provide(effect, services))` without the wrapper allocations.
803
- const effect = system.run(context);
804
- outcome = effect === undefined ? succeeded : effect.run(context.services);
1014
+ // A system that returns nothing cannot fail; otherwise it returned a `Result`.
1015
+ outcome = system.run(context) ?? succeeded;
805
1016
  }
806
1017
  catch (defect) {
807
1018
  if (tracing)
808
- emit(traceSystem(system, reader, thisRun, started, "defect", defect, context.commands.flush()));
1019
+ emit(traceSystem(system, reader, thisRun, started, "defect", defect, commands.flush()));
809
1020
  rollbackSystemTransaction();
810
- context.commands.flush();
1021
+ commands.flush();
811
1022
  throw defect;
812
1023
  }
813
1024
  if (!outcome.ok) {
814
1025
  if (tracing)
815
- emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, context.commands.flush()));
1026
+ emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, commands.flush()));
816
1027
  rollbackSystemTransaction();
817
- context.commands.flush();
1028
+ commands.flush();
818
1029
  return Result.failure({ kind: "SystemFailure", system: system.name, error: outcome.error });
819
1030
  }
820
- const queued = context.commands.flush();
1031
+ const queued = commands.flush();
821
1032
  const traced = tracing ? traceSystem(system, reader, thisRun, started, "ok", undefined, queued) : undefined;
822
1033
  commitSystemTransaction();
823
1034
  // A failed run leaves `lastRun` unchanged, so the next run sees the same changes again.
@@ -840,8 +1051,15 @@ const makeValidatedRuntime = (options) => {
840
1051
  const origins = pendingOrigins.splice(0, pendingOrigins.length);
841
1052
  world.advanceTick();
842
1053
  if (!tracing) {
843
- for (const command of commands) {
844
- command.apply(commandWorld);
1054
+ if (!debugEnabled) {
1055
+ for (const command of commands) {
1056
+ command.apply(commandWorld);
1057
+ }
1058
+ return;
1059
+ }
1060
+ for (let index = 0; index < commands.length; index++) {
1061
+ commandOrigin = origins[index] ?? "(unknown)";
1062
+ commands[index].apply(commandWorld);
845
1063
  }
846
1064
  return;
847
1065
  }
@@ -850,6 +1068,7 @@ const makeValidatedRuntime = (options) => {
850
1068
  const command = commands[index];
851
1069
  const effects = [];
852
1070
  commandEffects = effects;
1071
+ commandOrigin = origins[index] ?? "(unknown)";
853
1072
  try {
854
1073
  command.apply(commandWorld);
855
1074
  }
@@ -875,7 +1094,7 @@ const makeValidatedRuntime = (options) => {
875
1094
  finally {
876
1095
  schedulePath.pop();
877
1096
  }
878
- emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: now() - started });
1097
+ emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: HostClockModule.elapsed(started, now()) });
879
1098
  return result;
880
1099
  };
881
1100
  const traceTransition = (machineKey, snapshot, outcome) => {
@@ -904,6 +1123,39 @@ const makeValidatedRuntime = (options) => {
904
1123
  activeTransitions.delete(schedule.transition.machine.key);
905
1124
  }
906
1125
  };
1126
+ /**
1127
+ * Despawns the scopes whose trigger matches a committed transition: leaving
1128
+ * `snapshot.from` or entering `snapshot.to`. It runs right after the
1129
+ * commit, so `onExit` systems still saw the members and entities spawned by
1130
+ * `onEnter` survive. Members are the entities in the scope at that moment:
1131
+ * commands queued by `onExit` systems and not yet applied are not.
1132
+ */
1133
+ const despawnTriggeredScopes = (machineKey, snapshot) => {
1134
+ const triggers = scopeTriggers.get(machineKey);
1135
+ if (triggers === undefined)
1136
+ return;
1137
+ const applied = [];
1138
+ for (const { trigger, scope } of triggers) {
1139
+ const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to;
1140
+ if (!matches)
1141
+ continue;
1142
+ const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`;
1143
+ const effects = [];
1144
+ commandEffects = tracing ? effects : undefined;
1145
+ commandOrigin = origin;
1146
+ try {
1147
+ commandWorld.destroyEntityScope(scope);
1148
+ }
1149
+ finally {
1150
+ commandEffects = undefined;
1151
+ }
1152
+ if (tracing)
1153
+ applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects });
1154
+ }
1155
+ if (applied.length > 0) {
1156
+ emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied });
1157
+ }
1158
+ };
907
1159
  /**
908
1160
  * Applies queued commands, then queued machine transitions, running the
909
1161
  * matching exit, transition, and enter schedules from the bundle.
@@ -916,6 +1168,10 @@ const makeValidatedRuntime = (options) => {
916
1168
  - (machineDefinitionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
917
1169
  const schedules = bundle?.entries ?? [];
918
1170
  for (const [machineKey, pending] of scheduledTransitions) {
1171
+ if (pendingMachines.get(machineKey) !== pending) {
1172
+ // Discarded earlier in this marker: its parent left the state it exists in.
1173
+ continue;
1174
+ }
919
1175
  pendingMachines.delete(machineKey);
920
1176
  const current = currentMachines.get(machineKey);
921
1177
  if (current === undefined) {
@@ -926,11 +1182,24 @@ const makeValidatedRuntime = (options) => {
926
1182
  traceTransition(machineKey, { from: current, to: current }, "unchanged");
927
1183
  continue;
928
1184
  }
929
- previousMachines.set(machineKey, current);
930
1185
  const snapshot = {
931
1186
  from: current,
932
1187
  to: pending.value
933
1188
  };
1189
+ // Sub-states that exist in the state being left exit first, deepest
1190
+ // first: their exit schedules run before the parent's, while they
1191
+ // still have their values, and they lose them at the parent's commit.
1192
+ const leaving = activeSubStatesIn(machineKey, snapshot.from);
1193
+ for (const sub of leaving) {
1194
+ const value = currentMachines.get(sub.key);
1195
+ const result = runPhase(sub.key, "exit", value, { from: value, to: undefined }, schedules);
1196
+ if (!result.ok) {
1197
+ pendingMachines.set(machineKey, pending);
1198
+ if (tracing)
1199
+ traceTransition(machineKey, snapshot, "failed");
1200
+ return result;
1201
+ }
1202
+ }
934
1203
  for (const schedule of schedules) {
935
1204
  const transition = schedule.transition;
936
1205
  if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
@@ -959,11 +1228,9 @@ const makeValidatedRuntime = (options) => {
959
1228
  }
960
1229
  }
961
1230
  }
962
- currentMachines.set(machineKey, pending.value);
963
- changedMachines.add(machineKey);
964
- transitionEvents.append(machineKey, world.advanceTick(), [snapshot]);
965
- if (tracing)
966
- traceTransition(machineKey, snapshot, "applied");
1231
+ for (const sub of leaving)
1232
+ commitMachine(sub.key, currentMachines.get(sub.key), undefined);
1233
+ commitMachine(machineKey, current, pending.value);
967
1234
  for (const schedule of schedules) {
968
1235
  const transition = schedule.transition;
969
1236
  if (transition.phase === "enter" && transition.machine.key === machineKey && transition.state === snapshot.to) {
@@ -975,6 +1242,79 @@ const makeValidatedRuntime = (options) => {
975
1242
  }
976
1243
  }
977
1244
  }
1245
+ // Sub-states that exist in the entered state enter their value, parents first.
1246
+ const entered = enterSubStates(machineKey, snapshot.to, schedules);
1247
+ if (!entered.ok)
1248
+ return entered;
1249
+ }
1250
+ return succeeded;
1251
+ };
1252
+ /**
1253
+ * Commits one machine's value (`undefined` for a sub-state that stops
1254
+ * existing): records the change and its transition event, traces it, and
1255
+ * despawns the scopes it triggers.
1256
+ */
1257
+ const commitMachine = (machineKey, from, to) => {
1258
+ const snapshot = { from, to };
1259
+ if (from !== undefined)
1260
+ previousMachines.set(machineKey, from);
1261
+ if (to === undefined) {
1262
+ currentMachines.delete(machineKey);
1263
+ pendingMachines.delete(machineKey);
1264
+ }
1265
+ else {
1266
+ currentMachines.set(machineKey, to);
1267
+ }
1268
+ changedMachines.add(machineKey);
1269
+ transitionEvents.append(machineKey, world.advanceTick(), [snapshot]);
1270
+ if (tracing)
1271
+ traceTransition(machineKey, snapshot, "applied");
1272
+ despawnTriggeredScopes(machineKey, snapshot);
1273
+ };
1274
+ /** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
1275
+ const runPhase = (machineKey, phase, state, snapshot, schedules) => {
1276
+ for (const schedule of schedules) {
1277
+ const transition = schedule.transition;
1278
+ if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
1279
+ const result = runTransitionSchedule(schedule, snapshot);
1280
+ if (!result.ok)
1281
+ return result;
1282
+ }
1283
+ }
1284
+ return succeeded;
1285
+ };
1286
+ /** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
1287
+ const activeSubStatesIn = (parentKey, state) => {
1288
+ const result = [];
1289
+ for (const sub of subStates.get(parentKey) ?? []) {
1290
+ const value = currentMachines.get(sub.key);
1291
+ if (sub.within.state !== state || value === undefined)
1292
+ continue;
1293
+ result.push(...activeSubStatesIn(sub.key, value), sub);
1294
+ }
1295
+ return result;
1296
+ };
1297
+ /**
1298
+ * Gives the sub-states of `parentKey` that exist in `state` their entry
1299
+ * value, then runs their `onEnter` schedules and enters their own
1300
+ * sub-states.
1301
+ */
1302
+ const enterSubStates = (parentKey, state, schedules) => {
1303
+ for (const sub of subStates.get(parentKey) ?? []) {
1304
+ if (sub.within.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key))
1305
+ continue;
1306
+ const value = subStateEntry.get(sub.key);
1307
+ commitMachine(sub.key, undefined, value);
1308
+ const snapshot = { from: undefined, to: value };
1309
+ const result = runPhase(sub.key, "enter", value, snapshot, schedules);
1310
+ if (!result.ok) {
1311
+ if (tracing)
1312
+ traceTransition(sub.key, snapshot, "enterFailed");
1313
+ return result;
1314
+ }
1315
+ const nested = enterSubStates(sub.key, value, schedules);
1316
+ if (!nested.ok)
1317
+ return nested;
978
1318
  }
979
1319
  return succeeded;
980
1320
  };
@@ -1023,6 +1363,14 @@ const makeValidatedRuntime = (options) => {
1023
1363
  };
1024
1364
  const eventsByKey = new Set(Object.values(options.schema.events).map((descriptor) => descriptor.key));
1025
1365
  const externallyWrittenEvents = new Set();
1366
+ /** `debug.observeHost` listeners: ticks and sends, without tracing. Only a debug runtime registers any. */
1367
+ const hostListeners = new Set();
1368
+ const emitHost = (event) => {
1369
+ for (const listener of hostListeners)
1370
+ listener(event);
1371
+ };
1372
+ /** Events declared as sent by the host (`debug.declareHostEvents`). */
1373
+ let hostEvents = new Set();
1026
1374
  const send = (descriptor, value) => {
1027
1375
  if (ticking)
1028
1376
  throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`);
@@ -1033,6 +1381,8 @@ const makeValidatedRuntime = (options) => {
1033
1381
  // Bound host traffic even when it sends repeatedly without ticking.
1034
1382
  events.trim(0);
1035
1383
  externallyWrittenEvents.add(descriptor.name);
1384
+ if (hostListeners.size > 0)
1385
+ emitHost({ type: "send", event: descriptor.name, value });
1036
1386
  if (tracing)
1037
1387
  emit({ type: "runtime.sent", frame: frameCount, tick: published, event: descriptor.name, value });
1038
1388
  };
@@ -1045,23 +1395,90 @@ const makeValidatedRuntime = (options) => {
1045
1395
  transitionEvents.trim(boundary);
1046
1396
  relationFailures.trim(boundary);
1047
1397
  };
1398
+ /**
1399
+ * With debug: the last failed tick result, until its `ok` is read. The
1400
+ * result handed back is a copy whose `ok` getter marks it read.
1401
+ */
1402
+ let unread;
1403
+ const reportUnread = (when) => {
1404
+ const pending = unread;
1405
+ unread = undefined;
1406
+ if (pending === undefined || pending.read)
1407
+ return;
1408
+ const event = { type: "failure.ignored", ...pending.event, when };
1409
+ emit(event);
1410
+ emitHost(event);
1411
+ };
1412
+ const watchResult = (result, schedules) => {
1413
+ if (!debugEnabled || result.ok)
1414
+ return result;
1415
+ const error = result.error;
1416
+ const system = typeof error === "object" && error !== null && error.kind === "SystemFailure"
1417
+ ? error.system
1418
+ : undefined;
1419
+ const names = schedules.flatMap((schedule) => schedule.steps.filter(Schedule.isSystemStep).map((step) => step.name));
1420
+ const at = system === undefined ? -1 : names.indexOf(system);
1421
+ const holder = system === undefined
1422
+ ? schedules[0]
1423
+ : schedules.find((schedule) => schedule.steps.some((step) => Schedule.isSystemStep(step) && step.name === system)) ?? schedules[0];
1424
+ const entry = {
1425
+ read: false,
1426
+ event: {
1427
+ frame: frameCount,
1428
+ schedule: holder === undefined ? "(none)" : scheduleName(holder),
1429
+ system,
1430
+ error,
1431
+ skipped: [...new Set(names.slice(at + 1))]
1432
+ }
1433
+ };
1434
+ unread = entry;
1435
+ return {
1436
+ get ok() {
1437
+ entry.read = true;
1438
+ return false;
1439
+ },
1440
+ error
1441
+ };
1442
+ };
1048
1443
  const tickUnsafe = (schedules) => {
1049
1444
  if (ticking)
1050
1445
  throw new Error("runtime.tick cannot run inside another tick");
1446
+ if (debugEnabled)
1447
+ reportUnread("next-tick");
1051
1448
  ticking = true;
1052
1449
  try {
1053
1450
  advanceFrame();
1054
- for (const schedule of schedules) {
1055
- const result = runScheduleUnsafe(schedule);
1056
- if (!result.ok)
1057
- return result;
1058
- }
1059
- return succeeded;
1451
+ return watchResult(observedTick(schedules, () => {
1452
+ for (const schedule of schedules) {
1453
+ const result = runScheduleUnsafe(schedule);
1454
+ if (!result.ok)
1455
+ return result;
1456
+ }
1457
+ return succeeded;
1458
+ }), schedules);
1060
1459
  }
1061
1460
  finally {
1062
1461
  ticking = false;
1063
1462
  }
1064
1463
  };
1464
+ /** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
1465
+ const observedTick = (schedules, run) => {
1466
+ if (hostListeners.size === 0)
1467
+ return run();
1468
+ emitHost({ type: "tick.start", schedules, frame: frameCount });
1469
+ let result;
1470
+ try {
1471
+ result = run();
1472
+ }
1473
+ catch (defect) {
1474
+ emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect });
1475
+ throw defect;
1476
+ }
1477
+ emitHost(result.ok
1478
+ ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
1479
+ : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error });
1480
+ return result;
1481
+ };
1065
1482
  const hasRequirement = (requirement) => {
1066
1483
  switch (requirement.kind) {
1067
1484
  case "service":
@@ -1069,21 +1486,28 @@ const makeValidatedRuntime = (options) => {
1069
1486
  case "resource":
1070
1487
  return resources.has(requirement.key);
1071
1488
  case "stateMachine":
1072
- return currentMachines.has(requirement.key);
1489
+ // An inactive sub-state has no value but is provided.
1490
+ return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key);
1073
1491
  }
1074
1492
  };
1075
1493
  const tryRunSchedule = (schedule) => {
1494
+ if (debugEnabled)
1495
+ reportUnread("next-tick");
1076
1496
  const missing = schedule.requirements
1077
1497
  .filter((requirement) => !hasRequirement(requirement))
1078
1498
  .map(({ kind, name }) => ({ kind, name }));
1079
1499
  if (missing.length > 0) {
1080
- return Result.failure({
1500
+ return watchResult(Result.failure({
1081
1501
  kind: "MissingRuntimeRequirements",
1082
1502
  requirements: missing
1083
- });
1503
+ }), [schedule]);
1504
+ }
1505
+ const issues = OrderInternal.tickOrderIssues(schedule.steps);
1506
+ if (issues.length > 0) {
1507
+ return watchResult(Result.failure({ kind: "TickOrderViolation", issues }), [schedule]);
1084
1508
  }
1085
1509
  advanceFrame();
1086
- return runScheduleUnsafe(schedule);
1510
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]);
1087
1511
  };
1088
1512
  /**
1089
1513
  * Evaluates a declared read-only projection without running a schedule.
@@ -1167,6 +1591,11 @@ const makeValidatedRuntime = (options) => {
1167
1591
  return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error });
1168
1592
  components.push([descriptor, value.value]);
1169
1593
  }
1594
+ // Transient components are never saved, so a requirement on one is not checked here.
1595
+ const missing = missingIn(components, DescriptorModule.isTransient);
1596
+ if (missing !== undefined) {
1597
+ return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] });
1598
+ }
1170
1599
  entities.push({ id: entity.id, components });
1171
1600
  }
1172
1601
  const validatedResources = [];
@@ -1208,14 +1637,34 @@ const makeValidatedRuntime = (options) => {
1208
1637
  }
1209
1638
  }
1210
1639
  }
1211
- const machineValues = [];
1640
+ const restoredMachines = new Map(currentMachines);
1212
1641
  for (const [name, value] of Object.entries(saved.machines)) {
1213
1642
  const machine = machinesByName.get(name);
1214
1643
  if (!machine)
1215
1644
  return Result.failure({ _tag: "UnknownMachine", name });
1216
1645
  if (!machine.values.includes(value))
1217
1646
  return Result.failure({ _tag: "InvalidMachineState", name, value });
1218
- machineValues.push([machine.key, value]);
1647
+ restoredMachines.set(machine.key, value);
1648
+ }
1649
+ // A save has no value for a sub-state whose parent is elsewhere; one that
1650
+ // has a value must have its parent in the state it exists in. A sub-state
1651
+ // missing while its parent is there enters its entry value.
1652
+ for (const machine of options.machineDefinitions) {
1653
+ const within = machine.within;
1654
+ if (within === undefined)
1655
+ continue;
1656
+ const active = restoredMachines.get(within.machine.key) === within.state;
1657
+ if (Object.hasOwn(saved.machines, machine.name)) {
1658
+ if (!active) {
1659
+ return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state });
1660
+ }
1661
+ }
1662
+ else if (active && subStateEntry.has(machine.key)) {
1663
+ restoredMachines.set(machine.key, subStateEntry.get(machine.key));
1664
+ }
1665
+ else {
1666
+ restoredMachines.delete(machine.key);
1667
+ }
1219
1668
  }
1220
1669
  const scoped = new Set();
1221
1670
  const memberships = [];
@@ -1247,9 +1696,12 @@ const makeValidatedRuntime = (options) => {
1247
1696
  }
1248
1697
  for (const [id, key] of memberships)
1249
1698
  world.assignEntityScope(id, key);
1250
- for (const [key, value] of validatedResources)
1699
+ for (const [key, value] of validatedResources) {
1251
1700
  resources.set(key, value);
1252
- for (const [key, value] of machineValues)
1701
+ resourceChangedAt.set(key, world.currentTick());
1702
+ }
1703
+ currentMachines.clear();
1704
+ for (const [key, value] of restoredMachines)
1253
1705
  currentMachines.set(key, value);
1254
1706
  // Locals are not saved: each system starts over from its factory.
1255
1707
  localSlots.forEach(({ slot }, index) => { slot.context.local = rebuiltLocals[index]; });
@@ -1389,12 +1841,21 @@ const makeValidatedRuntime = (options) => {
1389
1841
  for (const [name, schedule] of Object.entries(named))
1390
1842
  scheduleNames.set(schedule, name);
1391
1843
  },
1844
+ declareHostEvents(declared) {
1845
+ hostEvents = new Set(declared.map((descriptor) => descriptor.name));
1846
+ },
1392
1847
  describe: () => DebugInternal.describe({
1393
1848
  schema: options.schema,
1394
1849
  machines: [...new Set(machinesByName.values())],
1395
1850
  currentMachine: (machine) => currentMachines.get(machine.key),
1851
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
1852
+ scopes: options.scopeDefinitions,
1396
1853
  providedServices: Object.keys(providedServices),
1397
- externallyWrittenEvents,
1854
+ externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
1855
+ relationMutations,
1856
+ commandUses,
1857
+ ranSystems,
1858
+ requirementViolations: [...requirementViolations.values()],
1398
1859
  hasResource: (descriptor) => resources.has(descriptor.key),
1399
1860
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule])
1400
1861
  }),
@@ -1411,6 +1872,13 @@ const makeValidatedRuntime = (options) => {
1411
1872
  }
1412
1873
  };
1413
1874
  },
1875
+ observeHost(listener) {
1876
+ hostListeners.add(listener);
1877
+ return () => {
1878
+ hostListeners.delete(listener);
1879
+ };
1880
+ },
1881
+ checkIgnoredFailures: () => reportUnread("session-end"),
1414
1882
  streams,
1415
1883
  population: () => {
1416
1884
  const components = {};
@@ -1458,7 +1926,8 @@ export const make = (options) => {
1458
1926
  resources: validated.value,
1459
1927
  machines: options.machines,
1460
1928
  machineDefinitions: options.machineDefinitions ?? [],
1461
- debug: options.debug === true
1929
+ scopeDefinitions: options.scopeDefinitions ?? [],
1930
+ debug: options.debug
1462
1931
  });
1463
1932
  return (fallible ? Result.success(runtime) : runtime);
1464
1933
  };