@typeonce/ritmo 0.7.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 (87) hide show
  1. package/dist/Command.d.ts +102 -17
  2. package/dist/Command.d.ts.map +1 -1
  3. package/dist/Command.js +10 -8
  4. package/dist/Command.js.map +1 -1
  5. package/dist/Condition.d.ts +12 -1
  6. package/dist/Condition.d.ts.map +1 -1
  7. package/dist/Condition.js.map +1 -1
  8. package/dist/Debug.d.ts +63 -7
  9. package/dist/Debug.d.ts.map +1 -1
  10. package/dist/Descriptor.d.ts +36 -3
  11. package/dist/Descriptor.d.ts.map +1 -1
  12. package/dist/Descriptor.js +11 -3
  13. package/dist/Descriptor.js.map +1 -1
  14. package/dist/Entity.d.ts +12 -4
  15. package/dist/Entity.d.ts.map +1 -1
  16. package/dist/Entity.js.map +1 -1
  17. package/dist/EntityScope.d.ts +20 -1
  18. package/dist/EntityScope.d.ts.map +1 -1
  19. package/dist/EntityScope.js +8 -2
  20. package/dist/EntityScope.js.map +1 -1
  21. package/dist/Machine.d.ts +55 -33
  22. package/dist/Machine.d.ts.map +1 -1
  23. package/dist/Machine.js +6 -2
  24. package/dist/Machine.js.map +1 -1
  25. package/dist/Query.d.ts +48 -4
  26. package/dist/Query.d.ts.map +1 -1
  27. package/dist/Query.js +32 -4
  28. package/dist/Query.js.map +1 -1
  29. package/dist/Runtime.d.ts +3 -0
  30. package/dist/Runtime.d.ts.map +1 -1
  31. package/dist/Runtime.js +330 -24
  32. package/dist/Runtime.js.map +1 -1
  33. package/dist/Schema.d.ts +141 -25
  34. package/dist/Schema.d.ts.map +1 -1
  35. package/dist/Schema.js +29 -3
  36. package/dist/Schema.js.map +1 -1
  37. package/dist/Snapshot.d.ts +14 -0
  38. package/dist/Snapshot.d.ts.map +1 -1
  39. package/dist/Snapshot.js.map +1 -1
  40. package/dist/System.d.ts +64 -7
  41. package/dist/System.d.ts.map +1 -1
  42. package/dist/System.js +32 -0
  43. package/dist/System.js.map +1 -1
  44. package/dist/internal/debug.d.ts +7 -0
  45. package/dist/internal/debug.d.ts.map +1 -1
  46. package/dist/internal/debug.js +97 -6
  47. package/dist/internal/debug.js.map +1 -1
  48. package/dist/internal/equal.d.ts +8 -0
  49. package/dist/internal/equal.d.ts.map +1 -0
  50. package/dist/internal/equal.js +38 -0
  51. package/dist/internal/equal.js.map +1 -0
  52. package/dist/internal/fragments.d.ts.map +1 -1
  53. package/dist/internal/fragments.js +11 -1
  54. package/dist/internal/fragments.js.map +1 -1
  55. package/dist/internal/game.d.ts.map +1 -1
  56. package/dist/internal/game.js +33 -5
  57. package/dist/internal/game.js.map +1 -1
  58. package/dist/internal/order.d.ts.map +1 -1
  59. package/dist/internal/order.js +4 -2
  60. package/dist/internal/order.js.map +1 -1
  61. package/dist/internal/queries.d.ts.map +1 -1
  62. package/dist/internal/queries.js +30 -8
  63. package/dist/internal/queries.js.map +1 -1
  64. package/dist/internal/requirements.d.ts +26 -0
  65. package/dist/internal/requirements.d.ts.map +1 -0
  66. package/dist/internal/requirements.js +46 -0
  67. package/dist/internal/requirements.js.map +1 -0
  68. package/package.json +1 -1
  69. package/src/Command.ts +149 -29
  70. package/src/Condition.ts +17 -1
  71. package/src/Debug.ts +64 -7
  72. package/src/Descriptor.ts +55 -10
  73. package/src/Entity.ts +13 -4
  74. package/src/EntityScope.ts +21 -2
  75. package/src/Machine.ts +68 -34
  76. package/src/Query.ts +71 -7
  77. package/src/Runtime.ts +336 -22
  78. package/src/Schema.ts +185 -28
  79. package/src/Snapshot.ts +4 -0
  80. package/src/System.ts +75 -6
  81. package/src/internal/debug.ts +104 -8
  82. package/src/internal/equal.ts +31 -0
  83. package/src/internal/fragments.ts +12 -2
  84. package/src/internal/game.ts +51 -5
  85. package/src/internal/order.ts +8 -4
  86. package/src/internal/queries.ts +34 -10
  87. package/src/internal/requirements.ts +71 -0
package/dist/Runtime.js CHANGED
@@ -59,8 +59,10 @@ import * as HostClockModule from "./HostClock.js";
59
59
  import * as Command from "./Command.js";
60
60
  import * as DescriptorModule from "./Descriptor.js";
61
61
  import * as Cells from "./internal/cells.js";
62
+ import { sameValue } from "./internal/equal.js";
62
63
  import * as DebugInternal from "./internal/debug.js";
63
64
  import { makeQueryEngine } from "./internal/queries.js";
65
+ import { requirementIndex } from "./internal/requirements.js";
64
66
  import * as Streams from "./internal/streams.js";
65
67
  import { ABSENT, makeWorld } from "./internal/world.js";
66
68
  import * as Query from "./Query.js";
@@ -240,8 +242,52 @@ const makeValidatedRuntime = (options) => {
240
242
  const providedMachines = (options.machines ?? machines());
241
243
  const machineEntries = (options.machines ?? machines())[runtimeMachinesEntries];
242
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();
243
247
  for (const provision of machineEntries) {
244
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
+ }
245
291
  }
246
292
  const appendRelationFailure = (relation, operation, sourceId, targetId, error) => {
247
293
  if (tracing) {
@@ -283,15 +329,57 @@ const makeValidatedRuntime = (options) => {
283
329
  }
284
330
  systems.add(commandOrigin);
285
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
+ };
286
366
  /**
287
367
  * World adapter used by deferred commands.
288
368
  */
289
369
  const commandWorld = {
290
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
+ }
291
378
  if (debugEnabled)
292
379
  for (const [descriptor] of components)
293
380
  noteCommand("spawns", descriptor.name);
294
381
  world.spawnEntity(id, components);
382
+ return true;
295
383
  },
296
384
  destroyEntity(id) {
297
385
  if (debugEnabled) {
@@ -311,15 +399,37 @@ const makeValidatedRuntime = (options) => {
311
399
  noteCommand("despawnsScope", scope.name);
312
400
  world.destroyEntityScope(scope.key);
313
401
  },
314
- removeComponent(id, descriptor) {
315
- if (debugEnabled)
316
- noteCommand("removes", descriptor.name);
317
- 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
+ }
318
419
  },
319
- writeComponent(id, descriptor, value) {
320
- if (debugEnabled)
321
- noteCommand("inserts", descriptor.name);
322
- 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
+ }
323
433
  },
324
434
  tryRelate(id, relation, target) {
325
435
  if (debugEnabled) {
@@ -494,6 +604,14 @@ const makeValidatedRuntime = (options) => {
494
604
  store.set(key, value);
495
605
  };
496
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();
497
615
  const journalMachine = (key) => {
498
616
  if (!machineOriginals.has(key)) {
499
617
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue);
@@ -504,6 +622,15 @@ const makeValidatedRuntime = (options) => {
504
622
  };
505
623
  const commitSystemTransaction = () => {
506
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
+ }
507
634
  if (emittedEvents.size > 0) {
508
635
  const published = world.advanceTick();
509
636
  for (const [key, values] of emittedEvents) {
@@ -540,6 +667,16 @@ const makeValidatedRuntime = (options) => {
540
667
  emittedEvents.clear();
541
668
  };
542
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
+ };
543
680
  const makeEventReadView = (descriptorKey, reader) => ({
544
681
  all() {
545
682
  return events.since(descriptorKey, reader.streamSince);
@@ -598,15 +735,28 @@ const makeValidatedRuntime = (options) => {
598
735
  get: () => currentMachines.get(stateMachine.key),
599
736
  is: (value) => currentMachines.get(stateMachine.key) === value
600
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
+ };
601
753
  const makeNextMachineWriteView = (stateMachine) => ({
602
754
  getPending: () => pendingMachines.get(stateMachine.key)?.value,
603
755
  set(value) {
604
- journalMachine(stateMachine.key);
605
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false });
756
+ return queueMachine(stateMachine, value, false);
606
757
  },
607
758
  setIfChanged(value) {
608
- journalMachine(stateMachine.key);
609
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true });
759
+ return queueMachine(stateMachine, value, true);
610
760
  },
611
761
  reset() {
612
762
  journalMachine(stateMachine.key);
@@ -660,7 +810,9 @@ const makeValidatedRuntime = (options) => {
660
810
  lookup,
661
811
  resources: mapRecord(spec.resources, (access) => access.mode === "read"
662
812
  ? Cells.storeRead(resources, access.descriptor.key)
663
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
813
+ : access.mode === "changed"
814
+ ? makeResourceChangedView(access.descriptor, reader)
815
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
664
816
  events: mapRecord(spec.events, (access) => access.mode === "read"
665
817
  ? makeEventReadView(access.descriptor.key, reader)
666
818
  : makeEventWriteView(access.descriptor.key)),
@@ -971,6 +1123,39 @@ const makeValidatedRuntime = (options) => {
971
1123
  activeTransitions.delete(schedule.transition.machine.key);
972
1124
  }
973
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
+ };
974
1159
  /**
975
1160
  * Applies queued commands, then queued machine transitions, running the
976
1161
  * matching exit, transition, and enter schedules from the bundle.
@@ -983,6 +1168,10 @@ const makeValidatedRuntime = (options) => {
983
1168
  - (machineDefinitionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
984
1169
  const schedules = bundle?.entries ?? [];
985
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
+ }
986
1175
  pendingMachines.delete(machineKey);
987
1176
  const current = currentMachines.get(machineKey);
988
1177
  if (current === undefined) {
@@ -993,11 +1182,24 @@ const makeValidatedRuntime = (options) => {
993
1182
  traceTransition(machineKey, { from: current, to: current }, "unchanged");
994
1183
  continue;
995
1184
  }
996
- previousMachines.set(machineKey, current);
997
1185
  const snapshot = {
998
1186
  from: current,
999
1187
  to: pending.value
1000
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
+ }
1001
1203
  for (const schedule of schedules) {
1002
1204
  const transition = schedule.transition;
1003
1205
  if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
@@ -1026,11 +1228,9 @@ const makeValidatedRuntime = (options) => {
1026
1228
  }
1027
1229
  }
1028
1230
  }
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");
1231
+ for (const sub of leaving)
1232
+ commitMachine(sub.key, currentMachines.get(sub.key), undefined);
1233
+ commitMachine(machineKey, current, pending.value);
1034
1234
  for (const schedule of schedules) {
1035
1235
  const transition = schedule.transition;
1036
1236
  if (transition.phase === "enter" && transition.machine.key === machineKey && transition.state === snapshot.to) {
@@ -1042,6 +1242,79 @@ const makeValidatedRuntime = (options) => {
1042
1242
  }
1043
1243
  }
1044
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;
1045
1318
  }
1046
1319
  return succeeded;
1047
1320
  };
@@ -1213,7 +1486,8 @@ const makeValidatedRuntime = (options) => {
1213
1486
  case "resource":
1214
1487
  return resources.has(requirement.key);
1215
1488
  case "stateMachine":
1216
- 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);
1217
1491
  }
1218
1492
  };
1219
1493
  const tryRunSchedule = (schedule) => {
@@ -1317,6 +1591,11 @@ const makeValidatedRuntime = (options) => {
1317
1591
  return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error });
1318
1592
  components.push([descriptor, value.value]);
1319
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
+ }
1320
1599
  entities.push({ id: entity.id, components });
1321
1600
  }
1322
1601
  const validatedResources = [];
@@ -1358,14 +1637,34 @@ const makeValidatedRuntime = (options) => {
1358
1637
  }
1359
1638
  }
1360
1639
  }
1361
- const machineValues = [];
1640
+ const restoredMachines = new Map(currentMachines);
1362
1641
  for (const [name, value] of Object.entries(saved.machines)) {
1363
1642
  const machine = machinesByName.get(name);
1364
1643
  if (!machine)
1365
1644
  return Result.failure({ _tag: "UnknownMachine", name });
1366
1645
  if (!machine.values.includes(value))
1367
1646
  return Result.failure({ _tag: "InvalidMachineState", name, value });
1368
- 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
+ }
1369
1668
  }
1370
1669
  const scoped = new Set();
1371
1670
  const memberships = [];
@@ -1397,9 +1696,12 @@ const makeValidatedRuntime = (options) => {
1397
1696
  }
1398
1697
  for (const [id, key] of memberships)
1399
1698
  world.assignEntityScope(id, key);
1400
- for (const [key, value] of validatedResources)
1699
+ for (const [key, value] of validatedResources) {
1401
1700
  resources.set(key, value);
1402
- for (const [key, value] of machineValues)
1701
+ resourceChangedAt.set(key, world.currentTick());
1702
+ }
1703
+ currentMachines.clear();
1704
+ for (const [key, value] of restoredMachines)
1403
1705
  currentMachines.set(key, value);
1404
1706
  // Locals are not saved: each system starts over from its factory.
1405
1707
  localSlots.forEach(({ slot }, index) => { slot.context.local = rebuiltLocals[index]; });
@@ -1546,11 +1848,14 @@ const makeValidatedRuntime = (options) => {
1546
1848
  schema: options.schema,
1547
1849
  machines: [...new Set(machinesByName.values())],
1548
1850
  currentMachine: (machine) => currentMachines.get(machine.key),
1851
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
1852
+ scopes: options.scopeDefinitions,
1549
1853
  providedServices: Object.keys(providedServices),
1550
1854
  externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
1551
1855
  relationMutations,
1552
1856
  commandUses,
1553
1857
  ranSystems,
1858
+ requirementViolations: [...requirementViolations.values()],
1554
1859
  hasResource: (descriptor) => resources.has(descriptor.key),
1555
1860
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule])
1556
1861
  }),
@@ -1621,6 +1926,7 @@ export const make = (options) => {
1621
1926
  resources: validated.value,
1622
1927
  machines: options.machines,
1623
1928
  machineDefinitions: options.machineDefinitions ?? [],
1929
+ scopeDefinitions: options.scopeDefinitions ?? [],
1624
1930
  debug: options.debug
1625
1931
  });
1626
1932
  return (fallible ? Result.success(runtime) : runtime);