@typeonce/ritmo 0.6.0 → 0.7.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 (125) 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 +157 -33
  11. package/dist/Command.d.ts.map +1 -1
  12. package/dist/Command.js +49 -15
  13. package/dist/Command.js.map +1 -1
  14. package/dist/Debug.d.ts +104 -12
  15. package/dist/Debug.d.ts.map +1 -1
  16. package/dist/Debug.js.map +1 -1
  17. package/dist/Decode.d.ts +1 -1
  18. package/dist/Decode.js +1 -1
  19. package/dist/Descriptor.d.ts +31 -21
  20. package/dist/Descriptor.d.ts.map +1 -1
  21. package/dist/Descriptor.js +21 -12
  22. package/dist/Descriptor.js.map +1 -1
  23. package/dist/Entity.d.ts +14 -5
  24. package/dist/Entity.d.ts.map +1 -1
  25. package/dist/Entity.js +2 -2
  26. package/dist/Entity.js.map +1 -1
  27. package/dist/EntityScope.d.ts +6 -0
  28. package/dist/EntityScope.d.ts.map +1 -1
  29. package/dist/EntityScope.js +6 -0
  30. package/dist/EntityScope.js.map +1 -1
  31. package/dist/HostClock.d.ts +49 -0
  32. package/dist/HostClock.d.ts.map +1 -0
  33. package/dist/HostClock.js +46 -0
  34. package/dist/HostClock.js.map +1 -0
  35. package/dist/Inspector.d.ts +7 -1
  36. package/dist/Inspector.d.ts.map +1 -1
  37. package/dist/Inspector.js.map +1 -1
  38. package/dist/Machine.d.ts +12 -3
  39. package/dist/Machine.d.ts.map +1 -1
  40. package/dist/Machine.js +4 -1
  41. package/dist/Machine.js.map +1 -1
  42. package/dist/Query.d.ts +28 -24
  43. package/dist/Query.d.ts.map +1 -1
  44. package/dist/Query.js +0 -24
  45. package/dist/Query.js.map +1 -1
  46. package/dist/Runtime.d.ts +36 -16
  47. package/dist/Runtime.d.ts.map +1 -1
  48. package/dist/Runtime.js +230 -67
  49. package/dist/Runtime.js.map +1 -1
  50. package/dist/Schedule.d.ts +217 -10
  51. package/dist/Schedule.d.ts.map +1 -1
  52. package/dist/Schedule.js +5 -0
  53. package/dist/Schedule.js.map +1 -1
  54. package/dist/Schema.d.ts +61 -25
  55. package/dist/Schema.d.ts.map +1 -1
  56. package/dist/Schema.js +0 -8
  57. package/dist/Schema.js.map +1 -1
  58. package/dist/System.d.ts +149 -52
  59. package/dist/System.d.ts.map +1 -1
  60. package/dist/System.js +52 -6
  61. package/dist/System.js.map +1 -1
  62. package/dist/index.d.ts +8 -7
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +8 -7
  65. package/dist/index.js.map +1 -1
  66. package/dist/internal/cells.d.ts +3 -3
  67. package/dist/internal/cells.d.ts.map +1 -1
  68. package/dist/internal/cells.js +9 -11
  69. package/dist/internal/cells.js.map +1 -1
  70. package/dist/internal/debug.d.ts +6 -0
  71. package/dist/internal/debug.d.ts.map +1 -1
  72. package/dist/internal/debug.js +95 -1
  73. package/dist/internal/debug.js.map +1 -1
  74. package/dist/internal/game.d.ts.map +1 -1
  75. package/dist/internal/game.js +9 -2
  76. package/dist/internal/game.js.map +1 -1
  77. package/dist/internal/order.d.ts +10 -0
  78. package/dist/internal/order.d.ts.map +1 -0
  79. package/dist/internal/order.js +120 -0
  80. package/dist/internal/order.js.map +1 -0
  81. package/dist/internal/queries.js +2 -2
  82. package/dist/internal/queries.js.map +1 -1
  83. package/package.json +1 -1
  84. package/src/Brand.ts +9 -4
  85. package/src/Clock.ts +311 -0
  86. package/src/Command.ts +271 -73
  87. package/src/Debug.ts +111 -10
  88. package/src/Decode.ts +1 -1
  89. package/src/Descriptor.ts +37 -27
  90. package/src/Entity.ts +15 -5
  91. package/src/EntityScope.ts +6 -0
  92. package/src/HostClock.ts +64 -0
  93. package/src/Inspector.ts +7 -2
  94. package/src/Machine.ts +13 -3
  95. package/src/Query.ts +41 -33
  96. package/src/Runtime.ts +273 -91
  97. package/src/Schedule.ts +380 -13
  98. package/src/Schema.ts +70 -41
  99. package/src/System.ts +174 -88
  100. package/src/index.ts +8 -7
  101. package/src/internal/cells.ts +10 -12
  102. package/src/internal/debug.ts +104 -4
  103. package/src/internal/game.ts +11 -3
  104. package/src/internal/order.ts +140 -0
  105. package/src/internal/queries.ts +3 -3
  106. package/dist/Definition.d.ts +0 -99
  107. package/dist/Definition.d.ts.map +0 -1
  108. package/dist/Definition.js +0 -68
  109. package/dist/Definition.js.map +0 -1
  110. package/dist/Feature.d.ts +0 -106
  111. package/dist/Feature.d.ts.map +0 -1
  112. package/dist/Feature.js +0 -87
  113. package/dist/Feature.js.map +0 -1
  114. package/dist/Fx.d.ts +0 -128
  115. package/dist/Fx.d.ts.map +0 -1
  116. package/dist/Fx.js +0 -112
  117. package/dist/Fx.js.map +0 -1
  118. package/dist/internal/definition.d.ts +0 -17
  119. package/dist/internal/definition.d.ts.map +0 -1
  120. package/dist/internal/definition.js +0 -25
  121. package/dist/internal/definition.js.map +0 -1
  122. package/src/Definition.ts +0 -106
  123. package/src/Feature.ts +0 -241
  124. package/src/Fx.ts +0 -176
  125. 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"
@@ -74,9 +75,10 @@ import * as Relation from "./Relation.ts"
74
75
  import type * as Requirement from "./Requirement.ts"
75
76
  import * as Result from "./Result.ts"
76
77
  import * as Schedule from "./Schedule.ts"
78
+ import * as OrderInternal from "./internal/order.ts"
77
79
  import * as EntityScopeModule from "./EntityScope.ts"
78
80
  import * as Snapshot from "./Snapshot.ts"
79
- import type { ExecutableScheduleDefinition } from "./Schedule.ts"
81
+ import type { AnyScheduleOrder, ExecutableScheduleDefinition } from "./Schedule.ts"
80
82
  import type { Registry, Schema } from "./Schema.ts"
81
83
  import type {
82
84
  DespawnedReadView,
@@ -179,10 +181,13 @@ export type RuntimeConstructionError<S extends Schema.Any, Provided> = Simplify<
179
181
  }>
180
182
 
181
183
  /**
182
- * A runtime plus the `debug` handle when it was made with `debug: true`.
184
+ * A runtime plus the `debug` handle when it was made with `debug: { hostClock }`
185
+ * (optional when the option may be `undefined`).
183
186
  */
184
187
  export type WithDebug<R, Enabled, S extends Schema.Any, Root> =
185
- [Enabled] extends [Debug.Option] ? R & Debug.Member<S, Root> : R
188
+ [Enabled] extends [undefined] ? R
189
+ : [Enabled] extends [Debug.Option] ? R & Debug.Member<S, Root>
190
+ : R & Partial<Debug.Member<S, Root>>
186
191
 
187
192
  /**
188
193
  * The runtime returned by `make`, wrapped in a `Result` exactly when a
@@ -367,23 +372,23 @@ type RequirementErrorsFor<
367
372
  : never
368
373
 
369
374
  type RequirementErrorsOfSchedule<
370
- Selected extends ExecutableScheduleDefinition<any, any, any, any, any>,
375
+ Selected extends ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>,
371
376
  Services extends Record<string, unknown>,
372
377
  Resources extends object,
373
378
  Machines extends object
374
- > = Selected extends ExecutableScheduleDefinition<infer S, any, any, any, any>
379
+ > = Selected extends ExecutableScheduleDefinition<infer S, any, any, any, any, AnyScheduleOrder>
375
380
  ? RequirementErrorsFor<Requirement.Of<Selected>, S, Services, Resources, Machines>
376
381
  : never
377
382
 
378
383
  export type ValidateSchedules<
379
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
384
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
380
385
  Services extends Record<string, unknown>,
381
386
  Resources extends object,
382
387
  Machines extends object
383
388
  > = {
384
389
  readonly [K in keyof Schedules]:
385
390
  Schedules[K] & ValidateSchedule<
386
- Extract<Schedules[K], ExecutableScheduleDefinition<any, any, any, any, any>>,
391
+ Extract<Schedules[K], ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
387
392
  Services,
388
393
  Resources,
389
394
  Machines
@@ -391,7 +396,7 @@ export type ValidateSchedules<
391
396
  }
392
397
 
393
398
  export type ValidateScheduleArray<
394
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
399
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
395
400
  Services extends Record<string, unknown>,
396
401
  Resources extends object,
397
402
  Machines extends object
@@ -402,18 +407,23 @@ export type ValidateScheduleArray<
402
407
  }
403
408
 
404
409
  type ValidateScheduleArgs<
405
- Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any>>,
410
+ Schedules extends ReadonlyArray<ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>>,
406
411
  Services extends Record<string, unknown>,
407
412
  Resources extends object,
408
413
  Machines extends object
409
414
  > = [RequirementErrorsOfSchedule<NoInfer<Schedules[number]>, Services, Resources, Machines>] extends [never]
410
- ? Schedules
415
+ ? [Schedule.TickOrderErrors<NoInfer<Schedules>>] extends [never]
416
+ ? Schedules
417
+ : Schedules & {
418
+ /** `Game.System.thisTick` reads whose writers do not all run earlier in this call. */
419
+ readonly __fixTickOrder__: Schedule.TickOrderErrors<NoInfer<Schedules>>
420
+ }
411
421
  : Schedules & {
412
422
  readonly __fixRuntimeRequirements__: RequirementErrorsOfSchedule<NoInfer<Schedules[number]>, Services, Resources, Machines>
413
423
  }
414
424
 
415
425
  type ValidateSchedule<
416
- Schedule extends ExecutableScheduleDefinition<any, any, any, any, any>,
426
+ Schedule extends ExecutableScheduleDefinition<any, any, any, any, any, AnyScheduleOrder>,
417
427
  Services extends Record<string, unknown>,
418
428
  Resources extends object,
419
429
  Machines extends object
@@ -432,7 +442,13 @@ type RequirementErrorsOfInspector<
432
442
  ? RequirementErrorsFor<Needs, Spec["schema"], Services, Resources, Machines>
433
443
  : never
434
444
 
435
- type ValidateInspector<
445
+ /**
446
+ * Resolves to `unknown` when the runtime provides every resource, service,
447
+ * and machine the inspector reads, otherwise to an object naming each
448
+ * missing or incompatible one. `runtime.inspect(...)` uses it; tools that
449
+ * run inspectors on a runtime they receive (devtools invariants) reuse it.
450
+ */
451
+ export type ValidateInspector<
436
452
  Selected extends Inspector.Inspector.Any,
437
453
  Services extends Record<string, unknown>,
438
454
  Resources extends object,
@@ -451,6 +467,15 @@ export interface MissingRuntimeRequirement {
451
467
  readonly name: string
452
468
  }
453
469
 
470
+ /**
471
+ * Returned by `tryTick` when a `Game.System.thisTick` read has a writer that
472
+ * runs after it in the schedule. Nothing ran.
473
+ */
474
+ export interface TickOrderViolation {
475
+ readonly kind: "TickOrderViolation"
476
+ readonly issues: ReadonlyArray<Schedule.OrderIssue>
477
+ }
478
+
454
479
  /** Explicit failure returned by the dynamic schedule execution path. */
455
480
  export interface MissingRuntimeRequirements {
456
481
  readonly kind: "MissingRuntimeRequirements"
@@ -584,20 +609,22 @@ export interface Runtime<
584
609
  * system's ECS writes are rolled back and earlier systems stay committed.
585
610
  */
586
611
  readonly tick: {
587
- <const Schedules extends ReadonlyArray<ExecutableScheduleDefinition<S, any, Root, any, any>>>(
612
+ <const Schedules extends ReadonlyArray<ExecutableScheduleDefinition<S, any, Root, any, any, AnyScheduleOrder>>>(
588
613
  ...schedules: ValidateScheduleArgs<Schedules, Services, Resources, Machines>
589
614
  ): Result.Result<void, Schedule.FailureOf<Schedules[number]>>
590
615
  }
591
616
  /**
592
- * Runs a schedule whose exact requirements are not statically known, for
593
- * example one loaded through a plugin boundary.
617
+ * Runs a schedule whose exact requirements are not statically known: for
618
+ * tools that hold schedules with erased types, such as devtools sessions.
619
+ * Games use `tick(...)`, which rejects missing requirements at compile time.
594
620
  *
595
621
  * Requirements are checked against current provisioning first and missing
596
- * ones are returned as data.
622
+ * ones are returned as data, then `Game.System.thisTick` reads are checked
623
+ * against the schedule's order (`TickOrderViolation`).
597
624
  */
598
- readonly tryTick: <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any>>(
625
+ readonly tryTick: <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>(
599
626
  schedule: Selected
600
- ) => Result.Result<void, MissingRuntimeRequirements | Schedule.FailureOf<Selected>>
627
+ ) => Result.Result<void, MissingRuntimeRequirements | TickOrderViolation | Schedule.FailureOf<Selected>>
601
628
  /**
602
629
  * Captures the world as plain data: entities with their ids, components,
603
630
  * relation edges, resources, and committed machine values.
@@ -678,7 +705,7 @@ const makeValidatedRuntime = <
678
705
  readonly resources: Partial<Record<string, unknown>>
679
706
  readonly machines: RuntimeMachines<any> | undefined
680
707
  readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
681
- readonly debug: boolean
708
+ readonly debug: Debug.Option | undefined
682
709
  }): Runtime<S, Services, Resources, Root, Machines> => {
683
710
  const world = makeWorld(options.schema, streamCapacity)
684
711
  const queries = makeQueryEngine(world)
@@ -723,12 +750,12 @@ const makeValidatedRuntime = <
723
750
  const pendingCommands: Array<Command.DeferredCommand<S>> = []
724
751
 
725
752
  /**
726
- * Debug state. With `debug: true`, `pendingOrigins` names the system that
753
+ * Debug state. With `debug`, `pendingOrigins` names the system that
727
754
  * queued each pending command, in step with `pendingCommands`. Tracing is
728
755
  * on only while `observe` listeners are registered; untraced runs pay one
729
756
  * boolean check at each system, schedule, and marker boundary.
730
757
  */
731
- const debugEnabled = options.debug
758
+ const debugEnabled = options.debug !== undefined
732
759
  const pendingOrigins: Array<string> = []
733
760
  const traceListeners = new Set<(event: Debug.TraceEvent) => void>()
734
761
  let tracing = false
@@ -740,7 +767,9 @@ const makeValidatedRuntime = <
740
767
  const scheduleNames = new Map<object, string>()
741
768
  const anonymousScheduleNames = new WeakMap<object, string>()
742
769
  let anonymousScheduleCount = 0
743
- const now = (): number => performance.now()
770
+ // Only a debug runtime times anything; the clock is the one it was given.
771
+ const hostClock: HostClockModule.HostClock = options.debug?.hostClock ?? { now: () => 0 }
772
+ const now = (): number => hostClock.now()
744
773
  const emit = (event: Debug.TraceEvent): void => {
745
774
  for (const listener of traceListeners) listener(event)
746
775
  }
@@ -797,29 +826,76 @@ const makeValidatedRuntime = <
797
826
  ])
798
827
  }
799
828
 
829
+ /**
830
+ * With `debug`, the relations commands have tried to relate or
831
+ * reorder, with the systems that queued those commands, for the
832
+ * `relation-failure-never-read` lint. `commandOrigin` names the system whose
833
+ * command is being applied.
834
+ */
835
+ const relationMutations = new Map<string, Set<string>>()
836
+ let commandOrigin = "(unknown)"
837
+ /**
838
+ * With `debug`, what each system's applied commands did, as
839
+ * `kind:name` (`spawns:Enemy`, `despawns:Enemy` for every component the
840
+ * despawned entity had), and the systems that ran: for the
841
+ * `command-permission-unused` lint.
842
+ */
843
+ const commandUses = new Map<string, Set<string>>()
844
+ const ranSystems = new Set<string>()
845
+ const noteCommand = (kind: string, name: string): void => {
846
+ let uses = commandUses.get(commandOrigin)
847
+ if (uses === undefined) {
848
+ uses = new Set()
849
+ commandUses.set(commandOrigin, uses)
850
+ }
851
+ uses.add(`${kind}:${name}`)
852
+ }
853
+ const noteRelationMutation = (relation: Relation.Relation.Any): void => {
854
+ let systems = relationMutations.get(relation.name)
855
+ if (systems === undefined) {
856
+ systems = new Set()
857
+ relationMutations.set(relation.name, systems)
858
+ }
859
+ systems.add(commandOrigin)
860
+ }
861
+
800
862
  /**
801
863
  * World adapter used by deferred commands.
802
864
  */
803
865
  const commandWorld: Command.InternalWorld<S> = {
804
866
  spawnEntity(id, components) {
867
+ if (debugEnabled) for (const [descriptor] of components) noteCommand("spawns", descriptor.name)
805
868
  world.spawnEntity(id, components)
806
869
  },
807
870
  destroyEntity(id) {
871
+ if (debugEnabled) {
872
+ const record = world.records.get(id)
873
+ record?.values.forEach((value, ordinal) => {
874
+ if (value !== ABSENT) noteCommand("despawns", world.descriptorAt(ordinal).name)
875
+ })
876
+ }
808
877
  world.destroyEntity(id)
809
878
  },
810
879
  assignEntityScope(id, scope) {
811
880
  world.assignEntityScope(id, scope.key)
812
881
  },
813
882
  destroyEntityScope(scope) {
883
+ if (debugEnabled) noteCommand("despawnsScope", scope.name)
814
884
  world.destroyEntityScope(scope.key)
815
885
  },
816
886
  removeComponent(id, descriptor) {
887
+ if (debugEnabled) noteCommand("removes", descriptor.name)
817
888
  world.removeComponent(id, descriptor)
818
889
  },
819
890
  writeComponent(id, descriptor, value) {
891
+ if (debugEnabled) noteCommand("inserts", descriptor.name)
820
892
  world.writeComponent(id, descriptor, value)
821
893
  },
822
894
  tryRelate(id, relation, target) {
895
+ if (debugEnabled) {
896
+ noteRelationMutation(relation)
897
+ noteCommand("relates", relation.name)
898
+ }
823
899
  const result = world.tryRelate(id, relation, target)
824
900
  if (!result.ok) {
825
901
  appendRelationFailure(relation, "relate", id, target, result.error)
@@ -827,9 +903,14 @@ const makeValidatedRuntime = <
827
903
  return result
828
904
  },
829
905
  unrelate(id, relation) {
906
+ if (debugEnabled) noteCommand("relates", relation.name)
830
907
  world.unrelate(id, relation)
831
908
  },
832
909
  reorderChildren(id, relation, children) {
910
+ if (debugEnabled) {
911
+ noteRelationMutation(relation)
912
+ noteCommand("relates", relation.name)
913
+ }
833
914
  const result = world.reorderChildren(id, relation, children.map((child) => child))
834
915
  if (!result.ok) {
835
916
  const targetId = result.error._tag === "MissingChildEntity"
@@ -882,12 +963,6 @@ const makeValidatedRuntime = <
882
963
 
883
964
  const makeLookup = (reader: ReaderState): LookupApi<S, Root> => {
884
965
  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
966
  related: relatedTarget,
892
967
  relatedSources(target, relation) {
893
968
  if (!world.records.has(target)) {
@@ -895,19 +970,6 @@ const makeValidatedRuntime = <
895
970
  }
896
971
  return Result.success(world.relatedSourceIds(relation, target).map(entityId))
897
972
  },
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
973
  parent: relatedTarget,
912
974
  ancestors(target, relation) {
913
975
  if (!world.records.has(target)) {
@@ -946,20 +1008,6 @@ const makeValidatedRuntime = <
946
1008
  }
947
1009
  return Result.success(descendants)
948
1010
  },
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
1011
  root(target, relation) {
964
1012
  if (!world.records.has(target)) {
965
1013
  return Result.failure(Relation.missingEntityError(target))
@@ -976,13 +1024,32 @@ const makeValidatedRuntime = <
976
1024
  return lookup
977
1025
  }
978
1026
 
979
- const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState): QueryHandle<S, Q> => {
1027
+ const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryHandle<S, Q> => {
980
1028
  const each = () => queries.each(query, reader.since) as ReadonlyArray<QueryMatch<S, Q>>
1029
+ const matching = (ids: ReadonlyArray<Entity.EntityId<S, Root>>): Array<QueryMatch<S, Q>> => {
1030
+ const matches: Array<QueryMatch<S, Q>> = []
1031
+ for (const id of ids) {
1032
+ const resolved = resolve(id, query, reader)
1033
+ if (resolved.ok) matches.push(resolved.value)
1034
+ }
1035
+ return matches
1036
+ }
981
1037
  return {
982
1038
  each,
983
1039
  get(target) {
984
1040
  return resolve(target, query, reader)
985
1041
  },
1042
+ getHandle(handle) {
1043
+ return resolve(handle as Entity.Handle<Root, any>, query, reader)
1044
+ },
1045
+ children(target, relation) {
1046
+ if (!world.records.has(target)) return Result.failure(Relation.missingEntityError(target))
1047
+ return Result.success(matching(world.relatedSourceIds(relation, target).map(entityId)))
1048
+ },
1049
+ descendants(target, relation, options) {
1050
+ const ids = lookup.descendants(target, relation, options)
1051
+ return ids.ok ? Result.success(matching(ids.value)) : ids
1052
+ },
986
1053
  single() {
987
1054
  const matches = each()
988
1055
  if (matches.length === 0) {
@@ -1115,6 +1182,9 @@ const makeValidatedRuntime = <
1115
1182
  return {
1116
1183
  all() {
1117
1184
  return world.removedSince(ordinal, reader.since).map(entityId)
1185
+ },
1186
+ lagged() {
1187
+ return world.removedLagged(ordinal, reader.since, reader.registeredAt)
1118
1188
  }
1119
1189
  }
1120
1190
  }
@@ -1122,6 +1192,9 @@ const makeValidatedRuntime = <
1122
1192
  const makeDespawnedReadView = (reader: ReaderState): DespawnedReadView<S, Root> => ({
1123
1193
  all() {
1124
1194
  return world.despawnedSince(reader.since).map(entityId)
1195
+ },
1196
+ lagged() {
1197
+ return world.despawnedLagged(reader.since, reader.registeredAt)
1125
1198
  }
1126
1199
  })
1127
1200
 
@@ -1199,12 +1272,13 @@ const makeValidatedRuntime = <
1199
1272
  * views the implementation is allowed to see. Views read live storage, so
1200
1273
  * one context is built per system and reused for every run.
1201
1274
  */
1202
- const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState): SystemContext<any> => {
1275
+ const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState, queue: Command.CommandQueue<S, Root>): SystemContext<any> => {
1203
1276
  const spec = system.spec
1277
+ const lookup = makeLookup(reader)
1204
1278
  return {
1205
1279
  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),
1280
+ queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup)),
1281
+ lookup,
1208
1282
  resources: mapRecord(spec.resources as Record<string, any>, (access) =>
1209
1283
  access.mode === "read"
1210
1284
  ? Cells.storeRead(resources, access.descriptor.key)
@@ -1222,12 +1296,15 @@ const makeValidatedRuntime = <
1222
1296
  relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => makeRelationFailureReadView(access.relation, reader)),
1223
1297
  services: mapRecord(spec.services as Record<string, any>, (access) =>
1224
1298
  providedServices[access.descriptor.name as keyof Services]),
1225
- commands: Command.makeCommands<S, Root>(() => world.allocateEntity() as Entity.EntityId<S, Root>)
1299
+ // The queue serves every declared permission set; the spec's types narrow it.
1300
+ commands: queue as unknown
1226
1301
  } as SystemContext<any>
1227
1302
  }
1228
1303
 
1229
1304
  interface SystemSlot {
1230
1305
  readonly context: SystemContext<any>
1306
+ /** The system's command queue (its context's `commands`, unchecked). */
1307
+ readonly commands: Command.CommandQueue<S, Root>
1231
1308
  readonly reader: ReaderState
1232
1309
  }
1233
1310
 
@@ -1316,7 +1393,7 @@ const makeValidatedRuntime = <
1316
1393
  system: system.name,
1317
1394
  outcome,
1318
1395
  ...(outcome === "ok" ? {} : { error }),
1319
- ms: now() - started,
1396
+ ms: HostClockModule.elapsed(started, now()),
1320
1397
  writes,
1321
1398
  resources: resourceWrites,
1322
1399
  events: emitted,
@@ -1383,7 +1460,8 @@ const makeValidatedRuntime = <
1383
1460
  let slot = slots.get(stateKey(system))
1384
1461
  if (!slot) {
1385
1462
  const reader: ReaderState = { since: 0, lastRun: 0, streamSince: 0, streamLastRun: 0, registeredAt: world.currentTick() }
1386
- slot = { context: makeContext(system, reader), reader }
1463
+ const commands = Command.makeCommands<S, Root>(() => world.allocateEntity() as Entity.EntityId<S, Root>)
1464
+ slot = { context: makeContext(system, reader, commands), commands, reader }
1387
1465
  slots.set(stateKey(system), slot)
1388
1466
  if (holdsStreams) registerStreamReader(system, slot.reader)
1389
1467
  const make = system.spec.local as (() => unknown) | undefined
@@ -1422,7 +1500,8 @@ const makeValidatedRuntime = <
1422
1500
  return succeeded
1423
1501
  }
1424
1502
  }
1425
- const { context, reader } = slotOf(system, true)
1503
+ const { context, commands, reader } = slotOf(system, true)
1504
+ if (debugEnabled) ranSystems.add(system.name)
1426
1505
  // Changes stamped after the previous completed run are visible to this run.
1427
1506
  reader.since = reader.lastRun
1428
1507
  reader.streamSince = reader.streamLastRun
@@ -1431,23 +1510,21 @@ const makeValidatedRuntime = <
1431
1510
  beginSystemTransaction()
1432
1511
  let outcome: Result.Result<unknown, unknown>
1433
1512
  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)
1513
+ // A system that returns nothing cannot fail; otherwise it returned a `Result`.
1514
+ outcome = system.run(context) ?? succeeded
1438
1515
  } catch (defect) {
1439
- if (tracing) emit(traceSystem(system, reader, thisRun, started, "defect", defect, context.commands.flush()))
1516
+ if (tracing) emit(traceSystem(system, reader, thisRun, started, "defect", defect, commands.flush()))
1440
1517
  rollbackSystemTransaction()
1441
- context.commands.flush()
1518
+ commands.flush()
1442
1519
  throw defect
1443
1520
  }
1444
1521
  if (!outcome.ok) {
1445
- if (tracing) emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, context.commands.flush()))
1522
+ if (tracing) emit(traceSystem(system, reader, thisRun, started, "failed", outcome.error, commands.flush()))
1446
1523
  rollbackSystemTransaction()
1447
- context.commands.flush()
1524
+ commands.flush()
1448
1525
  return Result.failure({ kind: "SystemFailure", system: system.name, error: outcome.error })
1449
1526
  }
1450
- const queued = context.commands.flush()
1527
+ const queued = commands.flush()
1451
1528
  const traced = tracing ? traceSystem(system, reader, thisRun, started, "ok", undefined, queued) : undefined
1452
1529
  commitSystemTransaction()
1453
1530
  // A failed run leaves `lastRun` unchanged, so the next run sees the same changes again.
@@ -1469,8 +1546,15 @@ const makeValidatedRuntime = <
1469
1546
  const origins = pendingOrigins.splice(0, pendingOrigins.length)
1470
1547
  world.advanceTick()
1471
1548
  if (!tracing) {
1472
- for (const command of commands) {
1473
- command.apply(commandWorld)
1549
+ if (!debugEnabled) {
1550
+ for (const command of commands) {
1551
+ command.apply(commandWorld)
1552
+ }
1553
+ return
1554
+ }
1555
+ for (let index = 0; index < commands.length; index++) {
1556
+ commandOrigin = origins[index] ?? "(unknown)"
1557
+ commands[index]!.apply(commandWorld)
1474
1558
  }
1475
1559
  return
1476
1560
  }
@@ -1479,6 +1563,7 @@ const makeValidatedRuntime = <
1479
1563
  const command = commands[index]!
1480
1564
  const effects: Array<Debug.Effect> = []
1481
1565
  commandEffects = effects
1566
+ commandOrigin = origins[index] ?? "(unknown)"
1482
1567
  try {
1483
1568
  command.apply(commandWorld)
1484
1569
  } finally {
@@ -1503,7 +1588,7 @@ const makeValidatedRuntime = <
1503
1588
  } finally {
1504
1589
  schedulePath.pop()
1505
1590
  }
1506
- emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: now() - started })
1591
+ emit({ type: "schedule.end", frame: frameCount, schedule: scheduleLabel, ok: result.ok, ms: HostClockModule.elapsed(started, now()) })
1507
1592
  return result
1508
1593
  }
1509
1594
 
@@ -1661,7 +1746,7 @@ const makeValidatedRuntime = <
1661
1746
  * Runs one schedule. A failing system stops the schedule; systems that
1662
1747
  * already succeeded stay committed, and their pending work stays pending.
1663
1748
  */
1664
- const runScheduleUnsafe = (schedule: ExecutableScheduleDefinition<S, any, any, any, any>): Result.Result<void, SystemFailure> => {
1749
+ const runScheduleUnsafe = (schedule: ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>): Result.Result<void, SystemFailure> => {
1665
1750
  changedMachines = new Set()
1666
1751
  return tracing
1667
1752
  ? traceSchedule(scheduleName(schedule), () => runSteps(schedule.steps))
@@ -1670,6 +1755,13 @@ const makeValidatedRuntime = <
1670
1755
 
1671
1756
  const eventsByKey = new Set((Object.values(options.schema.events) as ReadonlyArray<Descriptor.Any>).map((descriptor) => descriptor.key))
1672
1757
  const externallyWrittenEvents = new Set<string>()
1758
+ /** `debug.observeHost` listeners: ticks and sends, without tracing. Only a debug runtime registers any. */
1759
+ const hostListeners = new Set<(event: Debug.HostEvent<S, Root>) => void>()
1760
+ const emitHost = (event: Debug.HostEvent<S, Root>): void => {
1761
+ for (const listener of hostListeners) listener(event)
1762
+ }
1763
+ /** Events declared as sent by the host (`debug.declareHostEvents`). */
1764
+ let hostEvents = new Set<string>()
1673
1765
 
1674
1766
  const send = (descriptor: Descriptor<"event", string, any>, value: unknown): void => {
1675
1767
  if (ticking) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
@@ -1679,6 +1771,7 @@ const makeValidatedRuntime = <
1679
1771
  // Bound host traffic even when it sends repeatedly without ticking.
1680
1772
  events.trim(0)
1681
1773
  externallyWrittenEvents.add(descriptor.name)
1774
+ if (hostListeners.size > 0) emitHost({ type: "send", event: descriptor.name, value })
1682
1775
  if (tracing) emit({ type: "runtime.sent", frame: frameCount, tick: published, event: descriptor.name, value })
1683
1776
  }
1684
1777
 
@@ -1691,21 +1784,91 @@ const makeValidatedRuntime = <
1691
1784
  relationFailures.trim(boundary)
1692
1785
  }
1693
1786
 
1694
- const tickUnsafe = (schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any>>): Result.Result<void, SystemFailure> => {
1787
+ /**
1788
+ * With debug: the last failed tick result, until its `ok` is read. The
1789
+ * result handed back is a copy whose `ok` getter marks it read.
1790
+ */
1791
+ let unread: { read: boolean; readonly event: Omit<Debug.FailureIgnoredEvent, "type" | "when"> } | undefined
1792
+ const reportUnread = (when: Debug.FailureIgnoredEvent["when"]): void => {
1793
+ const pending = unread
1794
+ unread = undefined
1795
+ if (pending === undefined || pending.read) return
1796
+ const event: Debug.FailureIgnoredEvent = { type: "failure.ignored", ...pending.event, when }
1797
+ emit(event)
1798
+ emitHost(event)
1799
+ }
1800
+ const watchResult = <R extends Result.Result<void, unknown>>(
1801
+ result: R,
1802
+ schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>
1803
+ ): R => {
1804
+ if (!debugEnabled || result.ok) return result
1805
+ const error = result.error
1806
+ const system = typeof error === "object" && error !== null && (error as { readonly kind?: unknown }).kind === "SystemFailure"
1807
+ ? (error as SystemFailure).system
1808
+ : undefined
1809
+ const names = schedules.flatMap((schedule) => schedule.steps.filter(Schedule.isSystemStep).map((step) => (step as SystemDefinition<any, any, any>).name))
1810
+ const at = system === undefined ? -1 : names.indexOf(system)
1811
+ const holder = system === undefined
1812
+ ? schedules[0]
1813
+ : schedules.find((schedule) => schedule.steps.some((step) => Schedule.isSystemStep(step) && (step as SystemDefinition<any, any, any>).name === system)) ?? schedules[0]
1814
+ const entry = {
1815
+ read: false,
1816
+ event: {
1817
+ frame: frameCount,
1818
+ schedule: holder === undefined ? "(none)" : scheduleName(holder),
1819
+ system,
1820
+ error,
1821
+ skipped: [...new Set(names.slice(at + 1))]
1822
+ }
1823
+ }
1824
+ unread = entry
1825
+ return {
1826
+ get ok() {
1827
+ entry.read = true
1828
+ return false
1829
+ },
1830
+ error
1831
+ } as unknown as R
1832
+ }
1833
+
1834
+ const tickUnsafe = (schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>): Result.Result<void, SystemFailure> => {
1695
1835
  if (ticking) throw new Error("runtime.tick cannot run inside another tick")
1836
+ if (debugEnabled) reportUnread("next-tick")
1696
1837
  ticking = true
1697
1838
  try {
1698
1839
  advanceFrame()
1699
- for (const schedule of schedules) {
1700
- const result = runScheduleUnsafe(schedule)
1701
- if (!result.ok) return result
1702
- }
1703
- return succeeded
1840
+ return watchResult(observedTick(schedules, () => {
1841
+ for (const schedule of schedules) {
1842
+ const result = runScheduleUnsafe(schedule)
1843
+ if (!result.ok) return result
1844
+ }
1845
+ return succeeded
1846
+ }), schedules)
1704
1847
  } finally {
1705
1848
  ticking = false
1706
1849
  }
1707
1850
  }
1708
1851
 
1852
+ /** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
1853
+ const observedTick = (
1854
+ schedules: ReadonlyArray<ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>,
1855
+ run: () => Result.Result<void, SystemFailure>
1856
+ ): Result.Result<void, SystemFailure> => {
1857
+ if (hostListeners.size === 0) return run()
1858
+ emitHost({ type: "tick.start", schedules, frame: frameCount })
1859
+ let result: Result.Result<void, SystemFailure>
1860
+ try {
1861
+ result = run()
1862
+ } catch (defect) {
1863
+ emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect })
1864
+ throw defect
1865
+ }
1866
+ emitHost(result.ok
1867
+ ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
1868
+ : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error })
1869
+ return result
1870
+ }
1871
+
1709
1872
  const hasRequirement = (requirement: Requirement.RequirementValue): boolean => {
1710
1873
  switch (requirement.kind) {
1711
1874
  case "service":
@@ -1717,22 +1880,28 @@ const makeValidatedRuntime = <
1717
1880
  }
1718
1881
  }
1719
1882
 
1720
- const tryRunSchedule = <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any>>(
1883
+ const tryRunSchedule = <const Selected extends ExecutableScheduleDefinition<S, any, any, any, any, AnyScheduleOrder>>(
1721
1884
  schedule: Selected
1722
- ): Result.Result<void, MissingRuntimeRequirements | Schedule.FailureOf<Selected>> => {
1885
+ ): Result.Result<void, MissingRuntimeRequirements | TickOrderViolation | Schedule.FailureOf<Selected>> => {
1886
+ if (debugEnabled) reportUnread("next-tick")
1723
1887
  const missing = schedule.requirements
1724
1888
  .filter((requirement) => !hasRequirement(requirement))
1725
1889
  .map(({ kind, name }) => ({ kind, name }))
1726
1890
 
1727
1891
  if (missing.length > 0) {
1728
- return Result.failure({
1729
- kind: "MissingRuntimeRequirements",
1892
+ return watchResult(Result.failure({
1893
+ kind: "MissingRuntimeRequirements" as const,
1730
1894
  requirements: missing
1731
- })
1895
+ }), [schedule])
1896
+ }
1897
+
1898
+ const issues = OrderInternal.tickOrderIssues(schedule.steps)
1899
+ if (issues.length > 0) {
1900
+ return watchResult(Result.failure({ kind: "TickOrderViolation" as const, issues }), [schedule])
1732
1901
  }
1733
1902
 
1734
1903
  advanceFrame()
1735
- return runScheduleUnsafe(schedule) as Result.Result<void, Schedule.FailureOf<Selected>>
1904
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
1736
1905
  }
1737
1906
 
1738
1907
  /**
@@ -2041,12 +2210,18 @@ const makeValidatedRuntime = <
2041
2210
  scheduleNames.clear()
2042
2211
  for (const [name, schedule] of Object.entries(named)) scheduleNames.set(schedule, name)
2043
2212
  },
2213
+ declareHostEvents(declared) {
2214
+ hostEvents = new Set(declared.map((descriptor) => descriptor.name))
2215
+ },
2044
2216
  describe: () => DebugInternal.describe({
2045
2217
  schema: options.schema,
2046
2218
  machines: [...new Set(machinesByName.values())],
2047
2219
  currentMachine: (machine) => currentMachines.get(machine.key) as Machine.StateValue | undefined,
2048
2220
  providedServices: Object.keys(providedServices),
2049
- externallyWrittenEvents,
2221
+ externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
2222
+ relationMutations,
2223
+ commandUses,
2224
+ ranSystems,
2050
2225
  hasResource: (descriptor) => resources.has(descriptor.key),
2051
2226
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
2052
2227
  }),
@@ -2063,6 +2238,13 @@ const makeValidatedRuntime = <
2063
2238
  }
2064
2239
  }
2065
2240
  },
2241
+ observeHost(listener) {
2242
+ hostListeners.add(listener)
2243
+ return () => {
2244
+ hostListeners.delete(listener)
2245
+ }
2246
+ },
2247
+ checkIgnoredFailures: () => reportUnread("session-end"),
2066
2248
  streams,
2067
2249
  population: () => {
2068
2250
  const components: Record<string, number> = {}
@@ -2110,7 +2292,7 @@ export const make = <
2110
2292
  readonly resources?: Resources
2111
2293
  readonly machines?: ProvidedMachines
2112
2294
  readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
2113
- /** Attaches a `debug` handle to the runtime; see the `Debug` module. */
2295
+ /** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
2114
2296
  readonly debug?: Enabled
2115
2297
  }): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
2116
2298
  const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) =>
@@ -2128,7 +2310,7 @@ export const make = <
2128
2310
  resources: validated.value,
2129
2311
  machines: options.machines,
2130
2312
  machineDefinitions: options.machineDefinitions ?? [],
2131
- debug: options.debug === true
2313
+ debug: options.debug
2132
2314
  })
2133
2315
  return (fallible ? Result.success(runtime) : runtime) as never
2134
2316
  }