@typeonce/ritmo 0.6.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 (148) hide show
  1. package/README.md +2 -0
  2. package/dist/Brand.d.ts +9 -4
  3. package/dist/Brand.d.ts.map +1 -1
  4. package/dist/Brand.js +9 -4
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Clock.d.ts +186 -0
  7. package/dist/Clock.d.ts.map +1 -0
  8. package/dist/Clock.js +111 -0
  9. package/dist/Clock.js.map +1 -0
  10. package/dist/Command.d.ts +250 -41
  11. package/dist/Command.d.ts.map +1 -1
  12. package/dist/Command.js +59 -23
  13. package/dist/Command.js.map +1 -1
  14. package/dist/Condition.d.ts +12 -1
  15. package/dist/Condition.d.ts.map +1 -1
  16. package/dist/Condition.js.map +1 -1
  17. package/dist/Debug.d.ts +163 -15
  18. package/dist/Debug.d.ts.map +1 -1
  19. package/dist/Debug.js.map +1 -1
  20. package/dist/Decode.d.ts +1 -1
  21. package/dist/Decode.js +1 -1
  22. package/dist/Descriptor.d.ts +67 -24
  23. package/dist/Descriptor.d.ts.map +1 -1
  24. package/dist/Descriptor.js +32 -15
  25. package/dist/Descriptor.js.map +1 -1
  26. package/dist/Entity.d.ts +22 -5
  27. package/dist/Entity.d.ts.map +1 -1
  28. package/dist/Entity.js +2 -2
  29. package/dist/Entity.js.map +1 -1
  30. package/dist/EntityScope.d.ts +26 -1
  31. package/dist/EntityScope.d.ts.map +1 -1
  32. package/dist/EntityScope.js +14 -2
  33. package/dist/EntityScope.js.map +1 -1
  34. package/dist/HostClock.d.ts +49 -0
  35. package/dist/HostClock.d.ts.map +1 -0
  36. package/dist/HostClock.js +46 -0
  37. package/dist/HostClock.js.map +1 -0
  38. package/dist/Inspector.d.ts +7 -1
  39. package/dist/Inspector.d.ts.map +1 -1
  40. package/dist/Inspector.js.map +1 -1
  41. package/dist/Machine.d.ts +67 -36
  42. package/dist/Machine.d.ts.map +1 -1
  43. package/dist/Machine.js +10 -3
  44. package/dist/Machine.js.map +1 -1
  45. package/dist/Query.d.ts +74 -26
  46. package/dist/Query.d.ts.map +1 -1
  47. package/dist/Query.js +32 -28
  48. package/dist/Query.js.map +1 -1
  49. package/dist/Runtime.d.ts +39 -16
  50. package/dist/Runtime.d.ts.map +1 -1
  51. package/dist/Runtime.js +556 -87
  52. package/dist/Runtime.js.map +1 -1
  53. package/dist/Schedule.d.ts +217 -10
  54. package/dist/Schedule.d.ts.map +1 -1
  55. package/dist/Schedule.js +5 -0
  56. package/dist/Schedule.js.map +1 -1
  57. package/dist/Schema.d.ts +196 -44
  58. package/dist/Schema.d.ts.map +1 -1
  59. package/dist/Schema.js +29 -11
  60. package/dist/Schema.js.map +1 -1
  61. package/dist/Snapshot.d.ts +14 -0
  62. package/dist/Snapshot.d.ts.map +1 -1
  63. package/dist/Snapshot.js.map +1 -1
  64. package/dist/System.d.ts +212 -58
  65. package/dist/System.d.ts.map +1 -1
  66. package/dist/System.js +84 -6
  67. package/dist/System.js.map +1 -1
  68. package/dist/index.d.ts +8 -7
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +8 -7
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/cells.d.ts +3 -3
  73. package/dist/internal/cells.d.ts.map +1 -1
  74. package/dist/internal/cells.js +9 -11
  75. package/dist/internal/cells.js.map +1 -1
  76. package/dist/internal/debug.d.ts +13 -0
  77. package/dist/internal/debug.d.ts.map +1 -1
  78. package/dist/internal/debug.js +192 -7
  79. package/dist/internal/debug.js.map +1 -1
  80. package/dist/internal/equal.d.ts +8 -0
  81. package/dist/internal/equal.d.ts.map +1 -0
  82. package/dist/internal/equal.js +38 -0
  83. package/dist/internal/equal.js.map +1 -0
  84. package/dist/internal/fragments.d.ts.map +1 -1
  85. package/dist/internal/fragments.js +11 -1
  86. package/dist/internal/fragments.js.map +1 -1
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +41 -6
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts +10 -0
  91. package/dist/internal/order.d.ts.map +1 -0
  92. package/dist/internal/order.js +122 -0
  93. package/dist/internal/order.js.map +1 -0
  94. package/dist/internal/queries.d.ts.map +1 -1
  95. package/dist/internal/queries.js +32 -10
  96. package/dist/internal/queries.js.map +1 -1
  97. package/dist/internal/requirements.d.ts +26 -0
  98. package/dist/internal/requirements.d.ts.map +1 -0
  99. package/dist/internal/requirements.js +46 -0
  100. package/dist/internal/requirements.js.map +1 -0
  101. package/package.json +1 -1
  102. package/src/Brand.ts +9 -4
  103. package/src/Clock.ts +311 -0
  104. package/src/Command.ts +407 -89
  105. package/src/Condition.ts +17 -1
  106. package/src/Debug.ts +172 -14
  107. package/src/Decode.ts +1 -1
  108. package/src/Descriptor.ts +92 -37
  109. package/src/Entity.ts +24 -5
  110. package/src/EntityScope.ts +27 -2
  111. package/src/HostClock.ts +64 -0
  112. package/src/Inspector.ts +7 -2
  113. package/src/Machine.ts +81 -37
  114. package/src/Query.ts +110 -38
  115. package/src/Runtime.ts +607 -111
  116. package/src/Schedule.ts +380 -13
  117. package/src/Schema.ts +249 -63
  118. package/src/Snapshot.ts +4 -0
  119. package/src/System.ts +248 -93
  120. package/src/index.ts +8 -7
  121. package/src/internal/cells.ts +10 -12
  122. package/src/internal/debug.ts +208 -12
  123. package/src/internal/equal.ts +31 -0
  124. package/src/internal/fragments.ts +12 -2
  125. package/src/internal/game.ts +61 -7
  126. package/src/internal/order.ts +144 -0
  127. package/src/internal/queries.ts +37 -13
  128. package/src/internal/requirements.ts +71 -0
  129. package/dist/Definition.d.ts +0 -99
  130. package/dist/Definition.d.ts.map +0 -1
  131. package/dist/Definition.js +0 -68
  132. package/dist/Definition.js.map +0 -1
  133. package/dist/Feature.d.ts +0 -106
  134. package/dist/Feature.d.ts.map +0 -1
  135. package/dist/Feature.js +0 -87
  136. package/dist/Feature.js.map +0 -1
  137. package/dist/Fx.d.ts +0 -128
  138. package/dist/Fx.d.ts.map +0 -1
  139. package/dist/Fx.js +0 -112
  140. package/dist/Fx.js.map +0 -1
  141. package/dist/internal/definition.d.ts +0 -17
  142. package/dist/internal/definition.d.ts.map +0 -1
  143. package/dist/internal/definition.js +0 -25
  144. package/dist/internal/definition.js.map +0 -1
  145. package/src/Definition.ts +0 -106
  146. package/src/Feature.ts +0 -241
  147. package/src/Fx.ts +0 -176
  148. package/src/internal/definition.ts +0 -56
package/src/Runtime.ts CHANGED
@@ -55,6 +55,7 @@
55
55
  * @groupDescription Functions
56
56
  * Public runtime constructors and helpers for services, machines, and bootstrap input.
57
57
  */
58
+ import * as HostClockModule from "./HostClock.ts"
58
59
  import * as Command from "./Command.ts"
59
60
  import type * as Debug from "./Debug.ts"
60
61
  import * as DescriptorModule from "./Descriptor.ts"
@@ -62,8 +63,10 @@ import type { Descriptor } from "./Descriptor.ts"
62
63
  import type * as Entity from "./Entity.ts"
63
64
  import type * as Inspector from "./Inspector.ts"
64
65
  import * as Cells from "./internal/cells.ts"
66
+ import { sameValue } from "./internal/equal.ts"
65
67
  import * as DebugInternal from "./internal/debug.ts"
66
68
  import { makeQueryEngine } from "./internal/queries.ts"
69
+ import { requirementIndex } from "./internal/requirements.ts"
67
70
  import * as Streams from "./internal/streams.ts"
68
71
  import { ABSENT, makeWorld } from "./internal/world.ts"
69
72
  import type { WorldHooks } from "./internal/world.ts"
@@ -74,13 +77,15 @@ import * as Relation from "./Relation.ts"
74
77
  import type * as Requirement from "./Requirement.ts"
75
78
  import * as Result from "./Result.ts"
76
79
  import * as Schedule from "./Schedule.ts"
80
+ import * as OrderInternal from "./internal/order.ts"
77
81
  import * as EntityScopeModule from "./EntityScope.ts"
78
82
  import * as Snapshot from "./Snapshot.ts"
79
- import type { ExecutableScheduleDefinition } from "./Schedule.ts"
83
+ import type { AnyScheduleOrder, ExecutableScheduleDefinition } from "./Schedule.ts"
80
84
  import type { Registry, Schema } from "./Schema.ts"
81
85
  import type {
82
86
  DespawnedReadView,
83
87
  EventReadView,
88
+ ResourceChangedView,
84
89
  EventWriteView,
85
90
  LookupApi,
86
91
  QueryHandle,
@@ -179,10 +184,13 @@ export type RuntimeConstructionError<S extends Schema.Any, Provided> = Simplify<
179
184
  }>
180
185
 
181
186
  /**
182
- * A runtime plus the `debug` handle when it was made with `debug: true`.
187
+ * A runtime plus the `debug` handle when it was made with `debug: { hostClock }`
188
+ * (optional when the option may be `undefined`).
183
189
  */
184
190
  export type WithDebug<R, Enabled, S extends Schema.Any, Root> =
185
- [Enabled] extends [Debug.Option] ? R & Debug.Member<S, Root> : R
191
+ [Enabled] extends [undefined] ? R
192
+ : [Enabled] extends [Debug.Option] ? R & Debug.Member<S, Root>
193
+ : R & Partial<Debug.Member<S, Root>>
186
194
 
187
195
  /**
188
196
  * The runtime returned by `make`, wrapped in a `Result` exactly when a
@@ -367,23 +375,23 @@ type RequirementErrorsFor<
367
375
  : never
368
376
 
369
377
  type RequirementErrorsOfSchedule<
370
- Selected extends ExecutableScheduleDefinition<any, any, any, any, any>,
378
+ Selected extends ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>,
371
379
  Services extends Record<string, unknown>,
372
380
  Resources extends object,
373
381
  Machines extends object
374
- > = Selected extends ExecutableScheduleDefinition<infer S, any, any, any, any>
382
+ > = Selected extends ExecutableScheduleDefinition<infer S, any, any, any, any, AnyScheduleOrder>
375
383
  ? RequirementErrorsFor<Requirement.Of<Selected>, S, Services, Resources, Machines>
376
384
  : never
377
385
 
378
386
  export type ValidateSchedules<
379
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
387
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
380
388
  Services extends Record<string, unknown>,
381
389
  Resources extends object,
382
390
  Machines extends object
383
391
  > = {
384
392
  readonly [K in keyof Schedules]:
385
393
  Schedules[K] & ValidateSchedule<
386
- Extract<Schedules[K], ExecutableScheduleDefinition<any, any, any, any, any>>,
394
+ Extract<Schedules[K], ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
387
395
  Services,
388
396
  Resources,
389
397
  Machines
@@ -391,7 +399,7 @@ export type ValidateSchedules<
391
399
  }
392
400
 
393
401
  export type ValidateScheduleArray<
394
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
402
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
395
403
  Services extends Record<string, unknown>,
396
404
  Resources extends object,
397
405
  Machines extends object
@@ -402,18 +410,23 @@ export type ValidateScheduleArray<
402
410
  }
403
411
 
404
412
  type ValidateScheduleArgs<
405
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
413
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
406
414
  Services extends Record<string, unknown>,
407
415
  Resources extends object,
408
416
  Machines extends object
409
417
  > = [RequirementErrorsOfSchedule<NoInfer<Schedules[number]>, Services, Resources, Machines>] extends [never]
410
- ? Schedules
418
+ ? [Schedule.TickOrderErrors<NoInfer<Schedules>>] extends [never]
419
+ ? Schedules
420
+ : Schedules & {
421
+ /** `Game.System.thisTick` reads whose writers do not all run earlier in this call. */
422
+ readonly __fixTickOrder__: Schedule.TickOrderErrors<NoInfer<Schedules>>
423
+ }
411
424
  : Schedules & {
412
425
  readonly __fixRuntimeRequirements__: RequirementErrorsOfSchedule<NoInfer<Schedules[number]>, Services, Resources, Machines>
413
426
  }
414
427
 
415
428
  type ValidateSchedule<
416
- Schedule extends ExecutableScheduleDefinition<any, any, any, any, any>,
429
+ Schedule extends ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>,
417
430
  Services extends Record<string, unknown>,
418
431
  Resources extends object,
419
432
  Machines extends object
@@ -432,7 +445,13 @@ type RequirementErrorsOfInspector<
432
445
  ? RequirementErrorsFor<Needs, Spec["schema"], Services, Resources, Machines>
433
446
  : never
434
447
 
435
- type ValidateInspector<
448
+ /**
449
+ * Resolves to `unknown` when the runtime provides every resource, service,
450
+ * and machine the inspector reads, otherwise to an object naming each
451
+ * missing or incompatible one. `runtime.inspect(...)` uses it; tools that
452
+ * run inspectors on a runtime they receive (devtools invariants) reuse it.
453
+ */
454
+ export type ValidateInspector<
436
455
  Selected extends Inspector.Inspector.Any,
437
456
  Services extends Record<string, unknown>,
438
457
  Resources extends object,
@@ -451,6 +470,15 @@ export interface MissingRuntimeRequirement {
451
470
  readonly name: string
452
471
  }
453
472
 
473
+ /**
474
+ * Returned by `tryTick` when a `Game.System.thisTick` read has a writer that
475
+ * runs after it in the schedule. Nothing ran.
476
+ */
477
+ export interface TickOrderViolation {
478
+ readonly kind: "TickOrderViolation"
479
+ readonly issues: ReadonlyArray<Schedule.OrderIssue>
480
+ }
481
+
454
482
  /** Explicit failure returned by the dynamic schedule execution path. */
455
483
  export interface MissingRuntimeRequirements {
456
484
  readonly kind: "MissingRuntimeRequirements"
@@ -584,20 +612,22 @@ export interface Runtime<
584
612
  * system's ECS writes are rolled back and earlier systems stay committed.
585
613
  */
586
614
  readonly tick: {
587
- <const Schedules extends ReadonlyArray<ExecutableScheduleDefinition<S, any, Root, any, any>>>(
615
+ <const Schedules extends ReadonlyArray<ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>(
588
616
  ...schedules: ValidateScheduleArgs<Schedules, Services, Resources, Machines>
589
617
  ): Result.Result<void, Schedule.FailureOf<Schedules[number]>>
590
618
  }
591
619
  /**
592
- * Runs a schedule whose exact requirements are not statically known, for
593
- * example one loaded through a plugin boundary.
620
+ * Runs a schedule whose exact requirements are not statically known: for
621
+ * tools that hold schedules with erased types, such as devtools sessions.
622
+ * Games use `tick(...)`, which rejects missing requirements at compile time.
594
623
  *
595
624
  * Requirements are checked against current provisioning first and missing
596
- * ones are returned as data.
625
+ * ones are returned as data, then `Game.System.thisTick` reads are checked
626
+ * against the schedule's order (`TickOrderViolation`).
597
627
  */
598
- readonly tryTick: <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any>>(
628
+ readonly tryTick: <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>(
599
629
  schedule: Selected
600
- ) => Result.Result<void, MissingRuntimeRequirements | Schedule.FailureOf<Selected>>
630
+ ) => Result.Result<void, MissingRuntimeRequirements | TickOrderViolation | Schedule.FailureOf<Selected>>
601
631
  /**
602
632
  * Captures the world as plain data: entities with their ids, components,
603
633
  * relation edges, resources, and committed machine values.
@@ -678,7 +708,8 @@ const makeValidatedRuntime = <
678
708
  readonly resources: Partial<Record<string, unknown>>
679
709
  readonly machines: RuntimeMachines<any> | undefined
680
710
  readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
681
- readonly debug: boolean
711
+ readonly scopeDefinitions: ReadonlyArray<EntityScopeModule.EntityScope.Any>
712
+ readonly debug: Debug.Option | undefined
682
713
  }): Runtime<S, Services, Resources, Root, Machines> => {
683
714
  const world = makeWorld(options.schema, streamCapacity)
684
715
  const queries = makeQueryEngine(world)
@@ -723,12 +754,12 @@ const makeValidatedRuntime = <
723
754
  const pendingCommands: Array<Command.DeferredCommand<S>> = []
724
755
 
725
756
  /**
726
- * Debug state. With `debug: true`, `pendingOrigins` names the system that
757
+ * Debug state. With `debug`, `pendingOrigins` names the system that
727
758
  * queued each pending command, in step with `pendingCommands`. Tracing is
728
759
  * on only while `observe` listeners are registered; untraced runs pay one
729
760
  * boolean check at each system, schedule, and marker boundary.
730
761
  */
731
- const debugEnabled = options.debug
762
+ const debugEnabled = options.debug !== undefined
732
763
  const pendingOrigins: Array<string> = []
733
764
  const traceListeners = new Set<(event: Debug.TraceEvent) => void>()
734
765
  let tracing = false
@@ -740,7 +771,9 @@ const makeValidatedRuntime = <
740
771
  const scheduleNames = new Map<object, string>()
741
772
  const anonymousScheduleNames = new WeakMap<object, string>()
742
773
  let anonymousScheduleCount = 0
743
- const now = (): number => performance.now()
774
+ // Only a debug runtime times anything; the clock is the one it was given.
775
+ const hostClock: HostClockModule.HostClock = options.debug?.hostClock ?? { now: () => 0 }
776
+ const now = (): number => hostClock.now()
744
777
  const emit = (event: Debug.TraceEvent): void => {
745
778
  for (const listener of traceListeners) listener(event)
746
779
  }
@@ -772,8 +805,48 @@ const makeValidatedRuntime = <
772
805
  options.machineDefinitions.map((machine, index) => [machine.key, index] as const)
773
806
  )
774
807
 
808
+ /** Machines the runtime was given an initial value for. */
809
+ const providedMachineKeys = new Set<symbol>()
775
810
  for (const provision of machineEntries) {
776
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
+ }
777
850
  }
778
851
 
779
852
  const appendRelationFailure = (
@@ -797,29 +870,148 @@ const makeValidatedRuntime = <
797
870
  ])
798
871
  }
799
872
 
873
+ /**
874
+ * With `debug`, the relations commands have tried to relate or
875
+ * reorder, with the systems that queued those commands, for the
876
+ * `relation-failure-never-read` lint. `commandOrigin` names the system whose
877
+ * command is being applied.
878
+ */
879
+ const relationMutations = new Map<string, Set<string>>()
880
+ let commandOrigin = "(unknown)"
881
+ /**
882
+ * With `debug`, what each system's applied commands did, as
883
+ * `kind:name` (`spawns:Enemy`, `despawns:Enemy` for every component the
884
+ * despawned entity had), and the systems that ran: for the
885
+ * `command-permission-unused` lint.
886
+ */
887
+ const commandUses = new Map<string, Set<string>>()
888
+ const ranSystems = new Set<string>()
889
+ const noteCommand = (kind: string, name: string): void => {
890
+ let uses = commandUses.get(commandOrigin)
891
+ if (uses === undefined) {
892
+ uses = new Set()
893
+ commandUses.set(commandOrigin, uses)
894
+ }
895
+ uses.add(`${kind}:${name}`)
896
+ }
897
+ const noteRelationMutation = (relation: Relation.Relation.Any): void => {
898
+ let systems = relationMutations.get(relation.name)
899
+ if (systems === undefined) {
900
+ systems = new Set()
901
+ relationMutations.set(relation.name, systems)
902
+ }
903
+ systems.add(commandOrigin)
904
+ }
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
+
800
948
  /**
801
949
  * World adapter used by deferred commands.
802
950
  */
803
951
  const commandWorld: Command.InternalWorld<S> = {
804
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
+ }
960
+ if (debugEnabled) for (const [descriptor] of components) noteCommand("spawns", descriptor.name)
805
961
  world.spawnEntity(id, components)
962
+ return true
806
963
  },
807
964
  destroyEntity(id) {
965
+ if (debugEnabled) {
966
+ const record = world.records.get(id)
967
+ record?.values.forEach((value, ordinal) => {
968
+ if (value !== ABSENT) noteCommand("despawns", world.descriptorAt(ordinal).name)
969
+ })
970
+ }
808
971
  world.destroyEntity(id)
809
972
  },
810
973
  assignEntityScope(id, scope) {
811
974
  world.assignEntityScope(id, scope.key)
812
975
  },
813
976
  destroyEntityScope(scope) {
977
+ if (debugEnabled) noteCommand("despawnsScope", scope.name)
814
978
  world.destroyEntityScope(scope.key)
815
979
  },
816
- removeComponent(id, descriptor) {
817
- 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
+ }
818
996
  },
819
- writeComponent(id, descriptor, value) {
820
- 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
+ }
821
1009
  },
822
1010
  tryRelate(id, relation, target) {
1011
+ if (debugEnabled) {
1012
+ noteRelationMutation(relation)
1013
+ noteCommand("relates", relation.name)
1014
+ }
823
1015
  const result = world.tryRelate(id, relation, target)
824
1016
  if (!result.ok) {
825
1017
  appendRelationFailure(relation, "relate", id, target, result.error)
@@ -827,9 +1019,14 @@ const makeValidatedRuntime = <
827
1019
  return result
828
1020
  },
829
1021
  unrelate(id, relation) {
1022
+ if (debugEnabled) noteCommand("relates", relation.name)
830
1023
  world.unrelate(id, relation)
831
1024
  },
832
1025
  reorderChildren(id, relation, children) {
1026
+ if (debugEnabled) {
1027
+ noteRelationMutation(relation)
1028
+ noteCommand("relates", relation.name)
1029
+ }
833
1030
  const result = world.reorderChildren(id, relation, children.map((child) => child))
834
1031
  if (!result.ok) {
835
1032
  const targetId = result.error._tag === "MissingChildEntity"
@@ -882,12 +1079,6 @@ const makeValidatedRuntime = <
882
1079
 
883
1080
  const makeLookup = (reader: ReaderState): LookupApi<S, Root> => {
884
1081
  const lookup: LookupApi<S, Root> = {
885
- get(target, query) {
886
- return resolve(target, query, reader)
887
- },
888
- getHandle(handle, query) {
889
- return resolve(handle, query, reader)
890
- },
891
1082
  related: relatedTarget,
892
1083
  relatedSources(target, relation) {
893
1084
  if (!world.records.has(target)) {
@@ -895,19 +1086,6 @@ const makeValidatedRuntime = <
895
1086
  }
896
1087
  return Result.success(world.relatedSourceIds(relation, target).map(entityId))
897
1088
  },
898
- childMatches(target, relation, query) {
899
- if (!world.records.has(target)) {
900
- return Result.failure(Relation.missingEntityError(target))
901
- }
902
- const matches: Array<QueryMatch<S, typeof query>> = []
903
- for (const sourceId of world.relatedSourceIds(relation, target)) {
904
- const resolved = resolve(sourceId, query, reader)
905
- if (resolved.ok) {
906
- matches.push(resolved.value)
907
- }
908
- }
909
- return Result.success(matches)
910
- },
911
1089
  parent: relatedTarget,
912
1090
  ancestors(target, relation) {
913
1091
  if (!world.records.has(target)) {
@@ -946,20 +1124,6 @@ const makeValidatedRuntime = <
946
1124
  }
947
1125
  return Result.success(descendants)
948
1126
  },
949
- descendantMatches(target, relation, query, options) {
950
- const descendants = lookup.descendants(target, relation, options)
951
- if (!descendants.ok) {
952
- return descendants
953
- }
954
- const matches: Array<QueryMatch<S, typeof query>> = []
955
- for (const descendantId of descendants.value) {
956
- const resolved = resolve(descendantId, query, reader)
957
- if (resolved.ok) {
958
- matches.push(resolved.value)
959
- }
960
- }
961
- return Result.success(matches)
962
- },
963
1127
  root(target, relation) {
964
1128
  if (!world.records.has(target)) {
965
1129
  return Result.failure(Relation.missingEntityError(target))
@@ -976,13 +1140,32 @@ const makeValidatedRuntime = <
976
1140
  return lookup
977
1141
  }
978
1142
 
979
- const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState): QueryHandle<S, Q> => {
1143
+ const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryHandle<S, Q> => {
980
1144
  const each = () => queries.each(query, reader.since) as ReadonlyArray<QueryMatch<S, Q>>
1145
+ const matching = (ids: ReadonlyArray<Entity.EntityId<S, Root>>): Array<QueryMatch<S, Q>> => {
1146
+ const matches: Array<QueryMatch<S, Q>> = []
1147
+ for (const id of ids) {
1148
+ const resolved = resolve(id, query, reader)
1149
+ if (resolved.ok) matches.push(resolved.value)
1150
+ }
1151
+ return matches
1152
+ }
981
1153
  return {
982
1154
  each,
983
1155
  get(target) {
984
1156
  return resolve(target, query, reader)
985
1157
  },
1158
+ getHandle(handle) {
1159
+ return resolve(handle as Entity.Handle<Root, any>, query, reader)
1160
+ },
1161
+ children(target, relation) {
1162
+ if (!world.records.has(target)) return Result.failure(Relation.missingEntityError(target))
1163
+ return Result.success(matching(world.relatedSourceIds(relation, target).map(entityId)))
1164
+ },
1165
+ descendants(target, relation, options) {
1166
+ const ids = lookup.descendants(target, relation, options)
1167
+ return ids.ok ? Result.success(matching(ids.value)) : ids
1168
+ },
986
1169
  single() {
987
1170
  const matches = each()
988
1171
  if (matches.length === 0) {
@@ -1024,6 +1207,15 @@ const makeValidatedRuntime = <
1024
1207
  }
1025
1208
  const writeResource = journalStoreWrite(resources, resourceOriginals)
1026
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
+
1027
1219
  const journalMachine = (key: symbol): void => {
1028
1220
  if (!machineOriginals.has(key)) {
1029
1221
  machineOriginals.set(key, pendingMachines.get(key) ?? absentValue)
@@ -1036,6 +1228,14 @@ const makeValidatedRuntime = <
1036
1228
 
1037
1229
  const commitSystemTransaction = (): void => {
1038
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
+ }
1039
1239
  if (emittedEvents.size > 0) {
1040
1240
  const published = world.advanceTick()
1041
1241
  for (const [key, values] of emittedEvents) {
@@ -1078,6 +1278,17 @@ const makeValidatedRuntime = <
1078
1278
  write: (key: symbol, value: unknown) => void
1079
1279
  ) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor))
1080
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
+
1081
1292
  const makeEventReadView = <T>(descriptorKey: symbol, reader: ReaderState): EventReadView<T> => ({
1082
1293
  all() {
1083
1294
  return events.since(descriptorKey, reader.streamSince) as ReadonlyArray<ReadonlyValue<T>>
@@ -1115,6 +1326,9 @@ const makeValidatedRuntime = <
1115
1326
  return {
1116
1327
  all() {
1117
1328
  return world.removedSince(ordinal, reader.since).map(entityId)
1329
+ },
1330
+ lagged() {
1331
+ return world.removedLagged(ordinal, reader.since, reader.registeredAt)
1118
1332
  }
1119
1333
  }
1120
1334
  }
@@ -1122,6 +1336,9 @@ const makeValidatedRuntime = <
1122
1336
  const makeDespawnedReadView = (reader: ReaderState): DespawnedReadView<S, Root> => ({
1123
1337
  all() {
1124
1338
  return world.despawnedSince(reader.since).map(entityId)
1339
+ },
1340
+ lagged() {
1341
+ return world.despawnedLagged(reader.since, reader.registeredAt)
1125
1342
  }
1126
1343
  })
1127
1344
 
@@ -1138,19 +1355,33 @@ const makeValidatedRuntime = <
1138
1355
  })
1139
1356
 
1140
1357
  const makeMachineReadView = <M extends Machine.StateMachine.Any>(stateMachine: M): MachineReadView<M> => ({
1141
- get: () => currentMachines.get(stateMachine.key) as Machine.StateMachine.Value<M>,
1358
+ get: () => currentMachines.get(stateMachine.key) as Machine.CurrentValue<M>,
1142
1359
  is: (value) => currentMachines.get(stateMachine.key) === value
1143
1360
  })
1144
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
+
1145
1378
  const makeNextMachineWriteView = <M extends Machine.StateMachine.Any>(stateMachine: M): NextMachineWriteView<M> => ({
1146
1379
  getPending: () => pendingMachines.get(stateMachine.key)?.value as Machine.StateMachine.Value<M> | undefined,
1147
1380
  set(value) {
1148
- journalMachine(stateMachine.key)
1149
- pendingMachines.set(stateMachine.key, { value, skipIfSame: false })
1381
+ return queueMachine(stateMachine, value, false)
1150
1382
  },
1151
1383
  setIfChanged(value) {
1152
- journalMachine(stateMachine.key)
1153
- pendingMachines.set(stateMachine.key, { value, skipIfSame: true })
1384
+ return queueMachine(stateMachine, value, true)
1154
1385
  },
1155
1386
  reset() {
1156
1387
  journalMachine(stateMachine.key)
@@ -1199,16 +1430,19 @@ const makeValidatedRuntime = <
1199
1430
  * views the implementation is allowed to see. Views read live storage, so
1200
1431
  * one context is built per system and reused for every run.
1201
1432
  */
1202
- const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState): SystemContext<any> => {
1433
+ const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState, queue: Command.CommandQueue<S, Root>): SystemContext<any> => {
1203
1434
  const spec = system.spec
1435
+ const lookup = makeLookup(reader)
1204
1436
  return {
1205
1437
  local: (spec.local as (() => unknown) | undefined)?.(),
1206
- queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader)),
1207
- lookup: makeLookup(reader),
1438
+ queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup)),
1439
+ lookup,
1208
1440
  resources: mapRecord(spec.resources as Record<string, any>, (access) =>
1209
1441
  access.mode === "read"
1210
1442
  ? Cells.storeRead(resources, access.descriptor.key)
1211
- : makeResourceWriteView(access.descriptor, resources, writeResource)),
1443
+ : access.mode === "changed"
1444
+ ? makeResourceChangedView(access.descriptor, reader)
1445
+ : makeResourceWriteView(access.descriptor, resources, writeResource)),
1212
1446
  events: mapRecord(spec.events as Record<string, any>, (access) =>
1213
1447
  access.mode === "read"
1214
1448
  ? makeEventReadView(access.descriptor.key, reader)
@@ -1222,12 +1456,15 @@ const makeValidatedRuntime = <
1222
1456
  relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => makeRelationFailureReadView(access.relation, reader)),
1223
1457
  services: mapRecord(spec.services as Record<string, any>, (access) =>
1224
1458
  providedServices[access.descriptor.name as keyof Services]),
1225
- commands: Command.makeCommands<S, Root>(() => world.allocateEntity() as Entity.EntityId<S, Root>)
1459
+ // The queue serves every declared permission set; the spec's types narrow it.
1460
+ commands: queue as unknown
1226
1461
  } as SystemContext<any>
1227
1462
  }
1228
1463
 
1229
1464
  interface SystemSlot {
1230
1465
  readonly context: SystemContext<any>
1466
+ /** The system's command queue (its context's `commands`, unchecked). */
1467
+ readonly commands: Command.CommandQueue<S, Root>
1231
1468
  readonly reader: ReaderState
1232
1469
  }
1233
1470
 
@@ -1316,7 +1553,7 @@ const makeValidatedRuntime = <
1316
1553
  system: system.name,
1317
1554
  outcome,
1318
1555
  ...(outcome === "ok" ? {} : { error }),
1319
- ms: now() - started,
1556
+ ms: HostClockModule.elapsed(started, now()),
1320
1557
  writes,
1321
1558
  resources: resourceWrites,
1322
1559
  events: emitted,
@@ -1383,7 +1620,8 @@ const makeValidatedRuntime = <
1383
1620
  let slot = slots.get(stateKey(system))
1384
1621
  if (!slot) {
1385
1622
  const reader: ReaderState = { since: 0, lastRun: 0, streamSince: 0, streamLastRun: 0, registeredAt: world.currentTick() }
1386
- slot = { context: makeContext(system, reader), reader }
1623
+ const commands = Command.makeCommands<S, Root>(() => world.allocateEntity() as Entity.EntityId<S, Root>)
1624
+ slot = { context: makeContext(system, reader, commands), commands, reader }
1387
1625
  slots.set(stateKey(system), slot)
1388
1626
  if (holdsStreams) registerStreamReader(system, slot.reader)
1389
1627
  const make = system.spec.local as (() => unknown) | undefined
@@ -1422,7 +1660,8 @@ const makeValidatedRuntime = <
1422
1660
  return succeeded
1423
1661
  }
1424
1662
  }
1425
- const { context, reader } = slotOf(system, true)
1663
+ const { context, commands, reader } = slotOf(system, true)
1664
+ if (debugEnabled) ranSystems.add(system.name)
1426
1665
  // Changes stamped after the previous completed run are visible to this run.
1427
1666
  reader.since = reader.lastRun
1428
1667
  reader.streamSince = reader.streamLastRun
@@ -1431,23 +1670,21 @@ const makeValidatedRuntime = <
1431
1670
  beginSystemTransaction()
1432
1671
  let outcome: Result.Result<unknown, unknown>
1433
1672
  try {
1434
- // A system that returns nothing cannot fail. Otherwise run its effect,
1435
- // equivalent to `Fx.runSync(Fx.provide(effect, services))` without the wrapper allocations.
1436
- const effect = system.run(context)
1437
- outcome = effect === undefined ? succeeded : effect.run(context.services)
1673
+ // A system that returns nothing cannot fail; otherwise it returned a `Result`.
1674
+ outcome = system.run(context) ?? succeeded
1438
1675
  } catch (defect) {
1439
- if (tracing) emit(traceSystem(system, reader, thisRun, started, "defect", defect, context.commands.flush()))
1676
+ if (tracing) emit(traceSystem(system, reader, thisRun, started, "defect", defect, commands.flush()))
1440
1677
  rollbackSystemTransaction()
1441
- context.commands.flush()
1678
+ commands.flush()
1442
1679
  throw defect
1443
1680
  }
1444
1681
  if (!outcome.ok) {
1445
- if (tracing) emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, context.commands.flush()))
1682
+ if (tracing) emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, commands.flush()))
1446
1683
  rollbackSystemTransaction()
1447
- context.commands.flush()
1684
+ commands.flush()
1448
1685
  return Result.failure({ kind: "SystemFailure", system: system.name, error: outcome.error })
1449
1686
  }
1450
- const queued = context.commands.flush()
1687
+ const queued = commands.flush()
1451
1688
  const traced = tracing ? traceSystem(system, reader, thisRun, started, "ok", undefined, queued) : undefined
1452
1689
  commitSystemTransaction()
1453
1690
  // A failed run leaves `lastRun` unchanged, so the next run sees the same changes again.
@@ -1469,8 +1706,15 @@ const makeValidatedRuntime = <
1469
1706
  const origins = pendingOrigins.splice(0, pendingOrigins.length)
1470
1707
  world.advanceTick()
1471
1708
  if (!tracing) {
1472
- for (const command of commands) {
1473
- command.apply(commandWorld)
1709
+ if (!debugEnabled) {
1710
+ for (const command of commands) {
1711
+ command.apply(commandWorld)
1712
+ }
1713
+ return
1714
+ }
1715
+ for (let index = 0; index < commands.length; index++) {
1716
+ commandOrigin = origins[index] ?? "(unknown)"
1717
+ commands[index]!.apply(commandWorld)
1474
1718
  }
1475
1719
  return
1476
1720
  }
@@ -1479,6 +1723,7 @@ const makeValidatedRuntime = <
1479
1723
  const command = commands[index]!
1480
1724
  const effects: Array<Debug.Effect> = []
1481
1725
  commandEffects = effects
1726
+ commandOrigin = origins[index] ?? "(unknown)"
1482
1727
  try {
1483
1728
  command.apply(commandWorld)
1484
1729
  } finally {
@@ -1503,7 +1748,7 @@ const makeValidatedRuntime = <
1503
1748
  } finally {
1504
1749
  schedulePath.pop()
1505
1750
  }
1506
- emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: now() - started })
1751
+ emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: HostClockModule.elapsed(started, now()) })
1507
1752
  return result
1508
1753
  }
1509
1754
 
@@ -1541,6 +1786,36 @@ const makeValidatedRuntime = <
1541
1786
  }
1542
1787
  }
1543
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
+
1544
1819
  /**
1545
1820
  * Applies queued commands, then queued machine transitions, running the
1546
1821
  * matching exit, transition, and enter schedules from the bundle.
@@ -1558,6 +1833,10 @@ const makeValidatedRuntime = <
1558
1833
 
1559
1834
  const schedules = bundle?.entries ?? []
1560
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
+ }
1561
1840
  pendingMachines.delete(machineKey)
1562
1841
  const current = currentMachines.get(machineKey)
1563
1842
  if (current === undefined) {
@@ -1568,12 +1847,25 @@ const makeValidatedRuntime = <
1568
1847
  continue
1569
1848
  }
1570
1849
 
1571
- previousMachines.set(machineKey, current)
1572
1850
  const snapshot = {
1573
1851
  from: current as Machine.StateValue,
1574
1852
  to: pending.value as Machine.StateValue
1575
1853
  }
1576
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
+
1577
1869
  for (const schedule of schedules) {
1578
1870
  const transition = schedule.transition
1579
1871
  if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
@@ -1603,10 +1895,8 @@ const makeValidatedRuntime = <
1603
1895
  }
1604
1896
  }
1605
1897
 
1606
- currentMachines.set(machineKey, pending.value)
1607
- changedMachines.add(machineKey)
1608
- transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
1609
- 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)
1610
1900
 
1611
1901
  for (const schedule of schedules) {
1612
1902
  const transition = schedule.transition
@@ -1618,6 +1908,82 @@ const makeValidatedRuntime = <
1618
1908
  }
1619
1909
  }
1620
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
1621
1987
  }
1622
1988
  return succeeded
1623
1989
  }
@@ -1661,7 +2027,7 @@ const makeValidatedRuntime = <
1661
2027
  * Runs one schedule. A failing system stops the schedule; systems that
1662
2028
  * already succeeded stay committed, and their pending work stays pending.
1663
2029
  */
1664
- const runScheduleUnsafe = (schedule: ExecutableScheduleDefinition<S, any, any, any, any>): Result.Result<void, SystemFailure> => {
2030
+ const runScheduleUnsafe = (schedule: ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>): Result.Result<void, SystemFailure> => {
1665
2031
  changedMachines = new Set()
1666
2032
  return tracing
1667
2033
  ? traceSchedule(scheduleName(schedule), () => runSteps(schedule.steps))
@@ -1670,6 +2036,13 @@ const makeValidatedRuntime = <
1670
2036
 
1671
2037
  const eventsByKey = new Set((Object.values(options.schema.events) as ReadonlyArray<Descriptor.Any>).map((descriptor) => descriptor.key))
1672
2038
  const externallyWrittenEvents = new Set<string>()
2039
+ /** `debug.observeHost` listeners: ticks and sends, without tracing. Only a debug runtime registers any. */
2040
+ const hostListeners = new Set<(event: Debug.HostEvent<S, Root>) => void>()
2041
+ const emitHost = (event: Debug.HostEvent<S, Root>): void => {
2042
+ for (const listener of hostListeners) listener(event)
2043
+ }
2044
+ /** Events declared as sent by the host (`debug.declareHostEvents`). */
2045
+ let hostEvents = new Set<string>()
1673
2046
 
1674
2047
  const send = (descriptor: Descriptor<"event", string, any>, value: unknown): void => {
1675
2048
  if (ticking) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
@@ -1679,6 +2052,7 @@ const makeValidatedRuntime = <
1679
2052
  // Bound host traffic even when it sends repeatedly without ticking.
1680
2053
  events.trim(0)
1681
2054
  externallyWrittenEvents.add(descriptor.name)
2055
+ if (hostListeners.size > 0) emitHost({ type: "send", event: descriptor.name, value })
1682
2056
  if (tracing) emit({ type: "runtime.sent", frame: frameCount, tick: published, event: descriptor.name, value })
1683
2057
  }
1684
2058
 
@@ -1691,21 +2065,91 @@ const makeValidatedRuntime = <
1691
2065
  relationFailures.trim(boundary)
1692
2066
  }
1693
2067
 
1694
- const tickUnsafe = (schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any>>): Result.Result<void, SystemFailure> => {
2068
+ /**
2069
+ * With debug: the last failed tick result, until its `ok` is read. The
2070
+ * result handed back is a copy whose `ok` getter marks it read.
2071
+ */
2072
+ let unread: { read: boolean; readonly event: Omit<Debug.FailureIgnoredEvent, "type" | "when"> } | undefined
2073
+ const reportUnread = (when: Debug.FailureIgnoredEvent["when"]): void => {
2074
+ const pending = unread
2075
+ unread = undefined
2076
+ if (pending === undefined || pending.read) return
2077
+ const event: Debug.FailureIgnoredEvent = { type: "failure.ignored", ...pending.event, when }
2078
+ emit(event)
2079
+ emitHost(event)
2080
+ }
2081
+ const watchResult = <R extends Result.Result<void, unknown>>(
2082
+ result: R,
2083
+ schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>
2084
+ ): R => {
2085
+ if (!debugEnabled || result.ok) return result
2086
+ const error = result.error
2087
+ const system = typeof error === "object" && error !== null && (error as { readonly kind?: unknown }).kind === "SystemFailure"
2088
+ ? (error as SystemFailure).system
2089
+ : undefined
2090
+ const names = schedules.flatMap((schedule) => schedule.steps.filter(Schedule.isSystemStep).map((step) => (step as SystemDefinition<any, any, any>).name))
2091
+ const at = system === undefined ? -1 : names.indexOf(system)
2092
+ const holder = system === undefined
2093
+ ? schedules[0]
2094
+ : schedules.find((schedule) => schedule.steps.some((step) => Schedule.isSystemStep(step) && (step as SystemDefinition<any, any, any>).name === system)) ?? schedules[0]
2095
+ const entry = {
2096
+ read: false,
2097
+ event: {
2098
+ frame: frameCount,
2099
+ schedule: holder === undefined ? "(none)" : scheduleName(holder),
2100
+ system,
2101
+ error,
2102
+ skipped: [...new Set(names.slice(at + 1))]
2103
+ }
2104
+ }
2105
+ unread = entry
2106
+ return {
2107
+ get ok() {
2108
+ entry.read = true
2109
+ return false
2110
+ },
2111
+ error
2112
+ } as unknown as R
2113
+ }
2114
+
2115
+ const tickUnsafe = (schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>): Result.Result<void, SystemFailure> => {
1695
2116
  if (ticking) throw new Error("runtime.tick cannot run inside another tick")
2117
+ if (debugEnabled) reportUnread("next-tick")
1696
2118
  ticking = true
1697
2119
  try {
1698
2120
  advanceFrame()
1699
- for (const schedule of schedules) {
1700
- const result = runScheduleUnsafe(schedule)
1701
- if (!result.ok) return result
1702
- }
1703
- return succeeded
2121
+ return watchResult(observedTick(schedules, () => {
2122
+ for (const schedule of schedules) {
2123
+ const result = runScheduleUnsafe(schedule)
2124
+ if (!result.ok) return result
2125
+ }
2126
+ return succeeded
2127
+ }), schedules)
1704
2128
  } finally {
1705
2129
  ticking = false
1706
2130
  }
1707
2131
  }
1708
2132
 
2133
+ /** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
2134
+ const observedTick = (
2135
+ schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>,
2136
+ run: () => Result.Result<void, SystemFailure>
2137
+ ): Result.Result<void, SystemFailure> => {
2138
+ if (hostListeners.size === 0) return run()
2139
+ emitHost({ type: "tick.start", schedules, frame: frameCount })
2140
+ let result: Result.Result<void, SystemFailure>
2141
+ try {
2142
+ result = run()
2143
+ } catch (defect) {
2144
+ emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect })
2145
+ throw defect
2146
+ }
2147
+ emitHost(result.ok
2148
+ ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
2149
+ : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error })
2150
+ return result
2151
+ }
2152
+
1709
2153
  const hasRequirement = (requirement: Requirement.RequirementValue): boolean => {
1710
2154
  switch (requirement.kind) {
1711
2155
  case "service":
@@ -1713,26 +2157,33 @@ const makeValidatedRuntime = <
1713
2157
  case "resource":
1714
2158
  return resources.has(requirement.key)
1715
2159
  case "stateMachine":
1716
- 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)
1717
2162
  }
1718
2163
  }
1719
2164
 
1720
- const tryRunSchedule = <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any>>(
2165
+ const tryRunSchedule = <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>(
1721
2166
  schedule: Selected
1722
- ): Result.Result<void, MissingRuntimeRequirements | Schedule.FailureOf<Selected>> => {
2167
+ ): Result.Result<void, MissingRuntimeRequirements | TickOrderViolation | Schedule.FailureOf<Selected>> => {
2168
+ if (debugEnabled) reportUnread("next-tick")
1723
2169
  const missing = schedule.requirements
1724
2170
  .filter((requirement) => !hasRequirement(requirement))
1725
2171
  .map(({ kind, name }) => ({ kind, name }))
1726
2172
 
1727
2173
  if (missing.length > 0) {
1728
- return Result.failure({
1729
- kind: "MissingRuntimeRequirements",
2174
+ return watchResult(Result.failure({
2175
+ kind: "MissingRuntimeRequirements" as const,
1730
2176
  requirements: missing
1731
- })
2177
+ }), [schedule])
2178
+ }
2179
+
2180
+ const issues = OrderInternal.tickOrderIssues(schedule.steps)
2181
+ if (issues.length > 0) {
2182
+ return watchResult(Result.failure({ kind: "TickOrderViolation" as const, issues }), [schedule])
1732
2183
  }
1733
2184
 
1734
2185
  advanceFrame()
1735
- return runScheduleUnsafe(schedule) as Result.Result<void, Schedule.FailureOf<Selected>>
2186
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
1736
2187
  }
1737
2188
 
1738
2189
  /**
@@ -1823,6 +2274,11 @@ const makeValidatedRuntime = <
1823
2274
  if (!value.ok) return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error })
1824
2275
  components.push([descriptor, value.value])
1825
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
+ }
1826
2282
  entities.push({ id: entity.id, components })
1827
2283
  }
1828
2284
  const validatedResources: Array<readonly [symbol, unknown]> = []
@@ -1859,12 +2315,29 @@ const makeValidatedRuntime = <
1859
2315
  }
1860
2316
  }
1861
2317
  }
1862
- const machineValues: Array<readonly [symbol, Machine.StateValue]> = []
2318
+ const restoredMachines = new Map(currentMachines)
1863
2319
  for (const [name, value] of Object.entries(saved.machines)) {
1864
2320
  const machine = machinesByName.get(name)
1865
2321
  if (!machine) return Result.failure({ _tag: "UnknownMachine", name })
1866
2322
  if (!machine.values.includes(value)) return Result.failure({ _tag: "InvalidMachineState", name, value })
1867
- 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
+ }
1868
2341
  }
1869
2342
 
1870
2343
  const scoped = new Set<number>()
@@ -1897,8 +2370,12 @@ const makeValidatedRuntime = <
1897
2370
  world.tryRelate(source, relation, target)
1898
2371
  }
1899
2372
  for (const [id, key] of memberships) world.assignEntityScope(id, key)
1900
- for (const [key, value] of validatedResources) resources.set(key, value)
1901
- 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)
1902
2379
  // Locals are not saved: each system starts over from its factory.
1903
2380
  localSlots.forEach(({ slot }, index) => { (slot.context as { local: unknown }).local = rebuiltLocals[index] })
1904
2381
  return succeeded
@@ -2041,12 +2518,21 @@ const makeValidatedRuntime = <
2041
2518
  scheduleNames.clear()
2042
2519
  for (const [name, schedule] of Object.entries(named)) scheduleNames.set(schedule, name)
2043
2520
  },
2521
+ declareHostEvents(declared) {
2522
+ hostEvents = new Set(declared.map((descriptor) => descriptor.name))
2523
+ },
2044
2524
  describe: () => DebugInternal.describe({
2045
2525
  schema: options.schema,
2046
2526
  machines: [...new Set(machinesByName.values())],
2047
2527
  currentMachine: (machine) => currentMachines.get(machine.key) as Machine.StateValue | undefined,
2528
+ providesMachine: (machine) => providedMachineKeys.has(machine.key),
2529
+ scopes: options.scopeDefinitions,
2048
2530
  providedServices: Object.keys(providedServices),
2049
- externallyWrittenEvents,
2531
+ externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
2532
+ relationMutations,
2533
+ commandUses,
2534
+ ranSystems,
2535
+ requirementViolations: [...requirementViolations.values()],
2050
2536
  hasResource: (descriptor) => resources.has(descriptor.key),
2051
2537
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
2052
2538
  }),
@@ -2063,6 +2549,13 @@ const makeValidatedRuntime = <
2063
2549
  }
2064
2550
  }
2065
2551
  },
2552
+ observeHost(listener) {
2553
+ hostListeners.add(listener)
2554
+ return () => {
2555
+ hostListeners.delete(listener)
2556
+ }
2557
+ },
2558
+ checkIgnoredFailures: () => reportUnread("session-end"),
2066
2559
  streams,
2067
2560
  population: () => {
2068
2561
  const components: Record<string, number> = {}
@@ -2110,7 +2603,9 @@ export const make = <
2110
2603
  readonly resources?: Resources
2111
2604
  readonly machines?: ProvidedMachines
2112
2605
  readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
2113
- /** Attaches a `debug` handle to the runtime; see the `Debug` module. */
2606
+ /** Scopes whose members a committed state change despawns; `Game.Runtime.make` passes every `Game.EntityScope`. */
2607
+ readonly scopeDefinitions?: ReadonlyArray<EntityScopeModule.EntityScope.Any>
2608
+ /** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
2114
2609
  readonly debug?: Enabled
2115
2610
  }): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
2116
2611
  const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) =>
@@ -2128,7 +2623,8 @@ export const make = <
2128
2623
  resources: validated.value,
2129
2624
  machines: options.machines,
2130
2625
  machineDefinitions: options.machineDefinitions ?? [],
2131
- debug: options.debug === true
2626
+ scopeDefinitions: options.scopeDefinitions ?? [],
2627
+ debug: options.debug
2132
2628
  })
2133
2629
  return (fallible ? Result.success(runtime) : runtime) as never
2134
2630
  }