@typeonce/ritmo 0.8.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 (117) 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 +101 -45
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +22 -4
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Debug.d.ts +66 -8
  11. package/dist/Debug.d.ts.map +1 -1
  12. package/dist/Descriptor.d.ts +27 -9
  13. package/dist/Descriptor.d.ts.map +1 -1
  14. package/dist/Descriptor.js +13 -9
  15. package/dist/Descriptor.js.map +1 -1
  16. package/dist/Entity.d.ts +15 -8
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js.map +1 -1
  19. package/dist/FixedLoop.d.ts +106 -0
  20. package/dist/FixedLoop.d.ts.map +1 -0
  21. package/dist/FixedLoop.js +97 -0
  22. package/dist/FixedLoop.js.map +1 -0
  23. package/dist/HostClock.d.ts +1 -1
  24. package/dist/HostClock.d.ts.map +1 -1
  25. package/dist/HostClock.js +2 -1
  26. package/dist/HostClock.js.map +1 -1
  27. package/dist/Machine.d.ts +4 -1
  28. package/dist/Machine.d.ts.map +1 -1
  29. package/dist/Machine.js +4 -1
  30. package/dist/Machine.js.map +1 -1
  31. package/dist/Query.d.ts +52 -10
  32. package/dist/Query.d.ts.map +1 -1
  33. package/dist/Query.js +2 -1
  34. package/dist/Query.js.map +1 -1
  35. package/dist/Relation.d.ts +5 -14
  36. package/dist/Relation.d.ts.map +1 -1
  37. package/dist/Relation.js +0 -32
  38. package/dist/Relation.js.map +1 -1
  39. package/dist/Runtime.d.ts +58 -15
  40. package/dist/Runtime.d.ts.map +1 -1
  41. package/dist/Runtime.js +288 -70
  42. package/dist/Runtime.js.map +1 -1
  43. package/dist/Schedule.d.ts +24 -15
  44. package/dist/Schedule.d.ts.map +1 -1
  45. package/dist/Schedule.js +2 -1
  46. package/dist/Schedule.js.map +1 -1
  47. package/dist/Schema.d.ts +98 -29
  48. package/dist/Schema.d.ts.map +1 -1
  49. package/dist/Schema.js +4 -4
  50. package/dist/Schema.js.map +1 -1
  51. package/dist/Snapshot.d.ts +76 -1
  52. package/dist/Snapshot.d.ts.map +1 -1
  53. package/dist/Snapshot.js +42 -1
  54. package/dist/Snapshot.js.map +1 -1
  55. package/dist/System.d.ts +68 -24
  56. package/dist/System.d.ts.map +1 -1
  57. package/dist/System.js.map +1 -1
  58. package/dist/index.d.ts +4 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +4 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal/cells.d.ts +2 -0
  63. package/dist/internal/cells.d.ts.map +1 -1
  64. package/dist/internal/cells.js +34 -8
  65. package/dist/internal/cells.js.map +1 -1
  66. package/dist/internal/debug.d.ts +3 -0
  67. package/dist/internal/debug.d.ts.map +1 -1
  68. package/dist/internal/debug.js +83 -15
  69. package/dist/internal/debug.js.map +1 -1
  70. package/dist/internal/fragments.d.ts +2 -6
  71. package/dist/internal/fragments.d.ts.map +1 -1
  72. package/dist/internal/fragments.js +25 -2
  73. package/dist/internal/fragments.js.map +1 -1
  74. package/dist/internal/game.d.ts +2 -0
  75. package/dist/internal/game.d.ts.map +1 -1
  76. package/dist/internal/game.js +16 -3
  77. package/dist/internal/game.js.map +1 -1
  78. package/dist/internal/queries.d.ts.map +1 -1
  79. package/dist/internal/queries.js +27 -18
  80. package/dist/internal/queries.js.map +1 -1
  81. package/dist/internal/relations.d.ts +10 -0
  82. package/dist/internal/relations.d.ts.map +1 -0
  83. package/dist/internal/relations.js +32 -0
  84. package/dist/internal/relations.js.map +1 -0
  85. package/dist/internal/streams.d.ts +2 -0
  86. package/dist/internal/streams.d.ts.map +1 -1
  87. package/dist/internal/streams.js +10 -0
  88. package/dist/internal/streams.js.map +1 -1
  89. package/dist/internal/world.d.ts +7 -1
  90. package/dist/internal/world.d.ts.map +1 -1
  91. package/dist/internal/world.js +53 -3
  92. package/dist/internal/world.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/Clock.ts +29 -4
  95. package/src/Command.ts +161 -67
  96. package/src/Debug.ts +61 -9
  97. package/src/Descriptor.ts +43 -17
  98. package/src/Entity.ts +16 -9
  99. package/src/FixedLoop.ts +218 -0
  100. package/src/HostClock.ts +2 -1
  101. package/src/Machine.ts +4 -1
  102. package/src/Query.ts +47 -10
  103. package/src/Relation.ts +5 -67
  104. package/src/Runtime.ts +345 -85
  105. package/src/Schedule.ts +31 -19
  106. package/src/Schema.ts +118 -35
  107. package/src/Snapshot.ts +90 -2
  108. package/src/System.ts +76 -23
  109. package/src/index.ts +4 -0
  110. package/src/internal/cells.ts +42 -10
  111. package/src/internal/debug.ts +84 -15
  112. package/src/internal/fragments.ts +23 -3
  113. package/src/internal/game.ts +19 -3
  114. package/src/internal/queries.ts +28 -2
  115. package/src/internal/relations.ts +52 -0
  116. package/src/internal/streams.ts +11 -0
  117. 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: {
@@ -149,7 +150,7 @@ const collectConstructedRegistryValues = (registry, provided) => {
149
150
  * resources: {
150
151
  * score: 0
151
152
  * }
152
- * })
153
+ * }).value
153
154
  * ```
154
155
  */
155
156
  /**
@@ -159,6 +160,25 @@ const collectConstructedRegistryValues = (registry, provided) => {
159
160
  * `lagged() === true` (or a `missed` read in the debug trace).
160
161
  */
161
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
+ };
162
182
  const makeValidatedRuntime = (options) => {
163
183
  const world = makeWorld(options.schema, streamCapacity);
164
184
  const queries = makeQueryEngine(world);
@@ -171,7 +191,10 @@ const makeValidatedRuntime = (options) => {
171
191
  * Machine-keyed committed, pending, and previous values.
172
192
  */
173
193
  const currentMachines = new Map();
194
+ /** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
174
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();
175
198
  const previousMachines = new Map();
176
199
  /**
177
200
  * Machine keys changed by transitions applied during the current schedule run.
@@ -313,6 +336,10 @@ const makeValidatedRuntime = (options) => {
313
336
  */
314
337
  const commandUses = new Map();
315
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();
316
343
  const noteCommand = (kind, name) => {
317
344
  let uses = commandUses.get(commandOrigin);
318
345
  if (uses === undefined) {
@@ -348,6 +375,41 @@ const makeValidatedRuntime = (options) => {
348
375
  requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 });
349
376
  }
350
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
+ };
351
413
  const holds = (id) => {
352
414
  const record = world.records.get(id);
353
415
  return (descriptor) => record !== undefined && world.has(record, world.ordinalOf(descriptor));
@@ -431,6 +493,9 @@ const makeValidatedRuntime = (options) => {
431
493
  world.writeComponent(id, descriptor, value);
432
494
  }
433
495
  },
496
+ reportStaleTarget(command, id) {
497
+ reportStaleTarget(command, id);
498
+ },
434
499
  tryRelate(id, relation, target) {
435
500
  if (debugEnabled) {
436
501
  noteRelationMutation(relation);
@@ -597,6 +662,13 @@ const makeValidatedRuntime = (options) => {
597
662
  const resourceOriginals = new Map();
598
663
  const machineOriginals = new Map();
599
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
+ };
600
672
  const journalStoreWrite = (store, originals) => (key, value) => {
601
673
  if (!originals.has(key)) {
602
674
  originals.set(key, store.has(key) ? store.get(key) : absentValue);
@@ -622,11 +694,23 @@ const makeValidatedRuntime = (options) => {
622
694
  };
623
695
  const commitSystemTransaction = () => {
624
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
+ }
625
707
  if (watchedResources.size > 0) {
626
708
  for (const [key, before] of resourceOriginals) {
627
709
  if (!watchedResources.has(key))
628
710
  continue;
629
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));
630
714
  resourceChangedAt.set(key, world.currentTick());
631
715
  }
632
716
  }
@@ -666,6 +750,59 @@ const makeValidatedRuntime = (options) => {
666
750
  machineOriginals.clear();
667
751
  emittedEvents.clear();
668
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
+ };
669
806
  const makeResourceWriteView = (descriptor, store, write) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor));
670
807
  const makeResourceChangedView = (descriptor, reader) => {
671
808
  const key = descriptor.key;
@@ -747,7 +884,24 @@ const makeValidatedRuntime = (options) => {
747
884
  });
748
885
  }
749
886
  journalMachine(stateMachine.key);
750
- pendingMachines.set(stateMachine.key, { value, skipIfSame });
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
+ }
751
905
  return (within === undefined ? undefined : succeeded);
752
906
  };
753
907
  const makeNextMachineWriteView = (stateMachine) => ({
@@ -804,8 +958,12 @@ const makeValidatedRuntime = (options) => {
804
958
  const makeContext = (system, reader, queue) => {
805
959
  const spec = system.spec;
806
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;
807
964
  return {
808
965
  local: spec.local?.(),
966
+ // A filtered query hands out the same object; its type hides the lookups.
809
967
  queries: mapRecord(spec.queries, (query) => makeQueryHandle(query, reader, lookup)),
810
968
  lookup,
811
969
  resources: mapRecord(spec.resources, (access) => access.mode === "read"
@@ -814,15 +972,15 @@ const makeValidatedRuntime = (options) => {
814
972
  ? makeResourceChangedView(access.descriptor, reader)
815
973
  : makeResourceWriteView(access.descriptor, resources, writeResource)),
816
974
  events: mapRecord(spec.events, (access) => access.mode === "read"
817
- ? makeEventReadView(access.descriptor.key, reader)
975
+ ? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
818
976
  : makeEventWriteView(access.descriptor.key)),
819
977
  machines: mapRecord(spec.machines, (access) => makeMachineReadView(access.machine)),
820
978
  nextMachines: mapRecord(spec.nextMachines, (access) => makeNextMachineWriteView(access.machine)),
821
- 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}`)),
822
980
  transitions: mapRecord(spec.transitions, (access) => makeTransitionReadView(access.machine)),
823
- removed: mapRecord(spec.removed, (access) => makeRemovedReadView(access.descriptor, reader)),
824
- despawned: mapRecord(spec.despawned, () => makeDespawnedReadView(reader)),
825
- 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}`)),
826
984
  services: mapRecord(spec.services, (access) => providedServices[access.descriptor.name]),
827
985
  // The queue serves every declared permission set; the spec's types narrow it.
828
986
  commands: queue
@@ -985,6 +1143,7 @@ const makeValidatedRuntime = (options) => {
985
1143
  const skipped = slotOf(system, true);
986
1144
  if (tracing)
987
1145
  traceSkipped(system, condition, skipped.reader, false);
1146
+ journalReader(skipped.reader);
988
1147
  skipped.reader.streamLastRun = world.currentTick();
989
1148
  return succeeded;
990
1149
  }
@@ -1001,8 +1160,11 @@ const makeValidatedRuntime = (options) => {
1001
1160
  }
1002
1161
  }
1003
1162
  const { context, commands, reader } = slotOf(system, true);
1004
- if (debugEnabled)
1163
+ if (debugEnabled) {
1005
1164
  ranSystems.add(system.name);
1165
+ runningSystem = system.name;
1166
+ }
1167
+ journalReader(reader);
1006
1168
  // Changes stamped after the previous completed run are visible to this run.
1007
1169
  reader.since = reader.lastRun;
1008
1170
  reader.streamSince = reader.streamLastRun;
@@ -1168,11 +1330,19 @@ const makeValidatedRuntime = (options) => {
1168
1330
  - (machineDefinitionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
1169
1331
  const schedules = bundle?.entries ?? [];
1170
1332
  for (const [machineKey, pending] of scheduledTransitions) {
1171
- if (pendingMachines.get(machineKey) !== pending) {
1333
+ if (discardedPending.has(pending)) {
1172
1334
  // Discarded earlier in this marker: its parent left the state it exists in.
1173
1335
  continue;
1174
1336
  }
1175
- pendingMachines.delete(machineKey);
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
+ };
1176
1346
  const current = currentMachines.get(machineKey);
1177
1347
  if (current === undefined) {
1178
1348
  continue;
@@ -1189,48 +1359,46 @@ const makeValidatedRuntime = (options) => {
1189
1359
  // Sub-states that exist in the state being left exit first, deepest
1190
1360
  // first: their exit schedules run before the parent's, while they
1191
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.
1192
1364
  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
- }
1203
- for (const schedule of schedules) {
1204
- const transition = schedule.transition;
1205
- if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
1206
- const result = runTransitionSchedule(schedule, snapshot);
1207
- if (!result.ok) {
1208
- // The transition did not happen; keep it queued for a later attempt.
1209
- pendingMachines.set(machineKey, pending);
1210
- if (tracing)
1211
- traceTransition(machineKey, snapshot, "failed");
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)
1212
1370
  return result;
1213
- }
1214
1371
  }
1215
- }
1216
- for (const schedule of schedules) {
1217
- const transition = schedule.transition;
1218
- if (transition.phase === "transition"
1219
- && transition.machine.key === machineKey
1220
- && transition.from === snapshot.from
1221
- && transition.to === snapshot.to) {
1222
- const result = runTransitionSchedule(schedule, snapshot);
1223
- if (!result.ok) {
1224
- pendingMachines.set(machineKey, pending);
1225
- if (tracing)
1226
- traceTransition(machineKey, snapshot, "failed");
1227
- 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;
1228
1384
  }
1229
1385
  }
1386
+ return succeeded;
1387
+ });
1388
+ if (!exited.ok) {
1389
+ requeue();
1390
+ if (tracing)
1391
+ traceTransition(machineKey, snapshot, "failed");
1392
+ return exited;
1230
1393
  }
1231
1394
  for (const sub of leaving)
1232
1395
  commitMachine(sub.key, currentMachines.get(sub.key), undefined);
1233
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);
1234
1402
  for (const schedule of schedules) {
1235
1403
  const transition = schedule.transition;
1236
1404
  if (transition.phase === "enter" && transition.machine.key === machineKey && transition.state === snapshot.to) {
@@ -1242,10 +1410,9 @@ const makeValidatedRuntime = (options) => {
1242
1410
  }
1243
1411
  }
1244
1412
  }
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;
1413
+ const enteredSchedules = runSubStateEnters(entered, schedules);
1414
+ if (!enteredSchedules.ok)
1415
+ return enteredSchedules;
1249
1416
  }
1250
1417
  return succeeded;
1251
1418
  };
@@ -1260,6 +1427,9 @@ const makeValidatedRuntime = (options) => {
1260
1427
  previousMachines.set(machineKey, from);
1261
1428
  if (to === undefined) {
1262
1429
  currentMachines.delete(machineKey);
1430
+ const discarded = pendingMachines.get(machineKey);
1431
+ if (discarded !== undefined)
1432
+ discardedPending.add(discarded);
1263
1433
  pendingMachines.delete(machineKey);
1264
1434
  }
1265
1435
  else {
@@ -1296,25 +1466,29 @@ const makeValidatedRuntime = (options) => {
1296
1466
  };
1297
1467
  /**
1298
1468
  * 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.
1469
+ * value, and theirs, parents first. Returns what it entered, in that order.
1301
1470
  */
1302
- const enterSubStates = (parentKey, state, schedules) => {
1471
+ const commitSubStates = (parentKey, state) => {
1472
+ const entered = [];
1303
1473
  for (const sub of subStates.get(parentKey) ?? []) {
1304
1474
  if (sub.within.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key))
1305
1475
  continue;
1306
1476
  const value = subStateEntry.get(sub.key);
1307
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) {
1308
1485
  const snapshot = { from: undefined, to: value };
1309
- const result = runPhase(sub.key, "enter", value, snapshot, schedules);
1486
+ const result = runPhase(key, "enter", value, snapshot, schedules);
1310
1487
  if (!result.ok) {
1311
1488
  if (tracing)
1312
- traceTransition(sub.key, snapshot, "enterFailed");
1489
+ traceTransition(key, snapshot, "enterFailed");
1313
1490
  return result;
1314
1491
  }
1315
- const nested = enterSubStates(sub.key, value, schedules);
1316
- if (!nested.ok)
1317
- return nested;
1318
1492
  }
1319
1493
  return succeeded;
1320
1494
  };
@@ -1349,8 +1523,14 @@ const makeValidatedRuntime = (options) => {
1349
1523
  }
1350
1524
  return succeeded;
1351
1525
  }
1352
- /** 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. */
1353
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;
1354
1534
  /**
1355
1535
  * Runs one schedule. A failing system stops the schedule; systems that
1356
1536
  * already succeeded stay committed, and their pending work stays pending.
@@ -1372,7 +1552,7 @@ const makeValidatedRuntime = (options) => {
1372
1552
  /** Events declared as sent by the host (`debug.declareHostEvents`). */
1373
1553
  let hostEvents = new Set();
1374
1554
  const send = (descriptor, value) => {
1375
- if (ticking)
1555
+ if (inTick)
1376
1556
  throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`);
1377
1557
  if (!eventsByKey.has(descriptor.key))
1378
1558
  throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`);
@@ -1446,6 +1626,7 @@ const makeValidatedRuntime = (options) => {
1446
1626
  if (debugEnabled)
1447
1627
  reportUnread("next-tick");
1448
1628
  ticking = true;
1629
+ inTick = true;
1449
1630
  try {
1450
1631
  advanceFrame();
1451
1632
  return watchResult(observedTick(schedules, () => {
@@ -1459,6 +1640,7 @@ const makeValidatedRuntime = (options) => {
1459
1640
  }
1460
1641
  finally {
1461
1642
  ticking = false;
1643
+ inTick = false;
1462
1644
  }
1463
1645
  };
1464
1646
  /** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
@@ -1471,9 +1653,11 @@ const makeValidatedRuntime = (options) => {
1471
1653
  result = run();
1472
1654
  }
1473
1655
  catch (defect) {
1656
+ inTick = false;
1474
1657
  emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect });
1475
1658
  throw defect;
1476
1659
  }
1660
+ inTick = false;
1477
1661
  emitHost(result.ok
1478
1662
  ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
1479
1663
  : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error });
@@ -1506,8 +1690,16 @@ const makeValidatedRuntime = (options) => {
1506
1690
  if (issues.length > 0) {
1507
1691
  return watchResult(Result.failure({ kind: "TickOrderViolation", issues }), [schedule]);
1508
1692
  }
1509
- advanceFrame();
1510
- 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
+ }
1511
1703
  };
1512
1704
  /**
1513
1705
  * Evaluates a declared read-only projection without running a schedule.
@@ -1535,6 +1727,8 @@ const makeValidatedRuntime = (options) => {
1535
1727
  for (const provision of machineEntries)
1536
1728
  machinesByName.set(provision.machine.name, provision.machine);
1537
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");
1538
1732
  const savedResources = {};
1539
1733
  for (const [name, descriptor] of resourcesByName) {
1540
1734
  if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
@@ -1555,6 +1749,7 @@ const makeValidatedRuntime = (options) => {
1555
1749
  }
1556
1750
  return {
1557
1751
  version: Snapshot.version,
1752
+ gameVersion: options.saves?.latest ?? 0,
1558
1753
  nextEntity: world.nextEntityValue(),
1559
1754
  entities: world.exportEntities(DescriptorModule.isTransient),
1560
1755
  relations: world.exportRelations(),
@@ -1570,7 +1765,10 @@ const makeValidatedRuntime = (options) => {
1570
1765
  const parsed = Snapshot.parse(data);
1571
1766
  if (!parsed.ok)
1572
1767
  return parsed;
1573
- const saved = parsed.value;
1768
+ const migrated = migrateSave(parsed.value, options.saves);
1769
+ if (!migrated.ok)
1770
+ return migrated;
1771
+ const saved = migrated.value;
1574
1772
  // Validate everything before touching the world.
1575
1773
  const ids = new Set();
1576
1774
  const entities = [];
@@ -1669,6 +1867,8 @@ const makeValidatedRuntime = (options) => {
1669
1867
  const scoped = new Set();
1670
1868
  const memberships = [];
1671
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 });
1672
1872
  for (const id of members) {
1673
1873
  if (!ids.has(id) || scoped.has(id))
1674
1874
  return Result.failure({ _tag: "InvalidScope", name, entityId: id });
@@ -1708,6 +1908,8 @@ const makeValidatedRuntime = (options) => {
1708
1908
  return succeeded;
1709
1909
  };
1710
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");
1711
1913
  const result = restore(data);
1712
1914
  if (tracing)
1713
1915
  emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok });
@@ -1856,6 +2058,9 @@ const makeValidatedRuntime = (options) => {
1856
2058
  commandUses,
1857
2059
  ranSystems,
1858
2060
  requirementViolations: [...requirementViolations.values()],
2061
+ nextStateReplacements: [...nextStateReplacements.values()],
2062
+ staleTargets: [...staleTargets.values()],
2063
+ streamLosses: [...streamLosses.values()],
1859
2064
  hasResource: (descriptor) => resources.has(descriptor.key),
1860
2065
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule])
1861
2066
  }),
@@ -1895,8 +2100,9 @@ const makeValidatedRuntime = (options) => {
1895
2100
  * Creates a runtime for a fully built schema and a set of external services.
1896
2101
  *
1897
2102
  * Resources owned by constructed descriptors take raw input and are validated
1898
- * here; when any are provided the runtime comes back as a `Result`. Otherwise
1899
- * 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.
1900
2106
  *
1901
2107
  * The runtime does not own the outer frame loop. It only owns ECS state plus
1902
2108
  * the host-provided services that systems are allowed to depend on.
@@ -1909,13 +2115,10 @@ const makeValidatedRuntime = (options) => {
1909
2115
  * ),
1910
2116
  * resources: { Score: 0 },
1911
2117
  * machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
1912
- * })
2118
+ * }).value
1913
2119
  * ```
1914
2120
  */
1915
2121
  export const make = (options) => {
1916
- const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) => options.resources !== undefined
1917
- && options.resources[key] !== undefined
1918
- && DescriptorModule.hasConstructor(descriptor));
1919
2122
  const validated = collectConstructedRegistryValues(options.schema.resources, options.resources);
1920
2123
  if (!validated.ok) {
1921
2124
  return Result.failure({ resources: validated.error });
@@ -1927,9 +2130,18 @@ export const make = (options) => {
1927
2130
  machines: options.machines,
1928
2131
  machineDefinitions: options.machineDefinitions ?? [],
1929
2132
  scopeDefinitions: options.scopeDefinitions ?? [],
1930
- debug: options.debug
2133
+ debug: options.debug,
2134
+ saves: options.saves
1931
2135
  });
1932
- 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
+ }
1933
2145
  };
1934
2146
  /**
1935
2147
  * Builds the descriptor-backed runtime service environment.
@@ -1940,6 +2152,8 @@ export const make = (options) => {
1940
2152
  *
1941
2153
  * The helper keeps the runtime map keyed by service descriptors instead of
1942
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.
1943
2157
  *
1944
2158
  * @example
1945
2159
  * ```ts
@@ -1951,6 +2165,7 @@ export const make = (options) => {
1951
2165
  * ```
1952
2166
  */
1953
2167
  export const services = (...entries) => {
2168
+ rejectRepeated(entries.map((entry) => entry.descriptor.name), "service");
1954
2169
  const provided = {};
1955
2170
  for (const { descriptor, implementation } of entries) {
1956
2171
  provided[descriptor.name] = implementation;
@@ -1973,9 +2188,12 @@ export const service = (descriptor, implementation) => ({
1973
2188
  * Builds the machine initialization environment from machine definitions.
1974
2189
  *
1975
2190
  * Use this when gameplay phases need a known committed starting state before
1976
- * 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.
1977
2194
  */
1978
2195
  export const machines = (...entries) => {
2196
+ rejectRepeated(entries.map((entry) => entry.machine.name), "machine");
1979
2197
  const provided = {};
1980
2198
  for (const { machine, initial } of entries) {
1981
2199
  provided[machine.name] = initial;