@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/src/Runtime.ts CHANGED
@@ -63,8 +63,10 @@ import type { Descriptor } from "./Descriptor.ts"
63
63
  import type * as Entity from "./Entity.ts"
64
64
  import type * as Inspector from "./Inspector.ts"
65
65
  import * as Cells from "./internal/cells.ts"
66
+ import { sameValue } from "./internal/equal.ts"
66
67
  import * as DebugInternal from "./internal/debug.ts"
67
68
  import { makeQueryEngine } from "./internal/queries.ts"
69
+ import { requirementIndex } from "./internal/requirements.ts"
68
70
  import * as Streams from "./internal/streams.ts"
69
71
  import { ABSENT, makeWorld } from "./internal/world.ts"
70
72
  import type { WorldHooks } from "./internal/world.ts"
@@ -83,6 +85,7 @@ import type { Registry, Schema } from "./Schema.ts"
83
85
  import type {
84
86
  DespawnedReadView,
85
87
  EventReadView,
88
+ ResourceChangedView,
86
89
  EventWriteView,
87
90
  LookupApi,
88
91
  QueryHandle,
@@ -705,6 +708,7 @@ const makeValidatedRuntime = <
705
708
  readonly resources: Partial<Record<string, unknown>>
706
709
  readonly machines: RuntimeMachines<any> | undefined
707
710
  readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
711
+ readonly scopeDefinitions: ReadonlyArray<EntityScopeModule.EntityScope.Any>
708
712
  readonly debug: Debug.Option | undefined
709
713
  }): Runtime<S, Services, Resources, Root, Machines> => {
710
714
  const world = makeWorld(options.schema, streamCapacity)
@@ -801,8 +805,48 @@ const makeValidatedRuntime = <
801
805
  options.machineDefinitions.map((machine, index) => [machine.key, index] as const)
802
806
  )
803
807
 
808
+ /** Machines the runtime was given an initial value for. */
809
+ const providedMachineKeys = new Set<symbol>()
804
810
  for (const provision of machineEntries) {
805
811
  currentMachines.set(provision.machine.key, provision.initial)
812
+ providedMachineKeys.add(provision.machine.key)
813
+ }
814
+
815
+ /**
816
+ * Sub-states by parent machine key, in definition order, and the value
817
+ * each enters whenever its parent enters the state it exists in (its
818
+ * runtime-provided value). A sub-state has no current value while its
819
+ * parent is elsewhere, and never one when the runtime does not provide it.
820
+ */
821
+ const subStates = new Map<symbol, Array<Machine.StateMachine.Any>>()
822
+ const subStateEntry = new Map<symbol, unknown>()
823
+ for (const machine of options.machineDefinitions) {
824
+ if (machine.within === undefined) continue
825
+ const siblings = subStates.get(machine.within.machine.key)
826
+ if (siblings === undefined) subStates.set(machine.within.machine.key, [machine])
827
+ else siblings.push(machine)
828
+ if (!currentMachines.has(machine.key)) continue
829
+ subStateEntry.set(machine.key, currentMachines.get(machine.key))
830
+ }
831
+ // Definition order puts a parent before its sub-states, so a sub-state of
832
+ // an inactive sub-state is cleared too.
833
+ for (const machine of options.machineDefinitions) {
834
+ if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
835
+ currentMachines.delete(machine.key)
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Scope despawn triggers by machine key, in scope definition order
841
+ * (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
842
+ */
843
+ const scopeTriggers = new Map<symbol, Array<{ readonly trigger: EntityScopeModule.Trigger; readonly scope: EntityScopeModule.EntityScope.Any }>>()
844
+ for (const scope of options.scopeDefinitions) {
845
+ for (const trigger of scope.despawnOn) {
846
+ const triggers = scopeTriggers.get(trigger.machine.key)
847
+ if (triggers === undefined) scopeTriggers.set(trigger.machine.key, [{ trigger, scope }])
848
+ else triggers.push({ trigger, scope })
849
+ }
806
850
  }
807
851
 
808
852
  const appendRelationFailure = (
@@ -859,13 +903,63 @@ const makeValidatedRuntime = <
859
903
  systems.add(commandOrigin)
860
904
  }
861
905
 
906
+ /**
907
+ * `Schema.requires`, checked when commands are applied. Types reject a
908
+ * breaking command when they can see it; this catches the rest: a query
909
+ * proof gone stale within one flush (another system's command applied in
910
+ * between), erased types, and raw ids. A breaking command is skipped
911
+ * whole, traced, and reported by the `requirement-violated` lint.
912
+ */
913
+ const requirements = requirementIndex(options.schema)
914
+ /** With `debug`, skipped commands by system, command, and requirement, for the lint. */
915
+ const requirementViolations = new Map<string, Debug.RequirementViolation>()
916
+ const violateRequirement = (
917
+ command: Debug.RequirementViolation["command"],
918
+ entity: number,
919
+ [component, missing]: readonly [string, string]
920
+ ): void => {
921
+ if (tracing) recordEffect({ kind: "requirementViolated", entity, command, component, missing })
922
+ if (debugEnabled) {
923
+ const key = `${commandOrigin}/${command}/${component}/${missing}`
924
+ const seen = requirementViolations.get(key)
925
+ requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 })
926
+ }
927
+ }
928
+ const holds = (id: number) => {
929
+ const record = world.records.get(id)
930
+ return (descriptor: Descriptor<"component", string, any>): boolean =>
931
+ record !== undefined && world.has(record, world.ordinalOf(descriptor))
932
+ }
933
+ /** The first requirement `components` break, as `[requirer, missing]`. */
934
+ const missingIn = (
935
+ components: ReadonlyArray<Entity.StagedComponent>,
936
+ alsoHeld: (descriptor: Descriptor<"component", string, any>) => boolean
937
+ ): readonly [string, string] | undefined => {
938
+ for (const [descriptor] of components) {
939
+ for (const required of requirements.requires.get(descriptor.name) ?? []) {
940
+ if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
941
+ return [descriptor.name, required.name]
942
+ }
943
+ }
944
+ }
945
+ return undefined
946
+ }
947
+
862
948
  /**
863
949
  * World adapter used by deferred commands.
864
950
  */
865
951
  const commandWorld: Command.InternalWorld<S> = {
866
952
  spawnEntity(id, components) {
953
+ if (!requirements.empty) {
954
+ const missing = missingIn(components, () => false)
955
+ if (missing !== undefined) {
956
+ violateRequirement("spawn", id, missing)
957
+ return false
958
+ }
959
+ }
867
960
  if (debugEnabled) for (const [descriptor] of components) noteCommand("spawns", descriptor.name)
868
961
  world.spawnEntity(id, components)
962
+ return true
869
963
  },
870
964
  destroyEntity(id) {
871
965
  if (debugEnabled) {
@@ -883,13 +977,35 @@ const makeValidatedRuntime = <
883
977
  if (debugEnabled) noteCommand("despawnsScope", scope.name)
884
978
  world.destroyEntityScope(scope.key)
885
979
  },
886
- removeComponent(id, descriptor) {
887
- if (debugEnabled) noteCommand("removes", descriptor.name)
888
- world.removeComponent(id, descriptor)
980
+ removeComponents(id, descriptors) {
981
+ if (!requirements.empty && world.records.has(id)) {
982
+ const held = holds(id)
983
+ for (const descriptor of descriptors) {
984
+ for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
985
+ if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
986
+ violateRequirement("remove", id, [requirer.name, descriptor.name])
987
+ return
988
+ }
989
+ }
990
+ }
991
+ }
992
+ for (const descriptor of descriptors) {
993
+ if (debugEnabled) noteCommand("removes", descriptor.name)
994
+ world.removeComponent(id, descriptor)
995
+ }
889
996
  },
890
- writeComponent(id, descriptor, value) {
891
- if (debugEnabled) noteCommand("inserts", descriptor.name)
892
- world.writeComponent(id, descriptor, value)
997
+ insertComponents(id, entries) {
998
+ if (!requirements.empty && world.records.has(id)) {
999
+ const missing = missingIn(entries, holds(id))
1000
+ if (missing !== undefined) {
1001
+ violateRequirement("insert", id, missing)
1002
+ return
1003
+ }
1004
+ }
1005
+ for (const [descriptor, value] of entries) {
1006
+ if (debugEnabled) noteCommand("inserts", descriptor.name)
1007
+ world.writeComponent(id, descriptor, value)
1008
+ }
893
1009
  },
894
1010
  tryRelate(id, relation, target) {
895
1011
  if (debugEnabled) {
@@ -1091,6 +1207,15 @@ const makeValidatedRuntime = <
1091
1207
  }
1092
1208
  const writeResource = journalStoreWrite(resources, resourceOriginals)
1093
1209
 
1210
+ /**
1211
+ * Resource change ticks for `changedResource` readers. Only resources some
1212
+ * system reads with `changedResource` are compared at commit; a reader's
1213
+ * first run reports a change anyway, so writes before it registers are not
1214
+ * needed.
1215
+ */
1216
+ const watchedResources = new Set<symbol>()
1217
+ const resourceChangedAt = new Map<symbol, number>()
1218
+
1094
1219
  const journalMachine = (key: symbol): void => {
1095
1220
  if (!machineOriginals.has(key)) {
1096
1221
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue)
@@ -1103,6 +1228,14 @@ const makeValidatedRuntime = <
1103
1228
 
1104
1229
  const commitSystemTransaction = (): void => {
1105
1230
  world.commitTransaction()
1231
+ if (watchedResources.size > 0) {
1232
+ for (const [key, before] of resourceOriginals) {
1233
+ if (!watchedResources.has(key)) continue
1234
+ if (before === absentValue || !sameValue(before, resources.get(key))) {
1235
+ resourceChangedAt.set(key, world.currentTick())
1236
+ }
1237
+ }
1238
+ }
1106
1239
  if (emittedEvents.size > 0) {
1107
1240
  const published = world.advanceTick()
1108
1241
  for (const [key, values] of emittedEvents) {
@@ -1145,6 +1278,17 @@ const makeValidatedRuntime = <
1145
1278
  write: (key: symbol, value: unknown) => void
1146
1279
  ) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor))
1147
1280
 
1281
+ const makeResourceChangedView = (descriptor: Descriptor<"resource", string, any>, reader: ReaderState): ResourceChangedView<unknown> => {
1282
+ const key = descriptor.key
1283
+ watchedResources.add(key)
1284
+ const read = Cells.storeRead(resources, key)
1285
+ return {
1286
+ get: () => read.get(),
1287
+ // `since` is the previous completed run; 0 before the first one.
1288
+ changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
1289
+ }
1290
+ }
1291
+
1148
1292
  const makeEventReadView = <T>(descriptorKey: symbol, reader: ReaderState): EventReadView<T> => ({
1149
1293
  all() {
1150
1294
  return events.since(descriptorKey, reader.streamSince) as ReadonlyArray<ReadonlyValue<T>>
@@ -1211,19 +1355,33 @@ const makeValidatedRuntime = <
1211
1355
  })
1212
1356
 
1213
1357
  const makeMachineReadView = <M extends Machine.StateMachine.Any>(stateMachine: M): MachineReadView<M> => ({
1214
- get: () => currentMachines.get(stateMachine.key) as Machine.StateMachine.Value<M>,
1358
+ get: () => currentMachines.get(stateMachine.key) as Machine.CurrentValue<M>,
1215
1359
  is: (value) => currentMachines.get(stateMachine.key) === value
1216
1360
  })
1217
1361
 
1362
+ /** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
1363
+ const queueMachine = <M extends Machine.StateMachine.Any>(stateMachine: M, value: unknown, skipIfSame: boolean): Machine.SetResult<M> => {
1364
+ const within = stateMachine.within
1365
+ if (within !== undefined && !currentMachines.has(stateMachine.key)) {
1366
+ return Result.failure({
1367
+ _tag: "SubStateInactive",
1368
+ machine: stateMachine.name,
1369
+ parent: within.machine.name,
1370
+ requires: within.state
1371
+ } satisfies Machine.SubStateInactive) as Machine.SetResult<M>
1372
+ }
1373
+ journalMachine(stateMachine.key)
1374
+ pendingMachines.set(stateMachine.key, { value, skipIfSame })
1375
+ return (within === undefined ? undefined : succeeded) as Machine.SetResult<M>
1376
+ }
1377
+
1218
1378
  const makeNextMachineWriteView = <M extends Machine.StateMachine.Any>(stateMachine: M): NextMachineWriteView<M> => ({
1219
1379
  getPending: () => pendingMachines.get(stateMachine.key)?.value as Machine.StateMachine.Value<M> | undefined,
1220
1380
  set(value) {
1221
- journalMachine(stateMachine.key)
1222
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false })
1381
+ return queueMachine(stateMachine, value, false)
1223
1382
  },
1224
1383
  setIfChanged(value) {
1225
- journalMachine(stateMachine.key)
1226
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true })
1384
+ return queueMachine(stateMachine, value, true)
1227
1385
  },
1228
1386
  reset() {
1229
1387
  journalMachine(stateMachine.key)
@@ -1282,7 +1440,9 @@ const makeValidatedRuntime = <
1282
1440
  resources: mapRecord(spec.resources as Record<string, any>, (access) =>
1283
1441
  access.mode === "read"
1284
1442
  ? Cells.storeRead(resources, access.descriptor.key)
1285
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
1443
+ : access.mode === "changed"
1444
+ ? makeResourceChangedView(access.descriptor, reader)
1445
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
1286
1446
  events: mapRecord(spec.events as Record<string, any>, (access) =>
1287
1447
  access.mode === "read"
1288
1448
  ? makeEventReadView(access.descriptor.key, reader)
@@ -1626,6 +1786,36 @@ const makeValidatedRuntime = <
1626
1786
  }
1627
1787
  }
1628
1788
 
1789
+ /**
1790
+ * Despawns the scopes whose trigger matches a committed transition: leaving
1791
+ * `snapshot.from` or entering `snapshot.to`. It runs right after the
1792
+ * commit, so `onExit` systems still saw the members and entities spawned by
1793
+ * `onEnter` survive. Members are the entities in the scope at that moment:
1794
+ * commands queued by `onExit` systems and not yet applied are not.
1795
+ */
1796
+ const despawnTriggeredScopes = (machineKey: symbol, snapshot: Machine.TransitionSnapshot): void => {
1797
+ const triggers = scopeTriggers.get(machineKey)
1798
+ if (triggers === undefined) return
1799
+ const applied: Array<Debug.AppliedCommand> = []
1800
+ for (const { trigger, scope } of triggers) {
1801
+ const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to
1802
+ if (!matches) continue
1803
+ const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`
1804
+ const effects: Array<Debug.Effect> = []
1805
+ commandEffects = tracing ? effects : undefined
1806
+ commandOrigin = origin
1807
+ try {
1808
+ commandWorld.destroyEntityScope(scope)
1809
+ } finally {
1810
+ commandEffects = undefined
1811
+ }
1812
+ if (tracing) applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects })
1813
+ }
1814
+ if (applied.length > 0) {
1815
+ emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied })
1816
+ }
1817
+ }
1818
+
1629
1819
  /**
1630
1820
  * Applies queued commands, then queued machine transitions, running the
1631
1821
  * matching exit, transition, and enter schedules from the bundle.
@@ -1643,6 +1833,10 @@ const makeValidatedRuntime = <
1643
1833
 
1644
1834
  const schedules = bundle?.entries ?? []
1645
1835
  for (const [machineKey, pending] of scheduledTransitions) {
1836
+ if (pendingMachines.get(machineKey) !== pending) {
1837
+ // Discarded earlier in this marker: its parent left the state it exists in.
1838
+ continue
1839
+ }
1646
1840
  pendingMachines.delete(machineKey)
1647
1841
  const current = currentMachines.get(machineKey)
1648
1842
  if (current === undefined) {
@@ -1653,12 +1847,25 @@ const makeValidatedRuntime = <
1653
1847
  continue
1654
1848
  }
1655
1849
 
1656
- previousMachines.set(machineKey, current)
1657
1850
  const snapshot = {
1658
1851
  from: current as Machine.StateValue,
1659
1852
  to: pending.value as Machine.StateValue
1660
1853
  }
1661
1854
 
1855
+ // Sub-states that exist in the state being left exit first, deepest
1856
+ // first: their exit schedules run before the parent's, while they
1857
+ // still have their values, and they lose them at the parent's commit.
1858
+ const leaving = activeSubStatesIn(machineKey, snapshot.from)
1859
+ for (const sub of leaving) {
1860
+ const value = currentMachines.get(sub.key)
1861
+ const result = runPhase(sub.key, "exit", value, { from: value, to: undefined } as unknown as Machine.TransitionSnapshot, schedules)
1862
+ if (!result.ok) {
1863
+ pendingMachines.set(machineKey, pending)
1864
+ if (tracing) traceTransition(machineKey, snapshot, "failed")
1865
+ return result
1866
+ }
1867
+ }
1868
+
1662
1869
  for (const schedule of schedules) {
1663
1870
  const transition = schedule.transition
1664
1871
  if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
@@ -1688,10 +1895,8 @@ const makeValidatedRuntime = <
1688
1895
  }
1689
1896
  }
1690
1897
 
1691
- currentMachines.set(machineKey, pending.value)
1692
- changedMachines.add(machineKey)
1693
- transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
1694
- if (tracing) traceTransition(machineKey, snapshot, "applied")
1898
+ for (const sub of leaving) commitMachine(sub.key, currentMachines.get(sub.key), undefined)
1899
+ commitMachine(machineKey, current, pending.value)
1695
1900
 
1696
1901
  for (const schedule of schedules) {
1697
1902
  const transition = schedule.transition
@@ -1703,6 +1908,82 @@ const makeValidatedRuntime = <
1703
1908
  }
1704
1909
  }
1705
1910
  }
1911
+ // Sub-states that exist in the entered state enter their value, parents first.
1912
+ const entered = enterSubStates(machineKey, snapshot.to, schedules)
1913
+ if (!entered.ok) return entered
1914
+ }
1915
+ return succeeded
1916
+ }
1917
+
1918
+ /**
1919
+ * Commits one machine's value (`undefined` for a sub-state that stops
1920
+ * existing): records the change and its transition event, traces it, and
1921
+ * despawns the scopes it triggers.
1922
+ */
1923
+ const commitMachine = (machineKey: symbol, from: unknown, to: unknown): void => {
1924
+ const snapshot = { from, to } as unknown as Machine.TransitionSnapshot
1925
+ if (from !== undefined) previousMachines.set(machineKey, from)
1926
+ if (to === undefined) {
1927
+ currentMachines.delete(machineKey)
1928
+ pendingMachines.delete(machineKey)
1929
+ } else {
1930
+ currentMachines.set(machineKey, to)
1931
+ }
1932
+ changedMachines.add(machineKey)
1933
+ transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
1934
+ if (tracing) traceTransition(machineKey, snapshot, "applied")
1935
+ despawnTriggeredScopes(machineKey, snapshot)
1936
+ }
1937
+
1938
+ type TransitionSchedules = ReadonlyArray<Machine.StateMachine.AnyTransitionSchedule<S, Root>>
1939
+
1940
+ /** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
1941
+ const runPhase = (
1942
+ machineKey: symbol,
1943
+ phase: "enter" | "exit",
1944
+ state: unknown,
1945
+ snapshot: Machine.TransitionSnapshot,
1946
+ schedules: TransitionSchedules
1947
+ ): Result.Result<void, SystemFailure> => {
1948
+ for (const schedule of schedules) {
1949
+ const transition = schedule.transition
1950
+ if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
1951
+ const result = runTransitionSchedule(schedule, snapshot)
1952
+ if (!result.ok) return result
1953
+ }
1954
+ }
1955
+ return succeeded
1956
+ }
1957
+
1958
+ /** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
1959
+ const activeSubStatesIn = (parentKey: symbol, state: unknown): Array<Machine.StateMachine.Any> => {
1960
+ const result: Array<Machine.StateMachine.Any> = []
1961
+ for (const sub of subStates.get(parentKey) ?? []) {
1962
+ const value = currentMachines.get(sub.key)
1963
+ if (sub.within!.state !== state || value === undefined) continue
1964
+ result.push(...activeSubStatesIn(sub.key, value), sub)
1965
+ }
1966
+ return result
1967
+ }
1968
+
1969
+ /**
1970
+ * Gives the sub-states of `parentKey` that exist in `state` their entry
1971
+ * value, then runs their `onEnter` schedules and enters their own
1972
+ * sub-states.
1973
+ */
1974
+ const enterSubStates = (parentKey: symbol, state: unknown, schedules: TransitionSchedules): Result.Result<void, SystemFailure> => {
1975
+ for (const sub of subStates.get(parentKey) ?? []) {
1976
+ if (sub.within!.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key)) continue
1977
+ const value = subStateEntry.get(sub.key)
1978
+ commitMachine(sub.key, undefined, value)
1979
+ const snapshot = { from: undefined, to: value } as unknown as Machine.TransitionSnapshot
1980
+ const result = runPhase(sub.key, "enter", value, snapshot, schedules)
1981
+ if (!result.ok) {
1982
+ if (tracing) traceTransition(sub.key, snapshot, "enterFailed")
1983
+ return result
1984
+ }
1985
+ const nested = enterSubStates(sub.key, value, schedules)
1986
+ if (!nested.ok) return nested
1706
1987
  }
1707
1988
  return succeeded
1708
1989
  }
@@ -1876,7 +2157,8 @@ const makeValidatedRuntime = <
1876
2157
  case "resource":
1877
2158
  return resources.has(requirement.key)
1878
2159
  case "stateMachine":
1879
- return currentMachines.has(requirement.key)
2160
+ // An inactive sub-state has no value but is provided.
2161
+ return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key)
1880
2162
  }
1881
2163
  }
1882
2164
 
@@ -1992,6 +2274,11 @@ const makeValidatedRuntime = <
1992
2274
  if (!value.ok) return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error })
1993
2275
  components.push([descriptor, value.value])
1994
2276
  }
2277
+ // Transient components are never saved, so a requirement on one is not checked here.
2278
+ const missing = missingIn(components, DescriptorModule.isTransient)
2279
+ if (missing !== undefined) {
2280
+ return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] })
2281
+ }
1995
2282
  entities.push({ id: entity.id, components })
1996
2283
  }
1997
2284
  const validatedResources: Array<readonly [symbol, unknown]> = []
@@ -2028,12 +2315,29 @@ const makeValidatedRuntime = <
2028
2315
  }
2029
2316
  }
2030
2317
  }
2031
- const machineValues: Array<readonly [symbol, Machine.StateValue]> = []
2318
+ const restoredMachines = new Map(currentMachines)
2032
2319
  for (const [name, value] of Object.entries(saved.machines)) {
2033
2320
  const machine = machinesByName.get(name)
2034
2321
  if (!machine) return Result.failure({ _tag: "UnknownMachine", name })
2035
2322
  if (!machine.values.includes(value)) return Result.failure({ _tag: "InvalidMachineState", name, value })
2036
- machineValues.push([machine.key, value])
2323
+ restoredMachines.set(machine.key, value)
2324
+ }
2325
+ // A save has no value for a sub-state whose parent is elsewhere; one that
2326
+ // has a value must have its parent in the state it exists in. A sub-state
2327
+ // missing while its parent is there enters its entry value.
2328
+ for (const machine of options.machineDefinitions) {
2329
+ const within = machine.within
2330
+ if (within === undefined) continue
2331
+ const active = restoredMachines.get(within.machine.key) === within.state
2332
+ if (Object.hasOwn(saved.machines, machine.name)) {
2333
+ if (!active) {
2334
+ return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state })
2335
+ }
2336
+ } else if (active && subStateEntry.has(machine.key)) {
2337
+ restoredMachines.set(machine.key, subStateEntry.get(machine.key))
2338
+ } else {
2339
+ restoredMachines.delete(machine.key)
2340
+ }
2037
2341
  }
2038
2342
 
2039
2343
  const scoped = new Set<number>()
@@ -2066,8 +2370,12 @@ const makeValidatedRuntime = <
2066
2370
  world.tryRelate(source, relation, target)
2067
2371
  }
2068
2372
  for (const [id, key] of memberships) world.assignEntityScope(id, key)
2069
- for (const [key, value] of validatedResources) resources.set(key, value)
2070
- for (const [key, value] of machineValues) currentMachines.set(key, value)
2373
+ for (const [key, value] of validatedResources) {
2374
+ resources.set(key, value)
2375
+ resourceChangedAt.set(key, world.currentTick())
2376
+ }
2377
+ currentMachines.clear()
2378
+ for (const [key, value] of restoredMachines) currentMachines.set(key, value)
2071
2379
  // Locals are not saved: each system starts over from its factory.
2072
2380
  localSlots.forEach(({ slot }, index) => { (slot.context as { local: unknown }).local = rebuiltLocals[index] })
2073
2381
  return succeeded
@@ -2217,11 +2525,14 @@ const makeValidatedRuntime = <
2217
2525
  schema: options.schema,
2218
2526
  machines: [...new Set(machinesByName.values())],
2219
2527
  currentMachine: (machine) => currentMachines.get(machine.key) as Machine.StateValue | undefined,
2528
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
2529
+ scopes: options.scopeDefinitions,
2220
2530
  providedServices: Object.keys(providedServices),
2221
2531
  externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
2222
2532
  relationMutations,
2223
2533
  commandUses,
2224
2534
  ranSystems,
2535
+ requirementViolations: [...requirementViolations.values()],
2225
2536
  hasResource: (descriptor) => resources.has(descriptor.key),
2226
2537
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
2227
2538
  }),
@@ -2292,6 +2603,8 @@ export const make = <
2292
2603
  readonly resources?: Resources
2293
2604
  readonly machines?: ProvidedMachines
2294
2605
  readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
2606
+ /** Scopes whose members a committed state change despawns; `Game.Runtime.make` passes every `Game.EntityScope`. */
2607
+ readonly scopeDefinitions?: ReadonlyArray<EntityScopeModule.EntityScope.Any>
2295
2608
  /** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
2296
2609
  readonly debug?: Enabled
2297
2610
  }): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
@@ -2310,6 +2623,7 @@ export const make = <
2310
2623
  resources: validated.value,
2311
2624
  machines: options.machines,
2312
2625
  machineDefinitions: options.machineDefinitions ?? [],
2626
+ scopeDefinitions: options.scopeDefinitions ?? [],
2313
2627
  debug: options.debug
2314
2628
  })
2315
2629
  return (fallible ? Result.success(runtime) : runtime) as never