@typeonce/ritmo 0.7.0 → 0.9.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 (141) hide show
  1. package/README.md +3 -2
  2. package/dist/Clock.d.ts +19 -4
  3. package/dist/Clock.d.ts.map +1 -1
  4. package/dist/Clock.js +18 -2
  5. package/dist/Clock.js.map +1 -1
  6. package/dist/Command.d.ts +181 -40
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +31 -11
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Condition.d.ts +12 -1
  11. package/dist/Condition.d.ts.map +1 -1
  12. package/dist/Condition.js.map +1 -1
  13. package/dist/Debug.d.ts +127 -13
  14. package/dist/Debug.d.ts.map +1 -1
  15. package/dist/Descriptor.d.ts +61 -10
  16. package/dist/Descriptor.d.ts.map +1 -1
  17. package/dist/Descriptor.js +22 -10
  18. package/dist/Descriptor.js.map +1 -1
  19. package/dist/Entity.d.ts +22 -7
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js.map +1 -1
  22. package/dist/EntityScope.d.ts +20 -1
  23. package/dist/EntityScope.d.ts.map +1 -1
  24. package/dist/EntityScope.js +8 -2
  25. package/dist/EntityScope.js.map +1 -1
  26. package/dist/FixedLoop.d.ts +106 -0
  27. package/dist/FixedLoop.d.ts.map +1 -0
  28. package/dist/FixedLoop.js +97 -0
  29. package/dist/FixedLoop.js.map +1 -0
  30. package/dist/HostClock.d.ts +1 -1
  31. package/dist/HostClock.d.ts.map +1 -1
  32. package/dist/HostClock.js +2 -1
  33. package/dist/HostClock.js.map +1 -1
  34. package/dist/Machine.d.ts +59 -34
  35. package/dist/Machine.d.ts.map +1 -1
  36. package/dist/Machine.js +10 -3
  37. package/dist/Machine.js.map +1 -1
  38. package/dist/Query.d.ts +100 -14
  39. package/dist/Query.d.ts.map +1 -1
  40. package/dist/Query.js +34 -5
  41. package/dist/Query.js.map +1 -1
  42. package/dist/Relation.d.ts +5 -14
  43. package/dist/Relation.d.ts.map +1 -1
  44. package/dist/Relation.js +0 -32
  45. package/dist/Relation.js.map +1 -1
  46. package/dist/Runtime.d.ts +61 -15
  47. package/dist/Runtime.d.ts.map +1 -1
  48. package/dist/Runtime.js +594 -70
  49. package/dist/Runtime.js.map +1 -1
  50. package/dist/Schedule.d.ts +24 -15
  51. package/dist/Schedule.d.ts.map +1 -1
  52. package/dist/Schedule.js +2 -1
  53. package/dist/Schedule.js.map +1 -1
  54. package/dist/Schema.d.ts +233 -48
  55. package/dist/Schema.d.ts.map +1 -1
  56. package/dist/Schema.js +33 -7
  57. package/dist/Schema.js.map +1 -1
  58. package/dist/Snapshot.d.ts +90 -1
  59. package/dist/Snapshot.d.ts.map +1 -1
  60. package/dist/Snapshot.js +42 -1
  61. package/dist/Snapshot.js.map +1 -1
  62. package/dist/System.d.ts +132 -31
  63. package/dist/System.d.ts.map +1 -1
  64. package/dist/System.js +32 -0
  65. package/dist/System.js.map +1 -1
  66. package/dist/index.d.ts +4 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/internal/cells.d.ts +2 -0
  71. package/dist/internal/cells.d.ts.map +1 -1
  72. package/dist/internal/cells.js +34 -8
  73. package/dist/internal/cells.js.map +1 -1
  74. package/dist/internal/debug.d.ts +10 -0
  75. package/dist/internal/debug.d.ts.map +1 -1
  76. package/dist/internal/debug.js +171 -12
  77. package/dist/internal/debug.js.map +1 -1
  78. package/dist/internal/equal.d.ts +8 -0
  79. package/dist/internal/equal.d.ts.map +1 -0
  80. package/dist/internal/equal.js +38 -0
  81. package/dist/internal/equal.js.map +1 -0
  82. package/dist/internal/fragments.d.ts +2 -6
  83. package/dist/internal/fragments.d.ts.map +1 -1
  84. package/dist/internal/fragments.js +36 -3
  85. package/dist/internal/fragments.js.map +1 -1
  86. package/dist/internal/game.d.ts +2 -0
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +48 -7
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts.map +1 -1
  91. package/dist/internal/order.js +4 -2
  92. package/dist/internal/order.js.map +1 -1
  93. package/dist/internal/queries.d.ts.map +1 -1
  94. package/dist/internal/queries.js +57 -26
  95. package/dist/internal/queries.js.map +1 -1
  96. package/dist/internal/relations.d.ts +10 -0
  97. package/dist/internal/relations.d.ts.map +1 -0
  98. package/dist/internal/relations.js +32 -0
  99. package/dist/internal/relations.js.map +1 -0
  100. package/dist/internal/requirements.d.ts +26 -0
  101. package/dist/internal/requirements.d.ts.map +1 -0
  102. package/dist/internal/requirements.js +46 -0
  103. package/dist/internal/requirements.js.map +1 -0
  104. package/dist/internal/streams.d.ts +2 -0
  105. package/dist/internal/streams.d.ts.map +1 -1
  106. package/dist/internal/streams.js +10 -0
  107. package/dist/internal/streams.js.map +1 -1
  108. package/dist/internal/world.d.ts +7 -1
  109. package/dist/internal/world.d.ts.map +1 -1
  110. package/dist/internal/world.js +53 -3
  111. package/dist/internal/world.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/Clock.ts +29 -4
  114. package/src/Command.ts +281 -67
  115. package/src/Condition.ts +17 -1
  116. package/src/Debug.ts +124 -15
  117. package/src/Descriptor.ts +92 -21
  118. package/src/Entity.ts +23 -7
  119. package/src/EntityScope.ts +21 -2
  120. package/src/FixedLoop.ts +218 -0
  121. package/src/HostClock.ts +2 -1
  122. package/src/Machine.ts +72 -35
  123. package/src/Query.ts +118 -17
  124. package/src/Relation.ts +5 -67
  125. package/src/Runtime.ts +659 -85
  126. package/src/Schedule.ts +31 -19
  127. package/src/Schema.ts +297 -57
  128. package/src/Snapshot.ts +94 -2
  129. package/src/System.ts +151 -29
  130. package/src/index.ts +4 -0
  131. package/src/internal/cells.ts +42 -10
  132. package/src/internal/debug.ts +179 -14
  133. package/src/internal/equal.ts +31 -0
  134. package/src/internal/fragments.ts +35 -5
  135. package/src/internal/game.ts +69 -7
  136. package/src/internal/order.ts +8 -4
  137. package/src/internal/queries.ts +62 -12
  138. package/src/internal/relations.ts +52 -0
  139. package/src/internal/requirements.ts +71 -0
  140. package/src/internal/streams.ts +11 -0
  141. package/src/internal/world.ts +60 -4
package/dist/Runtime.js CHANGED
@@ -23,7 +23,8 @@
23
23
  * Game.Runtime.service(Random, { next: Math.random })
24
24
  * )
25
25
  *
26
- * // Viewport is a constructed resource, so the runtime comes back as a Result.
26
+ * // make always returns a Result. Viewport is a constructed resource, so
27
+ * // validating it can fail and the Result must be checked.
27
28
  * const runtime = Game.Runtime.make({
28
29
  * services,
29
30
  * resources: {
@@ -59,8 +60,10 @@ import * as HostClockModule from "./HostClock.js";
59
60
  import * as Command from "./Command.js";
60
61
  import * as DescriptorModule from "./Descriptor.js";
61
62
  import * as Cells from "./internal/cells.js";
63
+ import { sameValue } from "./internal/equal.js";
62
64
  import * as DebugInternal from "./internal/debug.js";
63
65
  import { makeQueryEngine } from "./internal/queries.js";
66
+ import { requirementIndex } from "./internal/requirements.js";
64
67
  import * as Streams from "./internal/streams.js";
65
68
  import { ABSENT, makeWorld } from "./internal/world.js";
66
69
  import * as Query from "./Query.js";
@@ -147,7 +150,7 @@ const collectConstructedRegistryValues = (registry, provided) => {
147
150
  * resources: {
148
151
  * score: 0
149
152
  * }
150
- * })
153
+ * }).value
151
154
  * ```
152
155
  */
153
156
  /**
@@ -157,6 +160,25 @@ const collectConstructedRegistryValues = (registry, provided) => {
157
160
  * `lagged() === true` (or a `missed` read in the debug trace).
158
161
  */
159
162
  export const streamCapacity = 65_536;
163
+ /**
164
+ * Brings a parsed save to `migrations.latest`: runs every step after its
165
+ * game version, checking the structure after each.
166
+ */
167
+ const migrateSave = (save, saves) => {
168
+ const latest = saves?.latest ?? 0;
169
+ if (save.gameVersion > latest)
170
+ return Result.failure({ _tag: "NewerGameVersion", gameVersion: save.gameVersion, latest });
171
+ let current = save;
172
+ for (const step of saves?.steps ?? []) {
173
+ if (step.to <= current.gameVersion)
174
+ continue;
175
+ const next = Snapshot.parse({ ...step.migrate(current), gameVersion: step.to });
176
+ if (!next.ok)
177
+ return next;
178
+ current = next.value;
179
+ }
180
+ return Result.success(current);
181
+ };
160
182
  const makeValidatedRuntime = (options) => {
161
183
  const world = makeWorld(options.schema, streamCapacity);
162
184
  const queries = makeQueryEngine(world);
@@ -169,7 +191,10 @@ const makeValidatedRuntime = (options) => {
169
191
  * Machine-keyed committed, pending, and previous values.
170
192
  */
171
193
  const currentMachines = new Map();
194
+ /** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
172
195
  const pendingMachines = new Map();
196
+ /** Requests dropped because their sub-state stopped existing: skipped by the marker that queued them. */
197
+ const discardedPending = new WeakSet();
173
198
  const previousMachines = new Map();
174
199
  /**
175
200
  * Machine keys changed by transitions applied during the current schedule run.
@@ -240,8 +265,52 @@ const makeValidatedRuntime = (options) => {
240
265
  const providedMachines = (options.machines ?? machines());
241
266
  const machineEntries = (options.machines ?? machines())[runtimeMachinesEntries];
242
267
  const machineDefinitionOrder = new Map(options.machineDefinitions.map((machine, index) => [machine.key, index]));
268
+ /** Machines the runtime was given an initial value for. */
269
+ const providedMachineKeys = new Set();
243
270
  for (const provision of machineEntries) {
244
271
  currentMachines.set(provision.machine.key, provision.initial);
272
+ providedMachineKeys.add(provision.machine.key);
273
+ }
274
+ /**
275
+ * Sub-states by parent machine key, in definition order, and the value
276
+ * each enters whenever its parent enters the state it exists in (its
277
+ * runtime-provided value). A sub-state has no current value while its
278
+ * parent is elsewhere, and never one when the runtime does not provide it.
279
+ */
280
+ const subStates = new Map();
281
+ const subStateEntry = new Map();
282
+ for (const machine of options.machineDefinitions) {
283
+ if (machine.within === undefined)
284
+ continue;
285
+ const siblings = subStates.get(machine.within.machine.key);
286
+ if (siblings === undefined)
287
+ subStates.set(machine.within.machine.key, [machine]);
288
+ else
289
+ siblings.push(machine);
290
+ if (!currentMachines.has(machine.key))
291
+ continue;
292
+ subStateEntry.set(machine.key, currentMachines.get(machine.key));
293
+ }
294
+ // Definition order puts a parent before its sub-states, so a sub-state of
295
+ // an inactive sub-state is cleared too.
296
+ for (const machine of options.machineDefinitions) {
297
+ if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
298
+ currentMachines.delete(machine.key);
299
+ }
300
+ }
301
+ /**
302
+ * Scope despawn triggers by machine key, in scope definition order
303
+ * (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
304
+ */
305
+ const scopeTriggers = new Map();
306
+ for (const scope of options.scopeDefinitions) {
307
+ for (const trigger of scope.despawnOn) {
308
+ const triggers = scopeTriggers.get(trigger.machine.key);
309
+ if (triggers === undefined)
310
+ scopeTriggers.set(trigger.machine.key, [{ trigger, scope }]);
311
+ else
312
+ triggers.push({ trigger, scope });
313
+ }
245
314
  }
246
315
  const appendRelationFailure = (relation, operation, sourceId, targetId, error) => {
247
316
  if (tracing) {
@@ -267,6 +336,10 @@ const makeValidatedRuntime = (options) => {
267
336
  */
268
337
  const commandUses = new Map();
269
338
  const ranSystems = new Set();
339
+ /** With `debug`, the system running now: who queued a next state. */
340
+ let runningSystem = "(host)";
341
+ /** With `debug`, next states one system queued and another replaced before the marker. */
342
+ const nextStateReplacements = new Map();
270
343
  const noteCommand = (kind, name) => {
271
344
  let uses = commandUses.get(commandOrigin);
272
345
  if (uses === undefined) {
@@ -283,15 +356,92 @@ const makeValidatedRuntime = (options) => {
283
356
  }
284
357
  systems.add(commandOrigin);
285
358
  };
359
+ /**
360
+ * `Schema.requires`, checked when commands are applied. Types reject a
361
+ * breaking command when they can see it; this catches the rest: a query
362
+ * proof gone stale within one flush (another system's command applied in
363
+ * between), erased types, and raw ids. A breaking command is skipped
364
+ * whole, traced, and reported by the `requirement-violated` lint.
365
+ */
366
+ const requirements = requirementIndex(options.schema);
367
+ /** With `debug`, skipped commands by system, command, and requirement, for the lint. */
368
+ const requirementViolations = new Map();
369
+ const violateRequirement = (command, entity, [component, missing]) => {
370
+ if (tracing)
371
+ recordEffect({ kind: "requirementViolated", entity, command, component, missing });
372
+ if (debugEnabled) {
373
+ const key = `${commandOrigin}/${command}/${component}/${missing}`;
374
+ const seen = requirementViolations.get(key);
375
+ requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 });
376
+ }
377
+ };
378
+ const streamLosses = new Map();
379
+ const watchStreamLoss = (view, system, stream, paused) => {
380
+ const key = `${system}/${stream}`;
381
+ const entry = () => {
382
+ let found = streamLosses.get(key);
383
+ if (found === undefined) {
384
+ found = { system, stream, lostRuns: 0, checksLagged: false, paused };
385
+ streamLosses.set(key, found);
386
+ }
387
+ return found;
388
+ };
389
+ return {
390
+ ...view,
391
+ all: () => {
392
+ const status = entry();
393
+ if (view.lagged())
394
+ status.lostRuns += 1;
395
+ return view.all();
396
+ },
397
+ lagged: () => {
398
+ entry().checksLagged = true;
399
+ return view.lagged();
400
+ }
401
+ };
402
+ };
403
+ const staleTargets = new Map();
404
+ const reportStaleTarget = (command, entity) => {
405
+ if (tracing)
406
+ recordEffect({ kind: "staleTarget", entity, command });
407
+ if (debugEnabled) {
408
+ const key = `${commandOrigin}/${command}`;
409
+ const seen = staleTargets.get(key);
410
+ staleTargets.set(key, { system: commandOrigin, command, entity, count: (seen?.count ?? 0) + 1 });
411
+ }
412
+ };
413
+ const holds = (id) => {
414
+ const record = world.records.get(id);
415
+ return (descriptor) => record !== undefined && world.has(record, world.ordinalOf(descriptor));
416
+ };
417
+ /** The first requirement `components` break, as `[requirer, missing]`. */
418
+ const missingIn = (components, alsoHeld) => {
419
+ for (const [descriptor] of components) {
420
+ for (const required of requirements.requires.get(descriptor.name) ?? []) {
421
+ if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
422
+ return [descriptor.name, required.name];
423
+ }
424
+ }
425
+ }
426
+ return undefined;
427
+ };
286
428
  /**
287
429
  * World adapter used by deferred commands.
288
430
  */
289
431
  const commandWorld = {
290
432
  spawnEntity(id, components) {
433
+ if (!requirements.empty) {
434
+ const missing = missingIn(components, () => false);
435
+ if (missing !== undefined) {
436
+ violateRequirement("spawn", id, missing);
437
+ return false;
438
+ }
439
+ }
291
440
  if (debugEnabled)
292
441
  for (const [descriptor] of components)
293
442
  noteCommand("spawns", descriptor.name);
294
443
  world.spawnEntity(id, components);
444
+ return true;
295
445
  },
296
446
  destroyEntity(id) {
297
447
  if (debugEnabled) {
@@ -311,15 +461,40 @@ const makeValidatedRuntime = (options) => {
311
461
  noteCommand("despawnsScope", scope.name);
312
462
  world.destroyEntityScope(scope.key);
313
463
  },
314
- removeComponent(id, descriptor) {
315
- if (debugEnabled)
316
- noteCommand("removes", descriptor.name);
317
- world.removeComponent(id, descriptor);
464
+ removeComponents(id, descriptors) {
465
+ if (!requirements.empty && world.records.has(id)) {
466
+ const held = holds(id);
467
+ for (const descriptor of descriptors) {
468
+ for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
469
+ if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
470
+ violateRequirement("remove", id, [requirer.name, descriptor.name]);
471
+ return;
472
+ }
473
+ }
474
+ }
475
+ }
476
+ for (const descriptor of descriptors) {
477
+ if (debugEnabled)
478
+ noteCommand("removes", descriptor.name);
479
+ world.removeComponent(id, descriptor);
480
+ }
318
481
  },
319
- writeComponent(id, descriptor, value) {
320
- if (debugEnabled)
321
- noteCommand("inserts", descriptor.name);
322
- world.writeComponent(id, descriptor, value);
482
+ insertComponents(id, entries) {
483
+ if (!requirements.empty && world.records.has(id)) {
484
+ const missing = missingIn(entries, holds(id));
485
+ if (missing !== undefined) {
486
+ violateRequirement("insert", id, missing);
487
+ return;
488
+ }
489
+ }
490
+ for (const [descriptor, value] of entries) {
491
+ if (debugEnabled)
492
+ noteCommand("inserts", descriptor.name);
493
+ world.writeComponent(id, descriptor, value);
494
+ }
495
+ },
496
+ reportStaleTarget(command, id) {
497
+ reportStaleTarget(command, id);
323
498
  },
324
499
  tryRelate(id, relation, target) {
325
500
  if (debugEnabled) {
@@ -487,6 +662,13 @@ const makeValidatedRuntime = (options) => {
487
662
  const resourceOriginals = new Map();
488
663
  const machineOriginals = new Map();
489
664
  const emittedEvents = new Map();
665
+ let phase;
666
+ /** Keeps a reader's position from before the phase, the first time the phase moves it. */
667
+ const journalReader = (reader) => {
668
+ if (phase !== undefined && !phase.readers.has(reader)) {
669
+ phase.readers.set(reader, [reader.since, reader.lastRun, reader.streamSince, reader.streamLastRun]);
670
+ }
671
+ };
490
672
  const journalStoreWrite = (store, originals) => (key, value) => {
491
673
  if (!originals.has(key)) {
492
674
  originals.set(key, store.has(key) ? store.get(key) : absentValue);
@@ -494,6 +676,14 @@ const makeValidatedRuntime = (options) => {
494
676
  store.set(key, value);
495
677
  };
496
678
  const writeResource = journalStoreWrite(resources, resourceOriginals);
679
+ /**
680
+ * Resource change ticks for `changedResource` readers. Only resources some
681
+ * system reads with `changedResource` are compared at commit; a reader's
682
+ * first run reports a change anyway, so writes before it registers are not
683
+ * needed.
684
+ */
685
+ const watchedResources = new Set();
686
+ const resourceChangedAt = new Map();
497
687
  const journalMachine = (key) => {
498
688
  if (!machineOriginals.has(key)) {
499
689
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue);
@@ -504,6 +694,27 @@ const makeValidatedRuntime = (options) => {
504
694
  };
505
695
  const commitSystemTransaction = () => {
506
696
  world.commitTransaction();
697
+ if (phase !== undefined) {
698
+ for (const [key, before] of resourceOriginals) {
699
+ if (!phase.resources.has(key))
700
+ phase.resources.set(key, before);
701
+ }
702
+ for (const [key, before] of machineOriginals) {
703
+ if (!phase.machines.has(key))
704
+ phase.machines.set(key, before);
705
+ }
706
+ }
707
+ if (watchedResources.size > 0) {
708
+ for (const [key, before] of resourceOriginals) {
709
+ if (!watchedResources.has(key))
710
+ continue;
711
+ if (before === absentValue || !sameValue(before, resources.get(key))) {
712
+ if (phase !== undefined && !phase.resourceChangedAt.has(key))
713
+ phase.resourceChangedAt.set(key, resourceChangedAt.get(key));
714
+ resourceChangedAt.set(key, world.currentTick());
715
+ }
716
+ }
717
+ }
507
718
  if (emittedEvents.size > 0) {
508
719
  const published = world.advanceTick();
509
720
  for (const [key, values] of emittedEvents) {
@@ -539,7 +750,70 @@ const makeValidatedRuntime = (options) => {
539
750
  machineOriginals.clear();
540
751
  emittedEvents.clear();
541
752
  };
753
+ /**
754
+ * Runs `run` as one phase: on failure, or a thrown defect, everything the
755
+ * systems in it committed is undone.
756
+ */
757
+ const runAsPhase = (run) => {
758
+ const opened = {
759
+ tick: world.currentTick(),
760
+ commands: pendingCommands.length,
761
+ origins: pendingOrigins.length,
762
+ resources: new Map(),
763
+ resourceChangedAt: new Map(),
764
+ machines: new Map(),
765
+ readers: new Map()
766
+ };
767
+ phase = opened;
768
+ world.beginPhase();
769
+ let result;
770
+ try {
771
+ result = run();
772
+ }
773
+ finally {
774
+ phase = undefined;
775
+ if (result?.ok === true) {
776
+ world.commitPhase();
777
+ }
778
+ else {
779
+ world.rollbackPhase();
780
+ restoreStore(resources, opened.resources);
781
+ for (const [key, value] of opened.machines) {
782
+ if (value === absentValue)
783
+ pendingMachines.delete(key);
784
+ else
785
+ pendingMachines.set(key, value);
786
+ }
787
+ for (const [key, tick] of opened.resourceChangedAt) {
788
+ if (tick === undefined)
789
+ resourceChangedAt.delete(key);
790
+ else
791
+ resourceChangedAt.set(key, tick);
792
+ }
793
+ for (const [reader, [since, lastRun, streamSince, streamLastRun]] of opened.readers) {
794
+ reader.since = since;
795
+ reader.lastRun = lastRun;
796
+ reader.streamSince = streamSince;
797
+ reader.streamLastRun = streamLastRun;
798
+ }
799
+ events.retract(opened.tick);
800
+ pendingCommands.length = opened.commands;
801
+ pendingOrigins.length = opened.origins;
802
+ }
803
+ }
804
+ return result;
805
+ };
542
806
  const makeResourceWriteView = (descriptor, store, write) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor));
807
+ const makeResourceChangedView = (descriptor, reader) => {
808
+ const key = descriptor.key;
809
+ watchedResources.add(key);
810
+ const read = Cells.storeRead(resources, key);
811
+ return {
812
+ get: () => read.get(),
813
+ // `since` is the previous completed run; 0 before the first one.
814
+ changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
815
+ };
816
+ };
543
817
  const makeEventReadView = (descriptorKey, reader) => ({
544
818
  all() {
545
819
  return events.since(descriptorKey, reader.streamSince);
@@ -598,15 +872,45 @@ const makeValidatedRuntime = (options) => {
598
872
  get: () => currentMachines.get(stateMachine.key),
599
873
  is: (value) => currentMachines.get(stateMachine.key) === value
600
874
  });
875
+ /** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
876
+ const queueMachine = (stateMachine, value, skipIfSame) => {
877
+ const within = stateMachine.within;
878
+ if (within !== undefined && !currentMachines.has(stateMachine.key)) {
879
+ return Result.failure({
880
+ _tag: "SubStateInactive",
881
+ machine: stateMachine.name,
882
+ parent: within.machine.name,
883
+ requires: within.state
884
+ });
885
+ }
886
+ journalMachine(stateMachine.key);
887
+ if (debugEnabled) {
888
+ const previous = pendingMachines.get(stateMachine.key);
889
+ // A set from a transition schedule waits for the next marker: it replaces nothing queued for this one.
890
+ if (previous?.by !== undefined && previous.by !== runningSystem && previous.value !== value && activeTransitions.size === 0) {
891
+ const key = `${stateMachine.name}/${previous.by}/${runningSystem}/${String(previous.value)}/${String(value)}`;
892
+ const seen = nextStateReplacements.get(key);
893
+ nextStateReplacements.set(key, {
894
+ machine: stateMachine.name,
895
+ replaced: { system: previous.by, value: previous.value },
896
+ by: { system: runningSystem, value: value },
897
+ count: (seen?.count ?? 0) + 1
898
+ });
899
+ }
900
+ pendingMachines.set(stateMachine.key, { value, skipIfSame, by: runningSystem });
901
+ }
902
+ else {
903
+ pendingMachines.set(stateMachine.key, { value, skipIfSame });
904
+ }
905
+ return (within === undefined ? undefined : succeeded);
906
+ };
601
907
  const makeNextMachineWriteView = (stateMachine) => ({
602
908
  getPending: () => pendingMachines.get(stateMachine.key)?.value,
603
909
  set(value) {
604
- journalMachine(stateMachine.key);
605
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false });
910
+ return queueMachine(stateMachine, value, false);
606
911
  },
607
912
  setIfChanged(value) {
608
- journalMachine(stateMachine.key);
609
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true });
913
+ return queueMachine(stateMachine, value, true);
610
914
  },
611
915
  reset() {
612
916
  journalMachine(stateMachine.key);
@@ -654,23 +958,29 @@ const makeValidatedRuntime = (options) => {
654
958
  const makeContext = (system, reader, queue) => {
655
959
  const spec = system.spec;
656
960
  const lookup = makeLookup(reader);
961
+ const paused = spec.pauseWhen.length > 0;
962
+ /** On a debug runtime, records reads after a loss and calls of `lagged()`, for the `stream-loss-unread` lint. */
963
+ const watch = (view, stream) => debugEnabled ? watchStreamLoss(view, system.name, stream, paused) : view;
657
964
  return {
658
965
  local: spec.local?.(),
966
+ // A filtered query hands out the same object; its type hides the lookups.
659
967
  queries: mapRecord(spec.queries, (query) => makeQueryHandle(query, reader, lookup)),
660
968
  lookup,
661
969
  resources: mapRecord(spec.resources, (access) => access.mode === "read"
662
970
  ? Cells.storeRead(resources, access.descriptor.key)
663
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
971
+ : access.mode === "changed"
972
+ ? makeResourceChangedView(access.descriptor, reader)
973
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
664
974
  events: mapRecord(spec.events, (access) => access.mode === "read"
665
- ? makeEventReadView(access.descriptor.key, reader)
975
+ ? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
666
976
  : makeEventWriteView(access.descriptor.key)),
667
977
  machines: mapRecord(spec.machines, (access) => makeMachineReadView(access.machine)),
668
978
  nextMachines: mapRecord(spec.nextMachines, (access) => makeNextMachineWriteView(access.machine)),
669
- transitionEvents: mapRecord(spec.transitionEvents, (access) => makeTransitionEventReadView(access.machine, reader)),
979
+ transitionEvents: mapRecord(spec.transitionEvents, (access) => watch(makeTransitionEventReadView(access.machine, reader), `transition events ${access.machine.name}`)),
670
980
  transitions: mapRecord(spec.transitions, (access) => makeTransitionReadView(access.machine)),
671
- removed: mapRecord(spec.removed, (access) => makeRemovedReadView(access.descriptor, reader)),
672
- despawned: mapRecord(spec.despawned, () => makeDespawnedReadView(reader)),
673
- relationFailures: mapRecord(spec.relationFailures, (access) => makeRelationFailureReadView(access.relation, reader)),
981
+ removed: mapRecord(spec.removed, (access) => watch(makeRemovedReadView(access.descriptor, reader), `removed ${access.descriptor.name}`)),
982
+ despawned: mapRecord(spec.despawned, () => watch(makeDespawnedReadView(reader), "despawned")),
983
+ relationFailures: mapRecord(spec.relationFailures, (access) => watch(makeRelationFailureReadView(access.relation, reader), `relation failures ${access.relation.name}`)),
674
984
  services: mapRecord(spec.services, (access) => providedServices[access.descriptor.name]),
675
985
  // The queue serves every declared permission set; the spec's types narrow it.
676
986
  commands: queue
@@ -833,6 +1143,7 @@ const makeValidatedRuntime = (options) => {
833
1143
  const skipped = slotOf(system, true);
834
1144
  if (tracing)
835
1145
  traceSkipped(system, condition, skipped.reader, false);
1146
+ journalReader(skipped.reader);
836
1147
  skipped.reader.streamLastRun = world.currentTick();
837
1148
  return succeeded;
838
1149
  }
@@ -849,8 +1160,11 @@ const makeValidatedRuntime = (options) => {
849
1160
  }
850
1161
  }
851
1162
  const { context, commands, reader } = slotOf(system, true);
852
- if (debugEnabled)
1163
+ if (debugEnabled) {
853
1164
  ranSystems.add(system.name);
1165
+ runningSystem = system.name;
1166
+ }
1167
+ journalReader(reader);
854
1168
  // Changes stamped after the previous completed run are visible to this run.
855
1169
  reader.since = reader.lastRun;
856
1170
  reader.streamSince = reader.streamLastRun;
@@ -971,6 +1285,39 @@ const makeValidatedRuntime = (options) => {
971
1285
  activeTransitions.delete(schedule.transition.machine.key);
972
1286
  }
973
1287
  };
1288
+ /**
1289
+ * Despawns the scopes whose trigger matches a committed transition: leaving
1290
+ * `snapshot.from` or entering `snapshot.to`. It runs right after the
1291
+ * commit, so `onExit` systems still saw the members and entities spawned by
1292
+ * `onEnter` survive. Members are the entities in the scope at that moment:
1293
+ * commands queued by `onExit` systems and not yet applied are not.
1294
+ */
1295
+ const despawnTriggeredScopes = (machineKey, snapshot) => {
1296
+ const triggers = scopeTriggers.get(machineKey);
1297
+ if (triggers === undefined)
1298
+ return;
1299
+ const applied = [];
1300
+ for (const { trigger, scope } of triggers) {
1301
+ const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to;
1302
+ if (!matches)
1303
+ continue;
1304
+ const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`;
1305
+ const effects = [];
1306
+ commandEffects = tracing ? effects : undefined;
1307
+ commandOrigin = origin;
1308
+ try {
1309
+ commandWorld.destroyEntityScope(scope);
1310
+ }
1311
+ finally {
1312
+ commandEffects = undefined;
1313
+ }
1314
+ if (tracing)
1315
+ applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects });
1316
+ }
1317
+ if (applied.length > 0) {
1318
+ emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied });
1319
+ }
1320
+ };
974
1321
  /**
975
1322
  * Applies queued commands, then queued machine transitions, running the
976
1323
  * matching exit, transition, and enter schedules from the bundle.
@@ -983,7 +1330,19 @@ const makeValidatedRuntime = (options) => {
983
1330
  - (machineDefinitionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
984
1331
  const schedules = bundle?.entries ?? [];
985
1332
  for (const [machineKey, pending] of scheduledTransitions) {
986
- pendingMachines.delete(machineKey);
1333
+ if (discardedPending.has(pending)) {
1334
+ // Discarded earlier in this marker: its parent left the state it exists in.
1335
+ continue;
1336
+ }
1337
+ // A transition schedule earlier in this marker may have queued a newer
1338
+ // value for this machine: that one waits for the next marker.
1339
+ if (pendingMachines.get(machineKey) === pending)
1340
+ pendingMachines.delete(machineKey);
1341
+ /** Keeps this request queued after a failure, unless a newer one replaced it. */
1342
+ const requeue = () => {
1343
+ if (!pendingMachines.has(machineKey))
1344
+ pendingMachines.set(machineKey, pending);
1345
+ };
987
1346
  const current = currentMachines.get(machineKey);
988
1347
  if (current === undefined) {
989
1348
  continue;
@@ -993,44 +1352,53 @@ const makeValidatedRuntime = (options) => {
993
1352
  traceTransition(machineKey, { from: current, to: current }, "unchanged");
994
1353
  continue;
995
1354
  }
996
- previousMachines.set(machineKey, current);
997
1355
  const snapshot = {
998
1356
  from: current,
999
1357
  to: pending.value
1000
1358
  };
1001
- for (const schedule of schedules) {
1002
- const transition = schedule.transition;
1003
- if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
1004
- const result = runTransitionSchedule(schedule, snapshot);
1005
- if (!result.ok) {
1006
- // The transition did not happen; keep it queued for a later attempt.
1007
- pendingMachines.set(machineKey, pending);
1008
- if (tracing)
1009
- traceTransition(machineKey, snapshot, "failed");
1359
+ // Sub-states that exist in the state being left exit first, deepest
1360
+ // first: their exit schedules run before the parent's, while they
1361
+ // still have their values, and they lose them at the parent's commit.
1362
+ // The exit phase is one transaction: a failure undoes all of it, and
1363
+ // the transition stays queued for a later attempt.
1364
+ const leaving = activeSubStatesIn(machineKey, snapshot.from);
1365
+ const exited = runAsPhase(() => {
1366
+ for (const sub of leaving) {
1367
+ const value = currentMachines.get(sub.key);
1368
+ const result = runPhase(sub.key, "exit", value, { from: value, to: undefined }, schedules);
1369
+ if (!result.ok)
1010
1370
  return result;
1011
- }
1012
1371
  }
1013
- }
1014
- for (const schedule of schedules) {
1015
- const transition = schedule.transition;
1016
- if (transition.phase === "transition"
1017
- && transition.machine.key === machineKey
1018
- && transition.from === snapshot.from
1019
- && transition.to === snapshot.to) {
1020
- const result = runTransitionSchedule(schedule, snapshot);
1021
- if (!result.ok) {
1022
- pendingMachines.set(machineKey, pending);
1023
- if (tracing)
1024
- traceTransition(machineKey, snapshot, "failed");
1025
- return result;
1372
+ const left = runPhase(machineKey, "exit", snapshot.from, snapshot, schedules);
1373
+ if (!left.ok)
1374
+ return left;
1375
+ for (const schedule of schedules) {
1376
+ const transition = schedule.transition;
1377
+ if (transition.phase === "transition"
1378
+ && transition.machine.key === machineKey
1379
+ && transition.from === snapshot.from
1380
+ && transition.to === snapshot.to) {
1381
+ const result = runTransitionSchedule(schedule, snapshot);
1382
+ if (!result.ok)
1383
+ return result;
1026
1384
  }
1027
1385
  }
1386
+ return succeeded;
1387
+ });
1388
+ if (!exited.ok) {
1389
+ requeue();
1390
+ if (tracing)
1391
+ traceTransition(machineKey, snapshot, "failed");
1392
+ return exited;
1028
1393
  }
1029
- currentMachines.set(machineKey, pending.value);
1030
- changedMachines.add(machineKey);
1031
- transitionEvents.append(machineKey, world.advanceTick(), [snapshot]);
1032
- if (tracing)
1033
- traceTransition(machineKey, snapshot, "applied");
1394
+ for (const sub of leaving)
1395
+ commitMachine(sub.key, currentMachines.get(sub.key), undefined);
1396
+ commitMachine(machineKey, current, pending.value);
1397
+ // Sub-states that exist in the entered state enter their value together
1398
+ // with the parent, parents first, before any onEnter schedule runs: a
1399
+ // failing onEnter cannot leave them without a value, and their scope
1400
+ // triggers fire before what the onEnter schedules spawn.
1401
+ const entered = commitSubStates(machineKey, snapshot.to);
1034
1402
  for (const schedule of schedules) {
1035
1403
  const transition = schedule.transition;
1036
1404
  if (transition.phase === "enter" && transition.machine.key === machineKey && transition.state === snapshot.to) {
@@ -1042,6 +1410,85 @@ const makeValidatedRuntime = (options) => {
1042
1410
  }
1043
1411
  }
1044
1412
  }
1413
+ const enteredSchedules = runSubStateEnters(entered, schedules);
1414
+ if (!enteredSchedules.ok)
1415
+ return enteredSchedules;
1416
+ }
1417
+ return succeeded;
1418
+ };
1419
+ /**
1420
+ * Commits one machine's value (`undefined` for a sub-state that stops
1421
+ * existing): records the change and its transition event, traces it, and
1422
+ * despawns the scopes it triggers.
1423
+ */
1424
+ const commitMachine = (machineKey, from, to) => {
1425
+ const snapshot = { from, to };
1426
+ if (from !== undefined)
1427
+ previousMachines.set(machineKey, from);
1428
+ if (to === undefined) {
1429
+ currentMachines.delete(machineKey);
1430
+ const discarded = pendingMachines.get(machineKey);
1431
+ if (discarded !== undefined)
1432
+ discardedPending.add(discarded);
1433
+ pendingMachines.delete(machineKey);
1434
+ }
1435
+ else {
1436
+ currentMachines.set(machineKey, to);
1437
+ }
1438
+ changedMachines.add(machineKey);
1439
+ transitionEvents.append(machineKey, world.advanceTick(), [snapshot]);
1440
+ if (tracing)
1441
+ traceTransition(machineKey, snapshot, "applied");
1442
+ despawnTriggeredScopes(machineKey, snapshot);
1443
+ };
1444
+ /** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
1445
+ const runPhase = (machineKey, phase, state, snapshot, schedules) => {
1446
+ for (const schedule of schedules) {
1447
+ const transition = schedule.transition;
1448
+ if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
1449
+ const result = runTransitionSchedule(schedule, snapshot);
1450
+ if (!result.ok)
1451
+ return result;
1452
+ }
1453
+ }
1454
+ return succeeded;
1455
+ };
1456
+ /** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
1457
+ const activeSubStatesIn = (parentKey, state) => {
1458
+ const result = [];
1459
+ for (const sub of subStates.get(parentKey) ?? []) {
1460
+ const value = currentMachines.get(sub.key);
1461
+ if (sub.within.state !== state || value === undefined)
1462
+ continue;
1463
+ result.push(...activeSubStatesIn(sub.key, value), sub);
1464
+ }
1465
+ return result;
1466
+ };
1467
+ /**
1468
+ * Gives the sub-states of `parentKey` that exist in `state` their entry
1469
+ * value, and theirs, parents first. Returns what it entered, in that order.
1470
+ */
1471
+ const commitSubStates = (parentKey, state) => {
1472
+ const entered = [];
1473
+ for (const sub of subStates.get(parentKey) ?? []) {
1474
+ if (sub.within.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key))
1475
+ continue;
1476
+ const value = subStateEntry.get(sub.key);
1477
+ commitMachine(sub.key, undefined, value);
1478
+ entered.push([sub.key, value], ...commitSubStates(sub.key, value));
1479
+ }
1480
+ return entered;
1481
+ };
1482
+ /** Runs the onEnter schedules of sub-states entered by {@link commitSubStates}, in that order. */
1483
+ const runSubStateEnters = (entered, schedules) => {
1484
+ for (const [key, value] of entered) {
1485
+ const snapshot = { from: undefined, to: value };
1486
+ const result = runPhase(key, "enter", value, snapshot, schedules);
1487
+ if (!result.ok) {
1488
+ if (tracing)
1489
+ traceTransition(key, snapshot, "enterFailed");
1490
+ return result;
1491
+ }
1045
1492
  }
1046
1493
  return succeeded;
1047
1494
  };
@@ -1076,8 +1523,14 @@ const makeValidatedRuntime = (options) => {
1076
1523
  }
1077
1524
  return succeeded;
1078
1525
  }
1079
- /** Whether a schedule is running now: `send` is not allowed meanwhile. */
1526
+ /** Whether `runtime.tick` is on the stack: a nested `tick` is a defect. */
1080
1527
  let ticking = false;
1528
+ /**
1529
+ * Whether a tick is advancing its frame or running its schedules (in
1530
+ * `tick` and `tryTick`): `send`, `snapshot`, and `restore` are defects
1531
+ * meanwhile. It is cleared before `tick.end` is announced to host listeners.
1532
+ */
1533
+ let inTick = false;
1081
1534
  /**
1082
1535
  * Runs one schedule. A failing system stops the schedule; systems that
1083
1536
  * already succeeded stay committed, and their pending work stays pending.
@@ -1099,7 +1552,7 @@ const makeValidatedRuntime = (options) => {
1099
1552
  /** Events declared as sent by the host (`debug.declareHostEvents`). */
1100
1553
  let hostEvents = new Set();
1101
1554
  const send = (descriptor, value) => {
1102
- if (ticking)
1555
+ if (inTick)
1103
1556
  throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`);
1104
1557
  if (!eventsByKey.has(descriptor.key))
1105
1558
  throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`);
@@ -1173,6 +1626,7 @@ const makeValidatedRuntime = (options) => {
1173
1626
  if (debugEnabled)
1174
1627
  reportUnread("next-tick");
1175
1628
  ticking = true;
1629
+ inTick = true;
1176
1630
  try {
1177
1631
  advanceFrame();
1178
1632
  return watchResult(observedTick(schedules, () => {
@@ -1186,6 +1640,7 @@ const makeValidatedRuntime = (options) => {
1186
1640
  }
1187
1641
  finally {
1188
1642
  ticking = false;
1643
+ inTick = false;
1189
1644
  }
1190
1645
  };
1191
1646
  /** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
@@ -1198,9 +1653,11 @@ const makeValidatedRuntime = (options) => {
1198
1653
  result = run();
1199
1654
  }
1200
1655
  catch (defect) {
1656
+ inTick = false;
1201
1657
  emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect });
1202
1658
  throw defect;
1203
1659
  }
1660
+ inTick = false;
1204
1661
  emitHost(result.ok
1205
1662
  ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
1206
1663
  : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error });
@@ -1213,7 +1670,8 @@ const makeValidatedRuntime = (options) => {
1213
1670
  case "resource":
1214
1671
  return resources.has(requirement.key);
1215
1672
  case "stateMachine":
1216
- return currentMachines.has(requirement.key);
1673
+ // An inactive sub-state has no value but is provided.
1674
+ return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key);
1217
1675
  }
1218
1676
  };
1219
1677
  const tryRunSchedule = (schedule) => {
@@ -1232,8 +1690,16 @@ const makeValidatedRuntime = (options) => {
1232
1690
  if (issues.length > 0) {
1233
1691
  return watchResult(Result.failure({ kind: "TickOrderViolation", issues }), [schedule]);
1234
1692
  }
1235
- advanceFrame();
1236
- return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]);
1693
+ if (inTick)
1694
+ throw new Error("runtime.tryTick cannot run inside another tick");
1695
+ inTick = true;
1696
+ try {
1697
+ advanceFrame();
1698
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]);
1699
+ }
1700
+ finally {
1701
+ inTick = false;
1702
+ }
1237
1703
  };
1238
1704
  /**
1239
1705
  * Evaluates a declared read-only projection without running a schedule.
@@ -1261,6 +1727,8 @@ const makeValidatedRuntime = (options) => {
1261
1727
  for (const provision of machineEntries)
1262
1728
  machinesByName.set(provision.machine.name, provision.machine);
1263
1729
  const snapshot = () => {
1730
+ if (inTick)
1731
+ throw new Error("runtime.snapshot was called while a schedule is running: it would capture writes a failing system rolls back; take it between ticks");
1264
1732
  const savedResources = {};
1265
1733
  for (const [name, descriptor] of resourcesByName) {
1266
1734
  if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
@@ -1281,6 +1749,7 @@ const makeValidatedRuntime = (options) => {
1281
1749
  }
1282
1750
  return {
1283
1751
  version: Snapshot.version,
1752
+ gameVersion: options.saves?.latest ?? 0,
1284
1753
  nextEntity: world.nextEntityValue(),
1285
1754
  entities: world.exportEntities(DescriptorModule.isTransient),
1286
1755
  relations: world.exportRelations(),
@@ -1296,7 +1765,10 @@ const makeValidatedRuntime = (options) => {
1296
1765
  const parsed = Snapshot.parse(data);
1297
1766
  if (!parsed.ok)
1298
1767
  return parsed;
1299
- const saved = parsed.value;
1768
+ const migrated = migrateSave(parsed.value, options.saves);
1769
+ if (!migrated.ok)
1770
+ return migrated;
1771
+ const saved = migrated.value;
1300
1772
  // Validate everything before touching the world.
1301
1773
  const ids = new Set();
1302
1774
  const entities = [];
@@ -1317,6 +1789,11 @@ const makeValidatedRuntime = (options) => {
1317
1789
  return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error });
1318
1790
  components.push([descriptor, value.value]);
1319
1791
  }
1792
+ // Transient components are never saved, so a requirement on one is not checked here.
1793
+ const missing = missingIn(components, DescriptorModule.isTransient);
1794
+ if (missing !== undefined) {
1795
+ return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] });
1796
+ }
1320
1797
  entities.push({ id: entity.id, components });
1321
1798
  }
1322
1799
  const validatedResources = [];
@@ -1358,18 +1835,40 @@ const makeValidatedRuntime = (options) => {
1358
1835
  }
1359
1836
  }
1360
1837
  }
1361
- const machineValues = [];
1838
+ const restoredMachines = new Map(currentMachines);
1362
1839
  for (const [name, value] of Object.entries(saved.machines)) {
1363
1840
  const machine = machinesByName.get(name);
1364
1841
  if (!machine)
1365
1842
  return Result.failure({ _tag: "UnknownMachine", name });
1366
1843
  if (!machine.values.includes(value))
1367
1844
  return Result.failure({ _tag: "InvalidMachineState", name, value });
1368
- machineValues.push([machine.key, value]);
1845
+ restoredMachines.set(machine.key, value);
1846
+ }
1847
+ // A save has no value for a sub-state whose parent is elsewhere; one that
1848
+ // has a value must have its parent in the state it exists in. A sub-state
1849
+ // missing while its parent is there enters its entry value.
1850
+ for (const machine of options.machineDefinitions) {
1851
+ const within = machine.within;
1852
+ if (within === undefined)
1853
+ continue;
1854
+ const active = restoredMachines.get(within.machine.key) === within.state;
1855
+ if (Object.hasOwn(saved.machines, machine.name)) {
1856
+ if (!active) {
1857
+ return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state });
1858
+ }
1859
+ }
1860
+ else if (active && subStateEntry.has(machine.key)) {
1861
+ restoredMachines.set(machine.key, subStateEntry.get(machine.key));
1862
+ }
1863
+ else {
1864
+ restoredMachines.delete(machine.key);
1865
+ }
1369
1866
  }
1370
1867
  const scoped = new Set();
1371
1868
  const memberships = [];
1372
1869
  for (const [name, members] of Object.entries(saved.scopes)) {
1870
+ if (!options.scopeDefinitions.some((scope) => scope.name === name))
1871
+ return Result.failure({ _tag: "UnknownScope", name });
1373
1872
  for (const id of members) {
1374
1873
  if (!ids.has(id) || scoped.has(id))
1375
1874
  return Result.failure({ _tag: "InvalidScope", name, entityId: id });
@@ -1397,15 +1896,20 @@ const makeValidatedRuntime = (options) => {
1397
1896
  }
1398
1897
  for (const [id, key] of memberships)
1399
1898
  world.assignEntityScope(id, key);
1400
- for (const [key, value] of validatedResources)
1899
+ for (const [key, value] of validatedResources) {
1401
1900
  resources.set(key, value);
1402
- for (const [key, value] of machineValues)
1901
+ resourceChangedAt.set(key, world.currentTick());
1902
+ }
1903
+ currentMachines.clear();
1904
+ for (const [key, value] of restoredMachines)
1403
1905
  currentMachines.set(key, value);
1404
1906
  // Locals are not saved: each system starts over from its factory.
1405
1907
  localSlots.forEach(({ slot }, index) => { slot.context.local = rebuiltLocals[index]; });
1406
1908
  return succeeded;
1407
1909
  };
1408
1910
  const tracedRestore = (data) => {
1911
+ if (inTick)
1912
+ throw new Error("runtime.restore was called while a schedule is running: the running systems' queued commands would land on the restored world; restore between ticks");
1409
1913
  const result = restore(data);
1410
1914
  if (tracing)
1411
1915
  emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok });
@@ -1546,11 +2050,17 @@ const makeValidatedRuntime = (options) => {
1546
2050
  schema: options.schema,
1547
2051
  machines: [...new Set(machinesByName.values())],
1548
2052
  currentMachine: (machine) => currentMachines.get(machine.key),
2053
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
2054
+ scopes: options.scopeDefinitions,
1549
2055
  providedServices: Object.keys(providedServices),
1550
2056
  externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
1551
2057
  relationMutations,
1552
2058
  commandUses,
1553
2059
  ranSystems,
2060
+ requirementViolations: [...requirementViolations.values()],
2061
+ nextStateReplacements: [...nextStateReplacements.values()],
2062
+ staleTargets: [...staleTargets.values()],
2063
+ streamLosses: [...streamLosses.values()],
1554
2064
  hasResource: (descriptor) => resources.has(descriptor.key),
1555
2065
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule])
1556
2066
  }),
@@ -1590,8 +2100,9 @@ const makeValidatedRuntime = (options) => {
1590
2100
  * Creates a runtime for a fully built schema and a set of external services.
1591
2101
  *
1592
2102
  * Resources owned by constructed descriptors take raw input and are validated
1593
- * here; when any are provided the runtime comes back as a `Result`. Otherwise
1594
- * the runtime is returned directly.
2103
+ * here. The runtime always comes back as a `Result`: typed `Success` when
2104
+ * nothing provided can fail (read `.value`), and a `Result` to check when a
2105
+ * constructed resource is provided.
1595
2106
  *
1596
2107
  * The runtime does not own the outer frame loop. It only owns ECS state plus
1597
2108
  * the host-provided services that systems are allowed to depend on.
@@ -1604,13 +2115,10 @@ const makeValidatedRuntime = (options) => {
1604
2115
  * ),
1605
2116
  * resources: { Score: 0 },
1606
2117
  * machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
1607
- * })
2118
+ * }).value
1608
2119
  * ```
1609
2120
  */
1610
2121
  export const make = (options) => {
1611
- const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) => options.resources !== undefined
1612
- && options.resources[key] !== undefined
1613
- && DescriptorModule.hasConstructor(descriptor));
1614
2122
  const validated = collectConstructedRegistryValues(options.schema.resources, options.resources);
1615
2123
  if (!validated.ok) {
1616
2124
  return Result.failure({ resources: validated.error });
@@ -1621,9 +2129,19 @@ export const make = (options) => {
1621
2129
  resources: validated.value,
1622
2130
  machines: options.machines,
1623
2131
  machineDefinitions: options.machineDefinitions ?? [],
1624
- debug: options.debug
2132
+ scopeDefinitions: options.scopeDefinitions ?? [],
2133
+ debug: options.debug,
2134
+ saves: options.saves
1625
2135
  });
1626
- return (fallible ? Result.success(runtime) : runtime);
2136
+ return Result.success(runtime);
2137
+ };
2138
+ const rejectRepeated = (names, kind) => {
2139
+ const seen = new Set();
2140
+ for (const name of names) {
2141
+ if (seen.has(name))
2142
+ throw new Error(`${name} is provided twice: keep one Game.Runtime.${kind}(...) for it`);
2143
+ seen.add(name);
2144
+ }
1627
2145
  };
1628
2146
  /**
1629
2147
  * Builds the descriptor-backed runtime service environment.
@@ -1634,6 +2152,8 @@ export const make = (options) => {
1634
2152
  *
1635
2153
  * The helper keeps the runtime map keyed by service descriptors instead of
1636
2154
  * ad hoc strings, so the provision site cannot drift from the declaration site.
2155
+ * Each service is provided once: a second provision of the same one does not
2156
+ * compile.
1637
2157
  *
1638
2158
  * @example
1639
2159
  * ```ts
@@ -1645,6 +2165,7 @@ export const make = (options) => {
1645
2165
  * ```
1646
2166
  */
1647
2167
  export const services = (...entries) => {
2168
+ rejectRepeated(entries.map((entry) => entry.descriptor.name), "service");
1648
2169
  const provided = {};
1649
2170
  for (const { descriptor, implementation } of entries) {
1650
2171
  provided[descriptor.name] = implementation;
@@ -1667,9 +2188,12 @@ export const service = (descriptor, implementation) => ({
1667
2188
  * Builds the machine initialization environment from machine definitions.
1668
2189
  *
1669
2190
  * Use this when gameplay phases need a known committed starting state before
1670
- * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`.
2191
+ * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`. Each
2192
+ * machine is provided once: a second provision of the same one does not
2193
+ * compile.
1671
2194
  */
1672
2195
  export const machines = (...entries) => {
2196
+ rejectRepeated(entries.map((entry) => entry.machine.name), "machine");
1673
2197
  const provided = {};
1674
2198
  for (const { machine, initial } of entries) {
1675
2199
  provided[machine.name] = initial;