@typeonce/ritmo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +3 -2
  2. package/dist/Clock.d.ts +19 -4
  3. package/dist/Clock.d.ts.map +1 -1
  4. package/dist/Clock.js +18 -2
  5. package/dist/Clock.js.map +1 -1
  6. package/dist/Command.d.ts +181 -40
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +31 -11
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Condition.d.ts +12 -1
  11. package/dist/Condition.d.ts.map +1 -1
  12. package/dist/Condition.js.map +1 -1
  13. package/dist/Debug.d.ts +127 -13
  14. package/dist/Debug.d.ts.map +1 -1
  15. package/dist/Descriptor.d.ts +61 -10
  16. package/dist/Descriptor.d.ts.map +1 -1
  17. package/dist/Descriptor.js +22 -10
  18. package/dist/Descriptor.js.map +1 -1
  19. package/dist/Entity.d.ts +22 -7
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js.map +1 -1
  22. package/dist/EntityScope.d.ts +20 -1
  23. package/dist/EntityScope.d.ts.map +1 -1
  24. package/dist/EntityScope.js +8 -2
  25. package/dist/EntityScope.js.map +1 -1
  26. package/dist/FixedLoop.d.ts +106 -0
  27. package/dist/FixedLoop.d.ts.map +1 -0
  28. package/dist/FixedLoop.js +97 -0
  29. package/dist/FixedLoop.js.map +1 -0
  30. package/dist/HostClock.d.ts +1 -1
  31. package/dist/HostClock.d.ts.map +1 -1
  32. package/dist/HostClock.js +2 -1
  33. package/dist/HostClock.js.map +1 -1
  34. package/dist/Machine.d.ts +59 -34
  35. package/dist/Machine.d.ts.map +1 -1
  36. package/dist/Machine.js +10 -3
  37. package/dist/Machine.js.map +1 -1
  38. package/dist/Query.d.ts +100 -14
  39. package/dist/Query.d.ts.map +1 -1
  40. package/dist/Query.js +34 -5
  41. package/dist/Query.js.map +1 -1
  42. package/dist/Relation.d.ts +5 -14
  43. package/dist/Relation.d.ts.map +1 -1
  44. package/dist/Relation.js +0 -32
  45. package/dist/Relation.js.map +1 -1
  46. package/dist/Runtime.d.ts +61 -15
  47. package/dist/Runtime.d.ts.map +1 -1
  48. package/dist/Runtime.js +594 -70
  49. package/dist/Runtime.js.map +1 -1
  50. package/dist/Schedule.d.ts +24 -15
  51. package/dist/Schedule.d.ts.map +1 -1
  52. package/dist/Schedule.js +2 -1
  53. package/dist/Schedule.js.map +1 -1
  54. package/dist/Schema.d.ts +233 -48
  55. package/dist/Schema.d.ts.map +1 -1
  56. package/dist/Schema.js +33 -7
  57. package/dist/Schema.js.map +1 -1
  58. package/dist/Snapshot.d.ts +90 -1
  59. package/dist/Snapshot.d.ts.map +1 -1
  60. package/dist/Snapshot.js +42 -1
  61. package/dist/Snapshot.js.map +1 -1
  62. package/dist/System.d.ts +132 -31
  63. package/dist/System.d.ts.map +1 -1
  64. package/dist/System.js +32 -0
  65. package/dist/System.js.map +1 -1
  66. package/dist/index.d.ts +4 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/internal/cells.d.ts +2 -0
  71. package/dist/internal/cells.d.ts.map +1 -1
  72. package/dist/internal/cells.js +34 -8
  73. package/dist/internal/cells.js.map +1 -1
  74. package/dist/internal/debug.d.ts +10 -0
  75. package/dist/internal/debug.d.ts.map +1 -1
  76. package/dist/internal/debug.js +171 -12
  77. package/dist/internal/debug.js.map +1 -1
  78. package/dist/internal/equal.d.ts +8 -0
  79. package/dist/internal/equal.d.ts.map +1 -0
  80. package/dist/internal/equal.js +38 -0
  81. package/dist/internal/equal.js.map +1 -0
  82. package/dist/internal/fragments.d.ts +2 -6
  83. package/dist/internal/fragments.d.ts.map +1 -1
  84. package/dist/internal/fragments.js +36 -3
  85. package/dist/internal/fragments.js.map +1 -1
  86. package/dist/internal/game.d.ts +2 -0
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +48 -7
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts.map +1 -1
  91. package/dist/internal/order.js +4 -2
  92. package/dist/internal/order.js.map +1 -1
  93. package/dist/internal/queries.d.ts.map +1 -1
  94. package/dist/internal/queries.js +57 -26
  95. package/dist/internal/queries.js.map +1 -1
  96. package/dist/internal/relations.d.ts +10 -0
  97. package/dist/internal/relations.d.ts.map +1 -0
  98. package/dist/internal/relations.js +32 -0
  99. package/dist/internal/relations.js.map +1 -0
  100. package/dist/internal/requirements.d.ts +26 -0
  101. package/dist/internal/requirements.d.ts.map +1 -0
  102. package/dist/internal/requirements.js +46 -0
  103. package/dist/internal/requirements.js.map +1 -0
  104. package/dist/internal/streams.d.ts +2 -0
  105. package/dist/internal/streams.d.ts.map +1 -1
  106. package/dist/internal/streams.js +10 -0
  107. package/dist/internal/streams.js.map +1 -1
  108. package/dist/internal/world.d.ts +7 -1
  109. package/dist/internal/world.d.ts.map +1 -1
  110. package/dist/internal/world.js +53 -3
  111. package/dist/internal/world.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/Clock.ts +29 -4
  114. package/src/Command.ts +281 -67
  115. package/src/Condition.ts +17 -1
  116. package/src/Debug.ts +124 -15
  117. package/src/Descriptor.ts +92 -21
  118. package/src/Entity.ts +23 -7
  119. package/src/EntityScope.ts +21 -2
  120. package/src/FixedLoop.ts +218 -0
  121. package/src/HostClock.ts +2 -1
  122. package/src/Machine.ts +72 -35
  123. package/src/Query.ts +118 -17
  124. package/src/Relation.ts +5 -67
  125. package/src/Runtime.ts +659 -85
  126. package/src/Schedule.ts +31 -19
  127. package/src/Schema.ts +297 -57
  128. package/src/Snapshot.ts +94 -2
  129. package/src/System.ts +151 -29
  130. package/src/index.ts +4 -0
  131. package/src/internal/cells.ts +42 -10
  132. package/src/internal/debug.ts +179 -14
  133. package/src/internal/equal.ts +31 -0
  134. package/src/internal/fragments.ts +35 -5
  135. package/src/internal/game.ts +69 -7
  136. package/src/internal/order.ts +8 -4
  137. package/src/internal/queries.ts +62 -12
  138. package/src/internal/relations.ts +52 -0
  139. package/src/internal/requirements.ts +71 -0
  140. package/src/internal/streams.ts +11 -0
  141. package/src/internal/world.ts +60 -4
package/src/Runtime.ts 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: {
@@ -63,8 +64,10 @@ import type { Descriptor } from "./Descriptor.ts"
63
64
  import type * as Entity from "./Entity.ts"
64
65
  import type * as Inspector from "./Inspector.ts"
65
66
  import * as Cells from "./internal/cells.ts"
67
+ import { sameValue } from "./internal/equal.ts"
66
68
  import * as DebugInternal from "./internal/debug.ts"
67
69
  import { makeQueryEngine } from "./internal/queries.ts"
70
+ import { requirementIndex } from "./internal/requirements.ts"
68
71
  import * as Streams from "./internal/streams.ts"
69
72
  import { ABSENT, makeWorld } from "./internal/world.ts"
70
73
  import type { WorldHooks } from "./internal/world.ts"
@@ -83,9 +86,11 @@ import type { Registry, Schema } from "./Schema.ts"
83
86
  import type {
84
87
  DespawnedReadView,
85
88
  EventReadView,
89
+ ResourceChangedView,
86
90
  EventWriteView,
87
91
  LookupApi,
88
- QueryHandle,
92
+ QueryIteration,
93
+ QueryLookups,
89
94
  RelationFailureReadView,
90
95
  RemovedReadView,
91
96
  TransitionEventReadView,
@@ -115,7 +120,7 @@ import type {
115
120
  * services: Game.Runtime.services(
116
121
  * Game.Runtime.service(Logger, { log: console.log })
117
122
  * )
118
- * })
123
+ * }).value
119
124
  *
120
125
  * runtime.tick(updateSchedule)
121
126
  * ```
@@ -154,6 +159,17 @@ export type RuntimeResources<S extends Schema.Any> = Partial<{
154
159
  : Schema.ResourceValue<S, K>
155
160
  }>
156
161
 
162
+ /**
163
+ * Rejects keys of `Provided` that are not resource keys of the schema, naming
164
+ * each one. A misspelled key next to known ones would otherwise compile and
165
+ * be dropped.
166
+ */
167
+ export type ExactResources<S extends Schema.Any, Provided> = {
168
+ readonly [K in Exclude<keyof Provided, keyof Schema.Resources<S>>]: {
169
+ readonly __fixResources__: `${K & string} is not a resource key of this schema: use a key of the fragment's resources: { ... }`
170
+ }
171
+ }
172
+
157
173
  type ConstructedKeys<S extends Schema.Any, Provided> = {
158
174
  readonly [K in keyof Provided]: K extends keyof Schema.Resources<S>
159
175
  ? Schema.Resources<S>[K] extends DescriptorModule.ConstructedDescriptor<"resource", string, any, any, any> ? K : never
@@ -190,8 +206,9 @@ export type WithDebug<R, Enabled, S extends Schema.Any, Root> =
190
206
  : R & Partial<Debug.Member<S, Root>>
191
207
 
192
208
  /**
193
- * The runtime returned by `make`, wrapped in a `Result` exactly when a
194
- * provided resource goes through a constructor and can therefore fail.
209
+ * What `make` returns: always a `Result`. It is a `Success` when nothing
210
+ * provided can fail, so `.value` is the runtime; when a provided resource
211
+ * goes through a constructor, it may be a `Failure` and must be checked.
195
212
  */
196
213
  export type MakeRuntimeResult<
197
214
  S extends Schema.Any,
@@ -201,7 +218,7 @@ export type MakeRuntimeResult<
201
218
  Machines extends Record<string, unknown>,
202
219
  Enabled = undefined
203
220
  > = [ConstructedKeys<S, Provided>] extends [never]
204
- ? WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>
221
+ ? Result.Success<WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>>
205
222
  : Result.Result<
206
223
  WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>,
207
224
  RuntimeConstructionError<S, Provided>
@@ -634,12 +651,16 @@ export interface Runtime<
634
651
  * resources are skipped.
635
652
  *
636
653
  * Callable only when every component and resource is constructed or
637
- * transient; see the `Snapshot` module.
654
+ * transient; see the `Snapshot` module. Take it between ticks: calling it
655
+ * while a schedule is running (from a system, a service, or a trace
656
+ * callback) throws.
638
657
  */
639
658
  readonly snapshot: Snapshot.Gate<S, () => Snapshot.WorldSnapshot>
640
659
  /**
641
660
  * Replaces the world with a snapshot after validating it against the
642
- * schema. On failure nothing changes. See the `Snapshot` module.
661
+ * schema. On failure nothing changes. See the `Snapshot` module. Restore
662
+ * between ticks: calling it while a schedule is running throws. A current
663
+ * resource the save lacks keeps its value.
643
664
  */
644
665
  readonly restore: Snapshot.Gate<S, (data: unknown) => Result.Result<void, Snapshot.RestoreError>>
645
666
  /**
@@ -658,7 +679,7 @@ export interface Runtime<
658
679
  * The event must belong to the schema. Calling it while a schedule is
659
680
  * running is a defect and throws: inside a tick, emit from a system.
660
681
  */
661
- readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: Descriptor.Value<D>) => void
682
+ readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: ReadonlyValue<Descriptor.Value<D>>) => void
662
683
  }
663
684
 
664
685
  /**
@@ -682,7 +703,7 @@ export interface Runtime<
682
703
  * resources: {
683
704
  * score: 0
684
705
  * }
685
- * })
706
+ * }).value
686
707
  * ```
687
708
  */
688
709
  /**
@@ -693,6 +714,23 @@ export interface Runtime<
693
714
  */
694
715
  export const streamCapacity = 65_536
695
716
 
717
+ /**
718
+ * Brings a parsed save to `migrations.latest`: runs every step after its
719
+ * game version, checking the structure after each.
720
+ */
721
+ const migrateSave = (save: Snapshot.WorldSnapshot, saves: Snapshot.Migrations | undefined): Result.Result<Snapshot.WorldSnapshot, Snapshot.RestoreError> => {
722
+ const latest = saves?.latest ?? 0
723
+ if (save.gameVersion > latest) return Result.failure({ _tag: "NewerGameVersion", gameVersion: save.gameVersion, latest })
724
+ let current = save
725
+ for (const step of saves?.steps ?? []) {
726
+ if (step.to <= current.gameVersion) continue
727
+ const next = Snapshot.parse({ ...step.migrate(current), gameVersion: step.to })
728
+ if (!next.ok) return next
729
+ current = next.value
730
+ }
731
+ return Result.success(current)
732
+ }
733
+
696
734
  const makeValidatedRuntime = <
697
735
  S extends Schema.Any,
698
736
  Services extends Record<string, unknown>,
@@ -705,7 +743,9 @@ const makeValidatedRuntime = <
705
743
  readonly resources: Partial<Record<string, unknown>>
706
744
  readonly machines: RuntimeMachines<any> | undefined
707
745
  readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
746
+ readonly scopeDefinitions: ReadonlyArray<EntityScopeModule.EntityScope.Any>
708
747
  readonly debug: Debug.Option | undefined
748
+ readonly saves: Snapshot.Migrations | undefined
709
749
  }): Runtime<S, Services, Resources, Root, Machines> => {
710
750
  const world = makeWorld(options.schema, streamCapacity)
711
751
  const queries = makeQueryEngine(world)
@@ -720,7 +760,10 @@ const makeValidatedRuntime = <
720
760
  * Machine-keyed committed, pending, and previous values.
721
761
  */
722
762
  const currentMachines = new Map<symbol, unknown>()
723
- const pendingMachines = new Map<symbol, { value: unknown; skipIfSame: boolean }>()
763
+ /** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
764
+ const pendingMachines = new Map<symbol, { value: unknown; skipIfSame: boolean; by?: string }>()
765
+ /** Requests dropped because their sub-state stopped existing: skipped by the marker that queued them. */
766
+ const discardedPending = new WeakSet<object>()
724
767
  const previousMachines = new Map<symbol, unknown>()
725
768
  /**
726
769
  * Machine keys changed by transitions applied during the current schedule run.
@@ -801,8 +844,48 @@ const makeValidatedRuntime = <
801
844
  options.machineDefinitions.map((machine, index) => [machine.key, index] as const)
802
845
  )
803
846
 
847
+ /** Machines the runtime was given an initial value for. */
848
+ const providedMachineKeys = new Set<symbol>()
804
849
  for (const provision of machineEntries) {
805
850
  currentMachines.set(provision.machine.key, provision.initial)
851
+ providedMachineKeys.add(provision.machine.key)
852
+ }
853
+
854
+ /**
855
+ * Sub-states by parent machine key, in definition order, and the value
856
+ * each enters whenever its parent enters the state it exists in (its
857
+ * runtime-provided value). A sub-state has no current value while its
858
+ * parent is elsewhere, and never one when the runtime does not provide it.
859
+ */
860
+ const subStates = new Map<symbol, Array<Machine.StateMachine.Any>>()
861
+ const subStateEntry = new Map<symbol, unknown>()
862
+ for (const machine of options.machineDefinitions) {
863
+ if (machine.within === undefined) continue
864
+ const siblings = subStates.get(machine.within.machine.key)
865
+ if (siblings === undefined) subStates.set(machine.within.machine.key, [machine])
866
+ else siblings.push(machine)
867
+ if (!currentMachines.has(machine.key)) continue
868
+ subStateEntry.set(machine.key, currentMachines.get(machine.key))
869
+ }
870
+ // Definition order puts a parent before its sub-states, so a sub-state of
871
+ // an inactive sub-state is cleared too.
872
+ for (const machine of options.machineDefinitions) {
873
+ if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
874
+ currentMachines.delete(machine.key)
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Scope despawn triggers by machine key, in scope definition order
880
+ * (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
881
+ */
882
+ const scopeTriggers = new Map<symbol, Array<{ readonly trigger: EntityScopeModule.Trigger; readonly scope: EntityScopeModule.EntityScope.Any }>>()
883
+ for (const scope of options.scopeDefinitions) {
884
+ for (const trigger of scope.despawnOn) {
885
+ const triggers = scopeTriggers.get(trigger.machine.key)
886
+ if (triggers === undefined) scopeTriggers.set(trigger.machine.key, [{ trigger, scope }])
887
+ else triggers.push({ trigger, scope })
888
+ }
806
889
  }
807
890
 
808
891
  const appendRelationFailure = (
@@ -842,6 +925,10 @@ const makeValidatedRuntime = <
842
925
  */
843
926
  const commandUses = new Map<string, Set<string>>()
844
927
  const ranSystems = new Set<string>()
928
+ /** With `debug`, the system running now: who queued a next state. */
929
+ let runningSystem = "(host)"
930
+ /** With `debug`, next states one system queued and another replaced before the marker. */
931
+ const nextStateReplacements = new Map<string, Debug.NextStateReplacement>()
845
932
  const noteCommand = (kind: string, name: string): void => {
846
933
  let uses = commandUses.get(commandOrigin)
847
934
  if (uses === undefined) {
@@ -859,13 +946,96 @@ const makeValidatedRuntime = <
859
946
  systems.add(commandOrigin)
860
947
  }
861
948
 
949
+ /**
950
+ * `Schema.requires`, checked when commands are applied. Types reject a
951
+ * breaking command when they can see it; this catches the rest: a query
952
+ * proof gone stale within one flush (another system's command applied in
953
+ * between), erased types, and raw ids. A breaking command is skipped
954
+ * whole, traced, and reported by the `requirement-violated` lint.
955
+ */
956
+ const requirements = requirementIndex(options.schema)
957
+ /** With `debug`, skipped commands by system, command, and requirement, for the lint. */
958
+ const requirementViolations = new Map<string, Debug.RequirementViolation>()
959
+ const violateRequirement = (
960
+ command: Debug.RequirementViolation["command"],
961
+ entity: number,
962
+ [component, missing]: readonly [string, string]
963
+ ): void => {
964
+ if (tracing) recordEffect({ kind: "requirementViolated", entity, command, component, missing })
965
+ if (debugEnabled) {
966
+ const key = `${commandOrigin}/${command}/${component}/${missing}`
967
+ const seen = requirementViolations.get(key)
968
+ requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 })
969
+ }
970
+ }
971
+ const streamLosses = new Map<string, Debug.StreamLoss>()
972
+ const watchStreamLoss = <V extends { all(): unknown; lagged(): boolean }>(view: V, system: string, stream: string, paused: boolean): V => {
973
+ const key = `${system}/${stream}`
974
+ const entry = (): { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] } => {
975
+ let found = streamLosses.get(key)
976
+ if (found === undefined) {
977
+ found = { system, stream, lostRuns: 0, checksLagged: false, paused }
978
+ streamLosses.set(key, found)
979
+ }
980
+ return found as { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] }
981
+ }
982
+ return {
983
+ ...view,
984
+ all: () => {
985
+ const status = entry()
986
+ if (view.lagged()) status.lostRuns += 1
987
+ return view.all()
988
+ },
989
+ lagged: () => {
990
+ entry().checksLagged = true
991
+ return view.lagged()
992
+ }
993
+ }
994
+ }
995
+ const staleTargets = new Map<string, Debug.StaleTarget>()
996
+ const reportStaleTarget = (command: Debug.StaleTarget["command"], entity: number): void => {
997
+ if (tracing) recordEffect({ kind: "staleTarget", entity, command })
998
+ if (debugEnabled) {
999
+ const key = `${commandOrigin}/${command}`
1000
+ const seen = staleTargets.get(key)
1001
+ staleTargets.set(key, { system: commandOrigin, command, entity, count: (seen?.count ?? 0) + 1 })
1002
+ }
1003
+ }
1004
+ const holds = (id: number) => {
1005
+ const record = world.records.get(id)
1006
+ return (descriptor: Descriptor<"component", string, any>): boolean =>
1007
+ record !== undefined && world.has(record, world.ordinalOf(descriptor))
1008
+ }
1009
+ /** The first requirement `components` break, as `[requirer, missing]`. */
1010
+ const missingIn = (
1011
+ components: ReadonlyArray<Entity.StagedComponent>,
1012
+ alsoHeld: (descriptor: Descriptor<"component", string, any>) => boolean
1013
+ ): readonly [string, string] | undefined => {
1014
+ for (const [descriptor] of components) {
1015
+ for (const required of requirements.requires.get(descriptor.name) ?? []) {
1016
+ if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
1017
+ return [descriptor.name, required.name]
1018
+ }
1019
+ }
1020
+ }
1021
+ return undefined
1022
+ }
1023
+
862
1024
  /**
863
1025
  * World adapter used by deferred commands.
864
1026
  */
865
1027
  const commandWorld: Command.InternalWorld<S> = {
866
1028
  spawnEntity(id, components) {
1029
+ if (!requirements.empty) {
1030
+ const missing = missingIn(components, () => false)
1031
+ if (missing !== undefined) {
1032
+ violateRequirement("spawn", id, missing)
1033
+ return false
1034
+ }
1035
+ }
867
1036
  if (debugEnabled) for (const [descriptor] of components) noteCommand("spawns", descriptor.name)
868
1037
  world.spawnEntity(id, components)
1038
+ return true
869
1039
  },
870
1040
  destroyEntity(id) {
871
1041
  if (debugEnabled) {
@@ -883,13 +1053,38 @@ const makeValidatedRuntime = <
883
1053
  if (debugEnabled) noteCommand("despawnsScope", scope.name)
884
1054
  world.destroyEntityScope(scope.key)
885
1055
  },
886
- removeComponent(id, descriptor) {
887
- if (debugEnabled) noteCommand("removes", descriptor.name)
888
- world.removeComponent(id, descriptor)
1056
+ removeComponents(id, descriptors) {
1057
+ if (!requirements.empty && world.records.has(id)) {
1058
+ const held = holds(id)
1059
+ for (const descriptor of descriptors) {
1060
+ for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
1061
+ if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
1062
+ violateRequirement("remove", id, [requirer.name, descriptor.name])
1063
+ return
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ for (const descriptor of descriptors) {
1069
+ if (debugEnabled) noteCommand("removes", descriptor.name)
1070
+ world.removeComponent(id, descriptor)
1071
+ }
1072
+ },
1073
+ insertComponents(id, entries) {
1074
+ if (!requirements.empty && world.records.has(id)) {
1075
+ const missing = missingIn(entries, holds(id))
1076
+ if (missing !== undefined) {
1077
+ violateRequirement("insert", id, missing)
1078
+ return
1079
+ }
1080
+ }
1081
+ for (const [descriptor, value] of entries) {
1082
+ if (debugEnabled) noteCommand("inserts", descriptor.name)
1083
+ world.writeComponent(id, descriptor, value)
1084
+ }
889
1085
  },
890
- writeComponent(id, descriptor, value) {
891
- if (debugEnabled) noteCommand("inserts", descriptor.name)
892
- world.writeComponent(id, descriptor, value)
1086
+ reportStaleTarget(command, id) {
1087
+ reportStaleTarget(command, id)
893
1088
  },
894
1089
  tryRelate(id, relation, target) {
895
1090
  if (debugEnabled) {
@@ -1024,7 +1219,7 @@ const makeValidatedRuntime = <
1024
1219
  return lookup
1025
1220
  }
1026
1221
 
1027
- const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryHandle<S, Q> => {
1222
+ const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryIteration<S, Q> & QueryLookups<S, Q> => {
1028
1223
  const each = () => queries.each(query, reader.since) as ReadonlyArray<QueryMatch<S, Q>>
1029
1224
  const matching = (ids: ReadonlyArray<Entity.EntityId<S, Root>>): Array<QueryMatch<S, Q>> => {
1030
1225
  const matches: Array<QueryMatch<S, Q>> = []
@@ -1082,6 +1277,32 @@ const makeValidatedRuntime = <
1082
1277
  const machineOriginals = new Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>()
1083
1278
  const emittedEvents = new Map<symbol, Array<unknown>>()
1084
1279
 
1280
+ /**
1281
+ * The open phase, or `undefined`. A transition's exit phase (the exit
1282
+ * schedules of the sub-states it ends and of the state it leaves, then its
1283
+ * `onTransition` schedules) commits or rolls back as one: when one of its
1284
+ * systems fails, the writes, events, queued next states, queued commands,
1285
+ * and stream positions of the systems that ran before it are undone too, so
1286
+ * the retry at a later marker starts from the same state.
1287
+ */
1288
+ interface Phase {
1289
+ readonly tick: number
1290
+ readonly commands: number
1291
+ readonly origins: number
1292
+ readonly resources: Map<symbol, unknown>
1293
+ readonly resourceChangedAt: Map<symbol, number | undefined>
1294
+ readonly machines: Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>
1295
+ readonly readers: Map<ReaderState, readonly [since: number, lastRun: number, streamSince: number, streamLastRun: number]>
1296
+ }
1297
+ let phase: Phase | undefined
1298
+
1299
+ /** Keeps a reader's position from before the phase, the first time the phase moves it. */
1300
+ const journalReader = (reader: ReaderState): void => {
1301
+ if (phase !== undefined && !phase.readers.has(reader)) {
1302
+ phase.readers.set(reader, [reader.since, reader.lastRun, reader.streamSince, reader.streamLastRun])
1303
+ }
1304
+ }
1305
+
1085
1306
  const journalStoreWrite = (store: Map<symbol, unknown>, originals: Map<symbol, unknown>) =>
1086
1307
  (key: symbol, value: unknown): void => {
1087
1308
  if (!originals.has(key)) {
@@ -1091,6 +1312,15 @@ const makeValidatedRuntime = <
1091
1312
  }
1092
1313
  const writeResource = journalStoreWrite(resources, resourceOriginals)
1093
1314
 
1315
+ /**
1316
+ * Resource change ticks for `changedResource` readers. Only resources some
1317
+ * system reads with `changedResource` are compared at commit; a reader's
1318
+ * first run reports a change anyway, so writes before it registers are not
1319
+ * needed.
1320
+ */
1321
+ const watchedResources = new Set<symbol>()
1322
+ const resourceChangedAt = new Map<symbol, number>()
1323
+
1094
1324
  const journalMachine = (key: symbol): void => {
1095
1325
  if (!machineOriginals.has(key)) {
1096
1326
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue)
@@ -1103,6 +1333,23 @@ const makeValidatedRuntime = <
1103
1333
 
1104
1334
  const commitSystemTransaction = (): void => {
1105
1335
  world.commitTransaction()
1336
+ if (phase !== undefined) {
1337
+ for (const [key, before] of resourceOriginals) {
1338
+ if (!phase.resources.has(key)) phase.resources.set(key, before)
1339
+ }
1340
+ for (const [key, before] of machineOriginals) {
1341
+ if (!phase.machines.has(key)) phase.machines.set(key, before)
1342
+ }
1343
+ }
1344
+ if (watchedResources.size > 0) {
1345
+ for (const [key, before] of resourceOriginals) {
1346
+ if (!watchedResources.has(key)) continue
1347
+ if (before === absentValue || !sameValue(before, resources.get(key))) {
1348
+ if (phase !== undefined && !phase.resourceChangedAt.has(key)) phase.resourceChangedAt.set(key, resourceChangedAt.get(key))
1349
+ resourceChangedAt.set(key, world.currentTick())
1350
+ }
1351
+ }
1352
+ }
1106
1353
  if (emittedEvents.size > 0) {
1107
1354
  const published = world.advanceTick()
1108
1355
  for (const [key, values] of emittedEvents) {
@@ -1139,12 +1386,71 @@ const makeValidatedRuntime = <
1139
1386
  emittedEvents.clear()
1140
1387
  }
1141
1388
 
1389
+ /**
1390
+ * Runs `run` as one phase: on failure, or a thrown defect, everything the
1391
+ * systems in it committed is undone.
1392
+ */
1393
+ const runAsPhase = (run: () => Result.Result<void, SystemFailure>): Result.Result<void, SystemFailure> => {
1394
+ const opened: Phase = {
1395
+ tick: world.currentTick(),
1396
+ commands: pendingCommands.length,
1397
+ origins: pendingOrigins.length,
1398
+ resources: new Map(),
1399
+ resourceChangedAt: new Map(),
1400
+ machines: new Map(),
1401
+ readers: new Map()
1402
+ }
1403
+ phase = opened
1404
+ world.beginPhase()
1405
+ let result: Result.Result<void, SystemFailure> | undefined
1406
+ try {
1407
+ result = run()
1408
+ } finally {
1409
+ phase = undefined
1410
+ if (result?.ok === true) {
1411
+ world.commitPhase()
1412
+ } else {
1413
+ world.rollbackPhase()
1414
+ restoreStore(resources, opened.resources)
1415
+ for (const [key, value] of opened.machines) {
1416
+ if (value === absentValue) pendingMachines.delete(key)
1417
+ else pendingMachines.set(key, value)
1418
+ }
1419
+ for (const [key, tick] of opened.resourceChangedAt) {
1420
+ if (tick === undefined) resourceChangedAt.delete(key)
1421
+ else resourceChangedAt.set(key, tick)
1422
+ }
1423
+ for (const [reader, [since, lastRun, streamSince, streamLastRun]] of opened.readers) {
1424
+ reader.since = since
1425
+ reader.lastRun = lastRun
1426
+ reader.streamSince = streamSince
1427
+ reader.streamLastRun = streamLastRun
1428
+ }
1429
+ events.retract(opened.tick)
1430
+ pendingCommands.length = opened.commands
1431
+ pendingOrigins.length = opened.origins
1432
+ }
1433
+ }
1434
+ return result
1435
+ }
1436
+
1142
1437
  const makeResourceWriteView = (
1143
1438
  descriptor: Descriptor<"resource", string, any>,
1144
1439
  store: Map<symbol, unknown>,
1145
1440
  write: (key: symbol, value: unknown) => void
1146
1441
  ) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor))
1147
1442
 
1443
+ const makeResourceChangedView = (descriptor: Descriptor<"resource", string, any>, reader: ReaderState): ResourceChangedView<unknown> => {
1444
+ const key = descriptor.key
1445
+ watchedResources.add(key)
1446
+ const read = Cells.storeRead(resources, key)
1447
+ return {
1448
+ get: () => read.get(),
1449
+ // `since` is the previous completed run; 0 before the first one.
1450
+ changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
1451
+ }
1452
+ }
1453
+
1148
1454
  const makeEventReadView = <T>(descriptorKey: symbol, reader: ReaderState): EventReadView<T> => ({
1149
1455
  all() {
1150
1456
  return events.since(descriptorKey, reader.streamSince) as ReadonlyArray<ReadonlyValue<T>>
@@ -1211,19 +1517,49 @@ const makeValidatedRuntime = <
1211
1517
  })
1212
1518
 
1213
1519
  const makeMachineReadView = <M extends Machine.StateMachine.Any>(stateMachine: M): MachineReadView<M> => ({
1214
- get: () => currentMachines.get(stateMachine.key) as Machine.StateMachine.Value<M>,
1520
+ get: () => currentMachines.get(stateMachine.key) as Machine.CurrentValue<M>,
1215
1521
  is: (value) => currentMachines.get(stateMachine.key) === value
1216
1522
  })
1217
1523
 
1524
+ /** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
1525
+ const queueMachine = <M extends Machine.StateMachine.Any>(stateMachine: M, value: unknown, skipIfSame: boolean): Machine.SetResult<M> => {
1526
+ const within = stateMachine.within
1527
+ if (within !== undefined && !currentMachines.has(stateMachine.key)) {
1528
+ return Result.failure({
1529
+ _tag: "SubStateInactive",
1530
+ machine: stateMachine.name,
1531
+ parent: within.machine.name,
1532
+ requires: within.state
1533
+ } satisfies Machine.SubStateInactive) as Machine.SetResult<M>
1534
+ }
1535
+ journalMachine(stateMachine.key)
1536
+ if (debugEnabled) {
1537
+ const previous = pendingMachines.get(stateMachine.key)
1538
+ // A set from a transition schedule waits for the next marker: it replaces nothing queued for this one.
1539
+ if (previous?.by !== undefined && previous.by !== runningSystem && previous.value !== value && activeTransitions.size === 0) {
1540
+ const key = `${stateMachine.name}/${previous.by}/${runningSystem}/${String(previous.value)}/${String(value)}`
1541
+ const seen = nextStateReplacements.get(key)
1542
+ nextStateReplacements.set(key, {
1543
+ machine: stateMachine.name,
1544
+ replaced: { system: previous.by, value: previous.value as Machine.StateValue },
1545
+ by: { system: runningSystem, value: value as Machine.StateValue },
1546
+ count: (seen?.count ?? 0) + 1
1547
+ })
1548
+ }
1549
+ pendingMachines.set(stateMachine.key, { value, skipIfSame, by: runningSystem })
1550
+ } else {
1551
+ pendingMachines.set(stateMachine.key, { value, skipIfSame })
1552
+ }
1553
+ return (within === undefined ? undefined : succeeded) as Machine.SetResult<M>
1554
+ }
1555
+
1218
1556
  const makeNextMachineWriteView = <M extends Machine.StateMachine.Any>(stateMachine: M): NextMachineWriteView<M> => ({
1219
1557
  getPending: () => pendingMachines.get(stateMachine.key)?.value as Machine.StateMachine.Value<M> | undefined,
1220
1558
  set(value) {
1221
- journalMachine(stateMachine.key)
1222
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false })
1559
+ return queueMachine(stateMachine, value, false)
1223
1560
  },
1224
1561
  setIfChanged(value) {
1225
- journalMachine(stateMachine.key)
1226
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true })
1562
+ return queueMachine(stateMachine, value, true)
1227
1563
  },
1228
1564
  reset() {
1229
1565
  journalMachine(stateMachine.key)
@@ -1275,25 +1611,32 @@ const makeValidatedRuntime = <
1275
1611
  const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState, queue: Command.CommandQueue<S, Root>): SystemContext<any> => {
1276
1612
  const spec = system.spec
1277
1613
  const lookup = makeLookup(reader)
1614
+ const paused = (spec.pauseWhen as ReadonlyArray<unknown>).length > 0
1615
+ /** On a debug runtime, records reads after a loss and calls of `lagged()`, for the `stream-loss-unread` lint. */
1616
+ const watch = <V extends { all(): unknown; lagged(): boolean }>(view: V, stream: string): V =>
1617
+ debugEnabled ? watchStreamLoss(view, system.name, stream, paused) : view
1278
1618
  return {
1279
1619
  local: (spec.local as (() => unknown) | undefined)?.(),
1280
- queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup)),
1620
+ // A filtered query hands out the same object; its type hides the lookups.
1621
+ queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup) as never),
1281
1622
  lookup,
1282
1623
  resources: mapRecord(spec.resources as Record<string, any>, (access) =>
1283
1624
  access.mode === "read"
1284
1625
  ? Cells.storeRead(resources, access.descriptor.key)
1285
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
1626
+ : access.mode === "changed"
1627
+ ? makeResourceChangedView(access.descriptor, reader)
1628
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
1286
1629
  events: mapRecord(spec.events as Record<string, any>, (access) =>
1287
1630
  access.mode === "read"
1288
- ? makeEventReadView(access.descriptor.key, reader)
1631
+ ? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
1289
1632
  : makeEventWriteView(access.descriptor.key)),
1290
1633
  machines: mapRecord(spec.machines as Record<string, any>, (access) => makeMachineReadView(access.machine)),
1291
1634
  nextMachines: mapRecord(spec.nextMachines as Record<string, any>, (access) => makeNextMachineWriteView(access.machine)),
1292
- transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => makeTransitionEventReadView(access.machine, reader)),
1635
+ transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => watch(makeTransitionEventReadView(access.machine, reader), `transition events ${access.machine.name}`)),
1293
1636
  transitions: mapRecord(spec.transitions as Record<string, any>, (access) => makeTransitionReadView(access.machine)),
1294
- removed: mapRecord(spec.removed as Record<string, any>, (access) => makeRemovedReadView(access.descriptor, reader)),
1295
- despawned: mapRecord(spec.despawned as Record<string, any>, () => makeDespawnedReadView(reader)),
1296
- relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => makeRelationFailureReadView(access.relation, reader)),
1637
+ removed: mapRecord(spec.removed as Record<string, any>, (access) => watch(makeRemovedReadView(access.descriptor, reader), `removed ${access.descriptor.name}`)),
1638
+ despawned: mapRecord(spec.despawned as Record<string, any>, () => watch(makeDespawnedReadView(reader), "despawned")),
1639
+ relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => watch(makeRelationFailureReadView(access.relation, reader), `relation failures ${access.relation.name}`)),
1297
1640
  services: mapRecord(spec.services as Record<string, any>, (access) =>
1298
1641
  providedServices[access.descriptor.name as keyof Services]),
1299
1642
  // The queue serves every declared permission set; the spec's types narrow it.
@@ -1486,6 +1829,7 @@ const makeValidatedRuntime = <
1486
1829
  // neither holds them nor receives a backlog when it runs again.
1487
1830
  const skipped = slotOf(system, true)
1488
1831
  if (tracing) traceSkipped(system, condition, skipped.reader, false)
1832
+ journalReader(skipped.reader)
1489
1833
  skipped.reader.streamLastRun = world.currentTick()
1490
1834
  return succeeded
1491
1835
  }
@@ -1501,7 +1845,11 @@ const makeValidatedRuntime = <
1501
1845
  }
1502
1846
  }
1503
1847
  const { context, commands, reader } = slotOf(system, true)
1504
- if (debugEnabled) ranSystems.add(system.name)
1848
+ if (debugEnabled) {
1849
+ ranSystems.add(system.name)
1850
+ runningSystem = system.name
1851
+ }
1852
+ journalReader(reader)
1505
1853
  // Changes stamped after the previous completed run are visible to this run.
1506
1854
  reader.since = reader.lastRun
1507
1855
  reader.streamSince = reader.streamLastRun
@@ -1626,6 +1974,36 @@ const makeValidatedRuntime = <
1626
1974
  }
1627
1975
  }
1628
1976
 
1977
+ /**
1978
+ * Despawns the scopes whose trigger matches a committed transition: leaving
1979
+ * `snapshot.from` or entering `snapshot.to`. It runs right after the
1980
+ * commit, so `onExit` systems still saw the members and entities spawned by
1981
+ * `onEnter` survive. Members are the entities in the scope at that moment:
1982
+ * commands queued by `onExit` systems and not yet applied are not.
1983
+ */
1984
+ const despawnTriggeredScopes = (machineKey: symbol, snapshot: Machine.TransitionSnapshot): void => {
1985
+ const triggers = scopeTriggers.get(machineKey)
1986
+ if (triggers === undefined) return
1987
+ const applied: Array<Debug.AppliedCommand> = []
1988
+ for (const { trigger, scope } of triggers) {
1989
+ const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to
1990
+ if (!matches) continue
1991
+ const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`
1992
+ const effects: Array<Debug.Effect> = []
1993
+ commandEffects = tracing ? effects : undefined
1994
+ commandOrigin = origin
1995
+ try {
1996
+ commandWorld.destroyEntityScope(scope)
1997
+ } finally {
1998
+ commandEffects = undefined
1999
+ }
2000
+ if (tracing) applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects })
2001
+ }
2002
+ if (applied.length > 0) {
2003
+ emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied })
2004
+ }
2005
+ }
2006
+
1629
2007
  /**
1630
2008
  * Applies queued commands, then queued machine transitions, running the
1631
2009
  * matching exit, transition, and enter schedules from the bundle.
@@ -1643,7 +2021,17 @@ const makeValidatedRuntime = <
1643
2021
 
1644
2022
  const schedules = bundle?.entries ?? []
1645
2023
  for (const [machineKey, pending] of scheduledTransitions) {
1646
- pendingMachines.delete(machineKey)
2024
+ if (discardedPending.has(pending)) {
2025
+ // Discarded earlier in this marker: its parent left the state it exists in.
2026
+ continue
2027
+ }
2028
+ // A transition schedule earlier in this marker may have queued a newer
2029
+ // value for this machine: that one waits for the next marker.
2030
+ if (pendingMachines.get(machineKey) === pending) pendingMachines.delete(machineKey)
2031
+ /** Keeps this request queued after a failure, unless a newer one replaced it. */
2032
+ const requeue = () => {
2033
+ if (!pendingMachines.has(machineKey)) pendingMachines.set(machineKey, pending)
2034
+ }
1647
2035
  const current = currentMachines.get(machineKey)
1648
2036
  if (current === undefined) {
1649
2037
  continue
@@ -1653,45 +2041,52 @@ const makeValidatedRuntime = <
1653
2041
  continue
1654
2042
  }
1655
2043
 
1656
- previousMachines.set(machineKey, current)
1657
2044
  const snapshot = {
1658
2045
  from: current as Machine.StateValue,
1659
2046
  to: pending.value as Machine.StateValue
1660
2047
  }
1661
2048
 
1662
- for (const schedule of schedules) {
1663
- const transition = schedule.transition
1664
- if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
1665
- const result = runTransitionSchedule(schedule, snapshot)
1666
- if (!result.ok) {
1667
- // The transition did not happen; keep it queued for a later attempt.
1668
- pendingMachines.set(machineKey, pending)
1669
- if (tracing) traceTransition(machineKey, snapshot, "failed")
1670
- return result
1671
- }
2049
+ // Sub-states that exist in the state being left exit first, deepest
2050
+ // first: their exit schedules run before the parent's, while they
2051
+ // still have their values, and they lose them at the parent's commit.
2052
+ // The exit phase is one transaction: a failure undoes all of it, and
2053
+ // the transition stays queued for a later attempt.
2054
+ const leaving = activeSubStatesIn(machineKey, snapshot.from)
2055
+ const exited = runAsPhase(() => {
2056
+ for (const sub of leaving) {
2057
+ const value = currentMachines.get(sub.key)
2058
+ const result = runPhase(sub.key, "exit", value, { from: value, to: undefined } as unknown as Machine.TransitionSnapshot, schedules)
2059
+ if (!result.ok) return result
1672
2060
  }
1673
- }
1674
- for (const schedule of schedules) {
1675
- const transition = schedule.transition
1676
- if (
1677
- transition.phase === "transition"
1678
- && transition.machine.key === machineKey
1679
- && transition.from === snapshot.from
1680
- && transition.to === snapshot.to
1681
- ) {
1682
- const result = runTransitionSchedule(schedule, snapshot)
1683
- if (!result.ok) {
1684
- pendingMachines.set(machineKey, pending)
1685
- if (tracing) traceTransition(machineKey, snapshot, "failed")
1686
- return result
2061
+ const left = runPhase(machineKey, "exit", snapshot.from, snapshot, schedules)
2062
+ if (!left.ok) return left
2063
+ for (const schedule of schedules) {
2064
+ const transition = schedule.transition
2065
+ if (
2066
+ transition.phase === "transition"
2067
+ && transition.machine.key === machineKey
2068
+ && transition.from === snapshot.from
2069
+ && transition.to === snapshot.to
2070
+ ) {
2071
+ const result = runTransitionSchedule(schedule, snapshot)
2072
+ if (!result.ok) return result
1687
2073
  }
1688
2074
  }
2075
+ return succeeded
2076
+ })
2077
+ if (!exited.ok) {
2078
+ requeue()
2079
+ if (tracing) traceTransition(machineKey, snapshot, "failed")
2080
+ return exited
1689
2081
  }
1690
2082
 
1691
- currentMachines.set(machineKey, pending.value)
1692
- changedMachines.add(machineKey)
1693
- transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
1694
- if (tracing) traceTransition(machineKey, snapshot, "applied")
2083
+ for (const sub of leaving) commitMachine(sub.key, currentMachines.get(sub.key), undefined)
2084
+ commitMachine(machineKey, current, pending.value)
2085
+ // Sub-states that exist in the entered state enter their value together
2086
+ // with the parent, parents first, before any onEnter schedule runs: a
2087
+ // failing onEnter cannot leave them without a value, and their scope
2088
+ // triggers fire before what the onEnter schedules spawn.
2089
+ const entered = commitSubStates(machineKey, snapshot.to)
1695
2090
 
1696
2091
  for (const schedule of schedules) {
1697
2092
  const transition = schedule.transition
@@ -1703,6 +2098,89 @@ const makeValidatedRuntime = <
1703
2098
  }
1704
2099
  }
1705
2100
  }
2101
+ const enteredSchedules = runSubStateEnters(entered, schedules)
2102
+ if (!enteredSchedules.ok) return enteredSchedules
2103
+ }
2104
+ return succeeded
2105
+ }
2106
+
2107
+ /**
2108
+ * Commits one machine's value (`undefined` for a sub-state that stops
2109
+ * existing): records the change and its transition event, traces it, and
2110
+ * despawns the scopes it triggers.
2111
+ */
2112
+ const commitMachine = (machineKey: symbol, from: unknown, to: unknown): void => {
2113
+ const snapshot = { from, to } as unknown as Machine.TransitionSnapshot
2114
+ if (from !== undefined) previousMachines.set(machineKey, from)
2115
+ if (to === undefined) {
2116
+ currentMachines.delete(machineKey)
2117
+ const discarded = pendingMachines.get(machineKey)
2118
+ if (discarded !== undefined) discardedPending.add(discarded)
2119
+ pendingMachines.delete(machineKey)
2120
+ } else {
2121
+ currentMachines.set(machineKey, to)
2122
+ }
2123
+ changedMachines.add(machineKey)
2124
+ transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
2125
+ if (tracing) traceTransition(machineKey, snapshot, "applied")
2126
+ despawnTriggeredScopes(machineKey, snapshot)
2127
+ }
2128
+
2129
+ type TransitionSchedules = ReadonlyArray<Machine.StateMachine.AnyTransitionSchedule<S, Root>>
2130
+
2131
+ /** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
2132
+ const runPhase = (
2133
+ machineKey: symbol,
2134
+ phase: "enter" | "exit",
2135
+ state: unknown,
2136
+ snapshot: Machine.TransitionSnapshot,
2137
+ schedules: TransitionSchedules
2138
+ ): Result.Result<void, SystemFailure> => {
2139
+ for (const schedule of schedules) {
2140
+ const transition = schedule.transition
2141
+ if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
2142
+ const result = runTransitionSchedule(schedule, snapshot)
2143
+ if (!result.ok) return result
2144
+ }
2145
+ }
2146
+ return succeeded
2147
+ }
2148
+
2149
+ /** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
2150
+ const activeSubStatesIn = (parentKey: symbol, state: unknown): Array<Machine.StateMachine.Any> => {
2151
+ const result: Array<Machine.StateMachine.Any> = []
2152
+ for (const sub of subStates.get(parentKey) ?? []) {
2153
+ const value = currentMachines.get(sub.key)
2154
+ if (sub.within!.state !== state || value === undefined) continue
2155
+ result.push(...activeSubStatesIn(sub.key, value), sub)
2156
+ }
2157
+ return result
2158
+ }
2159
+
2160
+ /**
2161
+ * Gives the sub-states of `parentKey` that exist in `state` their entry
2162
+ * value, and theirs, parents first. Returns what it entered, in that order.
2163
+ */
2164
+ const commitSubStates = (parentKey: symbol, state: unknown): Array<readonly [symbol, unknown]> => {
2165
+ const entered: Array<readonly [symbol, unknown]> = []
2166
+ for (const sub of subStates.get(parentKey) ?? []) {
2167
+ if (sub.within!.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key)) continue
2168
+ const value = subStateEntry.get(sub.key)
2169
+ commitMachine(sub.key, undefined, value)
2170
+ entered.push([sub.key, value], ...commitSubStates(sub.key, value))
2171
+ }
2172
+ return entered
2173
+ }
2174
+
2175
+ /** Runs the onEnter schedules of sub-states entered by {@link commitSubStates}, in that order. */
2176
+ const runSubStateEnters = (entered: ReadonlyArray<readonly [symbol, unknown]>, schedules: TransitionSchedules): Result.Result<void, SystemFailure> => {
2177
+ for (const [key, value] of entered) {
2178
+ const snapshot = { from: undefined, to: value } as unknown as Machine.TransitionSnapshot
2179
+ const result = runPhase(key, "enter", value, snapshot, schedules)
2180
+ if (!result.ok) {
2181
+ if (tracing) traceTransition(key, snapshot, "enterFailed")
2182
+ return result
2183
+ }
1706
2184
  }
1707
2185
  return succeeded
1708
2186
  }
@@ -1739,8 +2217,14 @@ const makeValidatedRuntime = <
1739
2217
  return succeeded
1740
2218
  }
1741
2219
 
1742
- /** Whether a schedule is running now: `send` is not allowed meanwhile. */
2220
+ /** Whether `runtime.tick` is on the stack: a nested `tick` is a defect. */
1743
2221
  let ticking = false
2222
+ /**
2223
+ * Whether a tick is advancing its frame or running its schedules (in
2224
+ * `tick` and `tryTick`): `send`, `snapshot`, and `restore` are defects
2225
+ * meanwhile. It is cleared before `tick.end` is announced to host listeners.
2226
+ */
2227
+ let inTick = false
1744
2228
 
1745
2229
  /**
1746
2230
  * Runs one schedule. A failing system stops the schedule; systems that
@@ -1764,7 +2248,7 @@ const makeValidatedRuntime = <
1764
2248
  let hostEvents = new Set<string>()
1765
2249
 
1766
2250
  const send = (descriptor: Descriptor<"event", string, any>, value: unknown): void => {
1767
- if (ticking) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
2251
+ if (inTick) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
1768
2252
  if (!eventsByKey.has(descriptor.key)) throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`)
1769
2253
  const published = world.advanceTick()
1770
2254
  events.append(descriptor.key, published, [value], true)
@@ -1835,6 +2319,7 @@ const makeValidatedRuntime = <
1835
2319
  if (ticking) throw new Error("runtime.tick cannot run inside another tick")
1836
2320
  if (debugEnabled) reportUnread("next-tick")
1837
2321
  ticking = true
2322
+ inTick = true
1838
2323
  try {
1839
2324
  advanceFrame()
1840
2325
  return watchResult(observedTick(schedules, () => {
@@ -1846,6 +2331,7 @@ const makeValidatedRuntime = <
1846
2331
  }), schedules)
1847
2332
  } finally {
1848
2333
  ticking = false
2334
+ inTick = false
1849
2335
  }
1850
2336
  }
1851
2337
 
@@ -1860,9 +2346,11 @@ const makeValidatedRuntime = <
1860
2346
  try {
1861
2347
  result = run()
1862
2348
  } catch (defect) {
2349
+ inTick = false
1863
2350
  emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect })
1864
2351
  throw defect
1865
2352
  }
2353
+ inTick = false
1866
2354
  emitHost(result.ok
1867
2355
  ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
1868
2356
  : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error })
@@ -1876,7 +2364,8 @@ const makeValidatedRuntime = <
1876
2364
  case "resource":
1877
2365
  return resources.has(requirement.key)
1878
2366
  case "stateMachine":
1879
- return currentMachines.has(requirement.key)
2367
+ // An inactive sub-state has no value but is provided.
2368
+ return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key)
1880
2369
  }
1881
2370
  }
1882
2371
 
@@ -1900,8 +2389,14 @@ const makeValidatedRuntime = <
1900
2389
  return watchResult(Result.failure({ kind: "TickOrderViolation" as const, issues }), [schedule])
1901
2390
  }
1902
2391
 
1903
- advanceFrame()
1904
- return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
2392
+ if (inTick) throw new Error("runtime.tryTick cannot run inside another tick")
2393
+ inTick = true
2394
+ try {
2395
+ advanceFrame()
2396
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
2397
+ } finally {
2398
+ inTick = false
2399
+ }
1905
2400
  }
1906
2401
 
1907
2402
  /**
@@ -1938,6 +2433,7 @@ const makeValidatedRuntime = <
1938
2433
  for (const provision of machineEntries) machinesByName.set(provision.machine.name, provision.machine)
1939
2434
 
1940
2435
  const snapshot = (): Snapshot.WorldSnapshot => {
2436
+ if (inTick) 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")
1941
2437
  const savedResources: Record<string, unknown> = {}
1942
2438
  for (const [name, descriptor] of resourcesByName) {
1943
2439
  if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
@@ -1956,6 +2452,7 @@ const makeValidatedRuntime = <
1956
2452
  }
1957
2453
  return {
1958
2454
  version: Snapshot.version,
2455
+ gameVersion: options.saves?.latest ?? 0,
1959
2456
  nextEntity: world.nextEntityValue(),
1960
2457
  entities: world.exportEntities(DescriptorModule.isTransient),
1961
2458
  relations: world.exportRelations(),
@@ -1973,7 +2470,9 @@ const makeValidatedRuntime = <
1973
2470
  const restore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
1974
2471
  const parsed = Snapshot.parse(data)
1975
2472
  if (!parsed.ok) return parsed
1976
- const saved = parsed.value
2473
+ const migrated = migrateSave(parsed.value, options.saves)
2474
+ if (!migrated.ok) return migrated
2475
+ const saved = migrated.value
1977
2476
 
1978
2477
  // Validate everything before touching the world.
1979
2478
  const ids = new Set<number>()
@@ -1992,6 +2491,11 @@ const makeValidatedRuntime = <
1992
2491
  if (!value.ok) return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error })
1993
2492
  components.push([descriptor, value.value])
1994
2493
  }
2494
+ // Transient components are never saved, so a requirement on one is not checked here.
2495
+ const missing = missingIn(components, DescriptorModule.isTransient)
2496
+ if (missing !== undefined) {
2497
+ return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] })
2498
+ }
1995
2499
  entities.push({ id: entity.id, components })
1996
2500
  }
1997
2501
  const validatedResources: Array<readonly [symbol, unknown]> = []
@@ -2028,17 +2532,35 @@ const makeValidatedRuntime = <
2028
2532
  }
2029
2533
  }
2030
2534
  }
2031
- const machineValues: Array<readonly [symbol, Machine.StateValue]> = []
2535
+ const restoredMachines = new Map(currentMachines)
2032
2536
  for (const [name, value] of Object.entries(saved.machines)) {
2033
2537
  const machine = machinesByName.get(name)
2034
2538
  if (!machine) return Result.failure({ _tag: "UnknownMachine", name })
2035
2539
  if (!machine.values.includes(value)) return Result.failure({ _tag: "InvalidMachineState", name, value })
2036
- machineValues.push([machine.key, value])
2540
+ restoredMachines.set(machine.key, value)
2541
+ }
2542
+ // A save has no value for a sub-state whose parent is elsewhere; one that
2543
+ // has a value must have its parent in the state it exists in. A sub-state
2544
+ // missing while its parent is there enters its entry value.
2545
+ for (const machine of options.machineDefinitions) {
2546
+ const within = machine.within
2547
+ if (within === undefined) continue
2548
+ const active = restoredMachines.get(within.machine.key) === within.state
2549
+ if (Object.hasOwn(saved.machines, machine.name)) {
2550
+ if (!active) {
2551
+ return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state })
2552
+ }
2553
+ } else if (active && subStateEntry.has(machine.key)) {
2554
+ restoredMachines.set(machine.key, subStateEntry.get(machine.key))
2555
+ } else {
2556
+ restoredMachines.delete(machine.key)
2557
+ }
2037
2558
  }
2038
2559
 
2039
2560
  const scoped = new Set<number>()
2040
2561
  const memberships: Array<readonly [number, symbol]> = []
2041
2562
  for (const [name, members] of Object.entries(saved.scopes)) {
2563
+ if (!options.scopeDefinitions.some((scope) => scope.name === name)) return Result.failure({ _tag: "UnknownScope", name })
2042
2564
  for (const id of members) {
2043
2565
  if (!ids.has(id) || scoped.has(id)) return Result.failure({ _tag: "InvalidScope", name, entityId: id })
2044
2566
  scoped.add(id)
@@ -2066,14 +2588,19 @@ const makeValidatedRuntime = <
2066
2588
  world.tryRelate(source, relation, target)
2067
2589
  }
2068
2590
  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)
2591
+ for (const [key, value] of validatedResources) {
2592
+ resources.set(key, value)
2593
+ resourceChangedAt.set(key, world.currentTick())
2594
+ }
2595
+ currentMachines.clear()
2596
+ for (const [key, value] of restoredMachines) currentMachines.set(key, value)
2071
2597
  // Locals are not saved: each system starts over from its factory.
2072
2598
  localSlots.forEach(({ slot }, index) => { (slot.context as { local: unknown }).local = rebuiltLocals[index] })
2073
2599
  return succeeded
2074
2600
  }
2075
2601
 
2076
2602
  const tracedRestore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
2603
+ if (inTick) 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")
2077
2604
  const result = restore(data)
2078
2605
  if (tracing) emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok })
2079
2606
  return result
@@ -2217,11 +2744,17 @@ const makeValidatedRuntime = <
2217
2744
  schema: options.schema,
2218
2745
  machines: [...new Set(machinesByName.values())],
2219
2746
  currentMachine: (machine) => currentMachines.get(machine.key) as Machine.StateValue | undefined,
2747
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
2748
+ scopes: options.scopeDefinitions,
2220
2749
  providedServices: Object.keys(providedServices),
2221
2750
  externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
2222
2751
  relationMutations,
2223
2752
  commandUses,
2224
2753
  ranSystems,
2754
+ requirementViolations: [...requirementViolations.values()],
2755
+ nextStateReplacements: [...nextStateReplacements.values()],
2756
+ staleTargets: [...staleTargets.values()],
2757
+ streamLosses: [...streamLosses.values()],
2225
2758
  hasResource: (descriptor) => resources.has(descriptor.key),
2226
2759
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
2227
2760
  }),
@@ -2262,8 +2795,9 @@ const makeValidatedRuntime = <
2262
2795
  * Creates a runtime for a fully built schema and a set of external services.
2263
2796
  *
2264
2797
  * Resources owned by constructed descriptors take raw input and are validated
2265
- * here; when any are provided the runtime comes back as a `Result`. Otherwise
2266
- * the runtime is returned directly.
2798
+ * here. The runtime always comes back as a `Result`: typed `Success` when
2799
+ * nothing provided can fail (read `.value`), and a `Result` to check when a
2800
+ * constructed resource is provided.
2267
2801
  *
2268
2802
  * The runtime does not own the outer frame loop. It only owns ECS state plus
2269
2803
  * the host-provided services that systems are allowed to depend on.
@@ -2276,7 +2810,7 @@ const makeValidatedRuntime = <
2276
2810
  * ),
2277
2811
  * resources: { Score: 0 },
2278
2812
  * machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
2279
- * })
2813
+ * }).value
2280
2814
  * ```
2281
2815
  */
2282
2816
  export const make = <
@@ -2289,17 +2823,16 @@ export const make = <
2289
2823
  >(options: {
2290
2824
  readonly schema: S
2291
2825
  readonly services: ProvidedServices
2292
- readonly resources?: Resources
2826
+ readonly resources?: Resources & ExactResources<S, Resources>
2293
2827
  readonly machines?: ProvidedMachines
2294
2828
  readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
2829
+ /** Scopes whose members a committed state change despawns; `Game.Runtime.make` passes every `Game.EntityScope`. */
2830
+ readonly scopeDefinitions?: ReadonlyArray<EntityScopeModule.EntityScope.Any>
2295
2831
  /** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
2296
2832
  readonly debug?: Enabled
2833
+ /** The game's save migrations (`Snapshot.migrations`), applied by `restore`. */
2834
+ readonly saves?: Snapshot.Migrations
2297
2835
  }): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
2298
- const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) =>
2299
- options.resources !== undefined
2300
- && (options.resources as Record<string, unknown>)[key] !== undefined
2301
- && DescriptorModule.hasConstructor(descriptor)
2302
- )
2303
2836
  const validated = collectConstructedRegistryValues(options.schema.resources, options.resources)
2304
2837
  if (!validated.ok) {
2305
2838
  return Result.failure({ resources: validated.error }) as never
@@ -2310,9 +2843,44 @@ export const make = <
2310
2843
  resources: validated.value,
2311
2844
  machines: options.machines,
2312
2845
  machineDefinitions: options.machineDefinitions ?? [],
2313
- debug: options.debug
2846
+ scopeDefinitions: options.scopeDefinitions ?? [],
2847
+ debug: options.debug,
2848
+ saves: options.saves
2314
2849
  })
2315
- return (fallible ? Result.success(runtime) : runtime) as never
2850
+ return Result.success(runtime) as never
2851
+ }
2852
+
2853
+ /** Names listed more than once in `Names` (a tuple), in order of their second listing. */
2854
+ type RepeatedNames<Names extends ReadonlyArray<string>, Seen extends string = never> =
2855
+ Names extends readonly [infer Head extends string, ...infer Tail extends ReadonlyArray<string>]
2856
+ ? (string extends Head ? never : Head extends Seen ? Head : never) | RepeatedNames<Tail, Seen | Head>
2857
+ : never
2858
+
2859
+ type ProvidedServiceNames<Entries extends ReadonlyArray<ServiceProvision>> = {
2860
+ readonly [K in keyof Entries]: Entries[K] extends ServiceProvision<infer D> ? D["name"] : string
2861
+ }
2862
+ type ProvidedMachineNames<Entries extends ReadonlyArray<MachineProvision>> = {
2863
+ readonly [K in keyof Entries]: Entries[K] extends MachineProvision<infer M> ? M["name"] : string
2864
+ }
2865
+
2866
+ /** Rejects a service provided twice: only the last would be used. */
2867
+ type UniqueServices<Entries extends ReadonlyArray<ServiceProvision>> =
2868
+ [RepeatedNames<ProvidedServiceNames<Entries>>] extends [never] ? unknown : {
2869
+ readonly __fixServices__: `${RepeatedNames<ProvidedServiceNames<Entries>>} is provided twice: keep one Game.Runtime.service(...) for it`
2870
+ }
2871
+
2872
+ /** Rejects a machine started twice: only the last initial value would be used. */
2873
+ type UniqueMachines<Entries extends ReadonlyArray<MachineProvision>> =
2874
+ [RepeatedNames<ProvidedMachineNames<Entries>>] extends [never] ? unknown : {
2875
+ readonly __fixMachines__: `${RepeatedNames<ProvidedMachineNames<Entries>>} is provided twice: keep one Game.Runtime.machine(...) for it`
2876
+ }
2877
+
2878
+ const rejectRepeated = (names: ReadonlyArray<string>, kind: "service" | "machine"): void => {
2879
+ const seen = new Set<string>()
2880
+ for (const name of names) {
2881
+ if (seen.has(name)) throw new Error(`${name} is provided twice: keep one Game.Runtime.${kind}(...) for it`)
2882
+ seen.add(name)
2883
+ }
2316
2884
  }
2317
2885
 
2318
2886
  /**
@@ -2324,6 +2892,8 @@ export const make = <
2324
2892
  *
2325
2893
  * The helper keeps the runtime map keyed by service descriptors instead of
2326
2894
  * ad hoc strings, so the provision site cannot drift from the declaration site.
2895
+ * Each service is provided once: a second provision of the same one does not
2896
+ * compile.
2327
2897
  *
2328
2898
  * @example
2329
2899
  * ```ts
@@ -2336,7 +2906,8 @@ export const make = <
2336
2906
  */
2337
2907
  export const services = <
2338
2908
  const Entries extends ReadonlyArray<ServiceProvision>
2339
- >(...entries: Entries): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
2909
+ >(...entries: Entries & UniqueServices<Entries>): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
2910
+ rejectRepeated(entries.map((entry) => entry.descriptor.name), "service")
2340
2911
  const provided: Record<string, unknown> = {}
2341
2912
  for (const { descriptor, implementation } of entries) {
2342
2913
  provided[descriptor.name] = implementation
@@ -2366,11 +2937,14 @@ export const service = <
2366
2937
  * Builds the machine initialization environment from machine definitions.
2367
2938
  *
2368
2939
  * Use this when gameplay phases need a known committed starting state before
2369
- * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`.
2940
+ * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`. Each
2941
+ * machine is provided once: a second provision of the same one does not
2942
+ * compile.
2370
2943
  */
2371
2944
  export const machines = <
2372
2945
  const Entries extends ReadonlyArray<MachineProvision>
2373
- >(...entries: Entries): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
2946
+ >(...entries: Entries & UniqueMachines<Entries>): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
2947
+ rejectRepeated(entries.map((entry) => entry.machine.name), "machine")
2374
2948
  const provided: Record<string, unknown> = {}
2375
2949
  for (const { machine, initial } of entries) {
2376
2950
  provided[machine.name] = initial