@typeonce/ritmo 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +3 -2
  2. package/dist/Clock.d.ts +19 -4
  3. package/dist/Clock.d.ts.map +1 -1
  4. package/dist/Clock.js +18 -2
  5. package/dist/Clock.js.map +1 -1
  6. package/dist/Command.d.ts +101 -45
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +22 -4
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Debug.d.ts +66 -8
  11. package/dist/Debug.d.ts.map +1 -1
  12. package/dist/Descriptor.d.ts +27 -9
  13. package/dist/Descriptor.d.ts.map +1 -1
  14. package/dist/Descriptor.js +13 -9
  15. package/dist/Descriptor.js.map +1 -1
  16. package/dist/Entity.d.ts +15 -8
  17. package/dist/Entity.d.ts.map +1 -1
  18. package/dist/Entity.js.map +1 -1
  19. package/dist/FixedLoop.d.ts +106 -0
  20. package/dist/FixedLoop.d.ts.map +1 -0
  21. package/dist/FixedLoop.js +97 -0
  22. package/dist/FixedLoop.js.map +1 -0
  23. package/dist/HostClock.d.ts +1 -1
  24. package/dist/HostClock.d.ts.map +1 -1
  25. package/dist/HostClock.js +2 -1
  26. package/dist/HostClock.js.map +1 -1
  27. package/dist/Machine.d.ts +4 -1
  28. package/dist/Machine.d.ts.map +1 -1
  29. package/dist/Machine.js +4 -1
  30. package/dist/Machine.js.map +1 -1
  31. package/dist/Query.d.ts +52 -10
  32. package/dist/Query.d.ts.map +1 -1
  33. package/dist/Query.js +2 -1
  34. package/dist/Query.js.map +1 -1
  35. package/dist/Relation.d.ts +5 -14
  36. package/dist/Relation.d.ts.map +1 -1
  37. package/dist/Relation.js +0 -32
  38. package/dist/Relation.js.map +1 -1
  39. package/dist/Runtime.d.ts +58 -15
  40. package/dist/Runtime.d.ts.map +1 -1
  41. package/dist/Runtime.js +288 -70
  42. package/dist/Runtime.js.map +1 -1
  43. package/dist/Schedule.d.ts +24 -15
  44. package/dist/Schedule.d.ts.map +1 -1
  45. package/dist/Schedule.js +2 -1
  46. package/dist/Schedule.js.map +1 -1
  47. package/dist/Schema.d.ts +98 -29
  48. package/dist/Schema.d.ts.map +1 -1
  49. package/dist/Schema.js +4 -4
  50. package/dist/Schema.js.map +1 -1
  51. package/dist/Snapshot.d.ts +76 -1
  52. package/dist/Snapshot.d.ts.map +1 -1
  53. package/dist/Snapshot.js +42 -1
  54. package/dist/Snapshot.js.map +1 -1
  55. package/dist/System.d.ts +68 -24
  56. package/dist/System.d.ts.map +1 -1
  57. package/dist/System.js.map +1 -1
  58. package/dist/index.d.ts +4 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +4 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/internal/cells.d.ts +2 -0
  63. package/dist/internal/cells.d.ts.map +1 -1
  64. package/dist/internal/cells.js +34 -8
  65. package/dist/internal/cells.js.map +1 -1
  66. package/dist/internal/debug.d.ts +3 -0
  67. package/dist/internal/debug.d.ts.map +1 -1
  68. package/dist/internal/debug.js +83 -15
  69. package/dist/internal/debug.js.map +1 -1
  70. package/dist/internal/fragments.d.ts +2 -6
  71. package/dist/internal/fragments.d.ts.map +1 -1
  72. package/dist/internal/fragments.js +25 -2
  73. package/dist/internal/fragments.js.map +1 -1
  74. package/dist/internal/game.d.ts +2 -0
  75. package/dist/internal/game.d.ts.map +1 -1
  76. package/dist/internal/game.js +16 -3
  77. package/dist/internal/game.js.map +1 -1
  78. package/dist/internal/queries.d.ts.map +1 -1
  79. package/dist/internal/queries.js +27 -18
  80. package/dist/internal/queries.js.map +1 -1
  81. package/dist/internal/relations.d.ts +10 -0
  82. package/dist/internal/relations.d.ts.map +1 -0
  83. package/dist/internal/relations.js +32 -0
  84. package/dist/internal/relations.js.map +1 -0
  85. package/dist/internal/streams.d.ts +2 -0
  86. package/dist/internal/streams.d.ts.map +1 -1
  87. package/dist/internal/streams.js +10 -0
  88. package/dist/internal/streams.js.map +1 -1
  89. package/dist/internal/world.d.ts +7 -1
  90. package/dist/internal/world.d.ts.map +1 -1
  91. package/dist/internal/world.js +53 -3
  92. package/dist/internal/world.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/Clock.ts +29 -4
  95. package/src/Command.ts +161 -67
  96. package/src/Debug.ts +61 -9
  97. package/src/Descriptor.ts +43 -17
  98. package/src/Entity.ts +16 -9
  99. package/src/FixedLoop.ts +218 -0
  100. package/src/HostClock.ts +2 -1
  101. package/src/Machine.ts +4 -1
  102. package/src/Query.ts +47 -10
  103. package/src/Relation.ts +5 -67
  104. package/src/Runtime.ts +345 -85
  105. package/src/Schedule.ts +31 -19
  106. package/src/Schema.ts +118 -35
  107. package/src/Snapshot.ts +90 -2
  108. package/src/System.ts +76 -23
  109. package/src/index.ts +4 -0
  110. package/src/internal/cells.ts +42 -10
  111. package/src/internal/debug.ts +84 -15
  112. package/src/internal/fragments.ts +23 -3
  113. package/src/internal/game.ts +19 -3
  114. package/src/internal/queries.ts +28 -2
  115. package/src/internal/relations.ts +52 -0
  116. package/src/internal/streams.ts +11 -0
  117. package/src/internal/world.ts +60 -4
package/src/System.ts CHANGED
@@ -461,7 +461,8 @@ export interface EventReadView<T> {
461
461
  * A writable event stream view.
462
462
  */
463
463
  export interface EventWriteView<T> {
464
- emit(value: T): void
464
+ /** Publishes one event. Takes the readonly shape reads return, so a read event can be emitted again. */
465
+ emit(value: import("./Query.ts").ReadonlyValue<T>): void
465
466
  }
466
467
 
467
468
  /**
@@ -482,6 +483,13 @@ export interface NextMachineWriteView<M extends Machine.StateMachine.Any = Machi
482
483
  * Queues `value`, committed at the next `applyStateTransitions(...)`. For a
483
484
  * sub-state it returns a `Result` that fails with `SubStateInactive` (and
484
485
  * queues nothing) while the parent is in another state.
486
+ *
487
+ * One value is queued per machine: a later `set` before the marker
488
+ * replaces an earlier one. A `set` from a transition schedule (`onEnter`,
489
+ * `onExit`, `onTransition`) is committed at the following marker, while a
490
+ * value queued before the current marker still commits at it. A sub-state
491
+ * value set while its parent is still in its state but leaves it at the
492
+ * same marker returns ok and is dropped with the sub-state.
485
493
  */
486
494
  set(value: Machine.StateMachine.Value<M>): Machine.SetResult<M>
487
495
  /** Like `set`, but committing the current value again is skipped (no exit or enter schedules). */
@@ -661,10 +669,47 @@ export interface RelationFailureReadView<
661
669
  * A runtime query handle exposed to system implementations.
662
670
  *
663
671
  * The handle is already typed from the query spec, so iterating it returns
664
- * strongly typed entity proofs and cells.
672
+ * strongly typed entity proofs and cells. A query with `added` / `changed`
673
+ * filters only iterates (`each`, `singleOptional`): its lookups would fail
674
+ * for every entity that did not change since this system's previous run, so
675
+ * declare a second query without filters to resolve entities.
665
676
  */
666
- export interface QueryHandle<S extends Schema.Any, Q extends Query.Any> {
677
+ export type QueryHandle<S extends Schema.Any, Q extends Query.Any> =
678
+ & QueryIteration<S, Q>
679
+ & (Q["filters"] extends readonly [] ? QueryLookups<S, Q> : FilteredQueryLookups)
680
+
681
+ /** What every query handle has: iteration over its current matches. */
682
+ export interface QueryIteration<S extends Schema.Any, Q extends Query.Any> {
667
683
  each(): ReadonlyArray<QueryMatch<S, Q>>
684
+ /**
685
+ * Returns a single match when zero or one entity satisfies the query.
686
+ *
687
+ * Use this when absence is acceptable but multiplicity is still a bug. Zero
688
+ * matches return `ok: true` with `value: undefined`; multiple matches remain
689
+ * an explicit `MultipleEntities` failure.
690
+ *
691
+ * @example
692
+ * ```ts
693
+ * const player = queries.player.singleOptional()
694
+ * if (!player.ok || !player.value) {
695
+ * return
696
+ * }
697
+ *
698
+ * player.value.data.position.set({ x: 0, y: 0 })
699
+ * ```
700
+ */
701
+ singleOptional(): Result.Result<QueryMatch<S, Q> | undefined, Query.MultipleEntitiesError>
702
+ }
703
+
704
+ type FilteredLookupMessage = "lookups need a query without added/changed filters: declare a second query for resolving entities"
705
+
706
+ /** The lookups of a query with filters, each replaced by the message naming the fix. */
707
+ export type FilteredQueryLookups = {
708
+ readonly [K in keyof QueryLookups<Schema.Any, Query.Any>]: (this: FilteredLookupMessage, ...args: ReadonlyArray<unknown>) => never
709
+ }
710
+
711
+ /** Lookups of one entity or one hierarchy through a query without `added` / `changed` filters. */
712
+ export interface QueryLookups<S extends Schema.Any, Q extends Query.Any> {
668
713
  /**
669
714
  * Retrieves the match for one specific entity id when it satisfies the query.
670
715
  *
@@ -711,24 +756,6 @@ export interface QueryHandle<S extends Schema.Any, Q extends Query.Any> {
711
756
  * Returns a single match when exactly one entity satisfies the query.
712
757
  */
713
758
  single(): Result.Result<QueryMatch<S, Q>, Query.SingleError>
714
- /**
715
- * Returns a single match when zero or one entity satisfies the query.
716
- *
717
- * Use this when absence is acceptable but multiplicity is still a bug. Zero
718
- * matches return `ok: true` with `value: undefined`; multiple matches remain
719
- * an explicit `MultipleEntities` failure.
720
- *
721
- * @example
722
- * ```ts
723
- * const player = queries.player.singleOptional()
724
- * if (!player.ok || !player.value) {
725
- * return
726
- * }
727
- *
728
- * player.value.data.position.set({ x: 0, y: 0 })
729
- * ```
730
- */
731
- singleOptional(): Result.Result<QueryMatch<S, Q> | undefined, Query.MultipleEntitiesError>
732
759
  }
733
760
 
734
761
  /**
@@ -859,10 +886,36 @@ export interface SystemAccessInput {
859
886
  */
860
887
  export type SystemAccessSpec = SystemAccessInput
861
888
 
862
- /** Rejects unknown system access categories at a bound constructor. */
889
+ /** Rejects unknown system access categories, and redundant resource and event slots, at a bound constructor. */
863
890
  export type ExactAccess<Access extends SystemAccessInput> = Access & {
864
891
  readonly [Key in Exclude<keyof Access, keyof SystemAccessInput>]: never
865
- }
892
+ } & RedundantSlotsFix<Access, "resources", "read"> & RedundantSlotsFix<Access, "events", never>
893
+
894
+ type SlotOf<A> = A extends { readonly mode: infer Mode extends string; readonly descriptor: { readonly name: infer Name extends string } } ? readonly [Mode, Name] : never
895
+
896
+ /**
897
+ * Slots of `Record` that repeat another slot: the same descriptor in the same
898
+ * mode, or in `ReadMode` next to any other slot of it (a write or changed
899
+ * view already reads).
900
+ */
901
+ type RedundantSlots<Record, ReadMode extends string> = {
902
+ readonly [K in keyof Record]: SlotOf<Record[K]> extends readonly [infer Mode, infer Name]
903
+ ? [Extract<SlotOf<Omit<Record, K>[keyof Omit<Record, K>]>, readonly [Mode extends ReadMode ? string : Mode, Name]>] extends [never] ? never : K
904
+ : never
905
+ }[keyof Record]
906
+
907
+ type RedundantSlotsFix<Access, Category extends "resources" | "events", ReadMode extends string> =
908
+ Access extends { readonly [C in Category]?: infer Record }
909
+ ? [RedundantSlots<Record, ReadMode>] extends [never]
910
+ ? unknown
911
+ : {
912
+ readonly [C in Category]: {
913
+ readonly [K in RedundantSlots<Record, ReadMode>]: {
914
+ readonly __fixSystem__: `${K & string} repeats ${Category extends "resources" ? "a resource" : "an event"} another slot of this system declares: keep one slot (a write or changed view reads too)`
915
+ }
916
+ }
917
+ }
918
+ : unknown
866
919
 
867
920
  type AccessField<
868
921
  Access,
package/src/index.ts CHANGED
@@ -33,6 +33,10 @@ export * as Entity from "./Entity.ts"
33
33
  * Entity lifetime scopes used by scene and level ownership patterns.
34
34
  */
35
35
  export * as EntityScope from "./EntityScope.ts"
36
+ /**
37
+ * Fixed-step timing for any host: clamping, catch-up limits, interpolation.
38
+ */
39
+ export * as FixedLoop from "./FixedLoop.ts"
36
40
  /**
37
41
  * The host's wall clock as an injected contract (`standard`, `test()`).
38
42
  */
@@ -63,38 +63,64 @@ const withTransition = (cell: WriteCellBase<unknown>, component: string) => Obje
63
63
  }
64
64
  })
65
65
 
66
+ /**
67
+ * A required slot is empty only when its match outlived the run that produced
68
+ * it: the entity was despawned, or lost the component. Reading would hand out
69
+ * the internal marker as a value, and writing would put the component back
70
+ * without the insert path, so both fail loudly instead.
71
+ */
72
+ const staleMatch = (record: EntityRecord, ordinal: number): Error =>
73
+ new Error(
74
+ `${record.nameAt(ordinal)} was used through a query match after ${record.alive ? "its entity lost it" : "its entity was despawned"}: `
75
+ + "a match is valid during the system run that produced it; keep the entity id or a handle and resolve it again with queries.x.get(id) or queries.x.getHandle(handle)"
76
+ )
77
+
78
+
79
+ /** Kept out of `get` so the hot read stays small enough to inline. */
80
+ const staleRead = (record: EntityRecord, ordinal: number): never => {
81
+ throw staleMatch(record, ordinal)
82
+ }
83
+
66
84
  class ComponentReadCell {
67
- readonly record: EntityRecord
85
+ // The record's values array, held directly: one load per read instead of
86
+ // two pays for the stale check. Records never replace their array.
87
+ readonly values: Array<unknown>
68
88
  readonly ordinal: number
89
+ readonly record: EntityRecord
69
90
 
70
91
  constructor(record: EntityRecord, ordinal: number) {
71
- this.record = record
92
+ this.values = record.values
72
93
  this.ordinal = ordinal
94
+ this.record = record
73
95
  }
74
96
 
75
97
  get(): unknown {
76
- return this.record.values[this.ordinal]
98
+ const value = this.values[this.ordinal]
99
+ return value === ABSENT ? staleRead(this.record, this.ordinal) : value
77
100
  }
78
101
  }
79
102
 
80
103
  class ComponentWriteCell extends WriteCellBase<unknown> {
81
- readonly record: EntityRecord
104
+ readonly values: Array<unknown>
82
105
  readonly ordinal: number
106
+ readonly record: EntityRecord
83
107
  readonly world: World
84
108
 
85
109
  constructor(record: EntityRecord, ordinal: number, world: World) {
86
110
  super()
87
- this.record = record
111
+ this.values = record.values
88
112
  this.ordinal = ordinal
113
+ this.record = record
89
114
  this.world = world
90
115
  }
91
116
 
92
117
  get(): unknown {
93
- return this.record.values[this.ordinal]
118
+ const value = this.values[this.ordinal]
119
+ return value === ABSENT ? staleRead(this.record, this.ordinal) : value
94
120
  }
95
121
 
96
122
  set(value: unknown): void {
97
- this.world.setComponentValue(this.record, this.ordinal, value)
123
+ if (!this.world.setComponentValue(this.record, this.ordinal, value)) staleRead(this.record, this.ordinal)
98
124
  }
99
125
  }
100
126
 
@@ -115,6 +141,11 @@ class OptionalComponentCell {
115
141
  get(): unknown {
116
142
  return this.record.values[this.ordinal]
117
143
  }
144
+
145
+ value(): unknown {
146
+ const value = this.record.values[this.ordinal]
147
+ return value === ABSENT ? undefined : value
148
+ }
118
149
  }
119
150
 
120
151
  class StoreReadCell {
@@ -167,7 +198,7 @@ export const componentWrite = (
167
198
  return entityState === undefined ? constructed : withTransition(constructed, entityState)
168
199
  }
169
200
 
170
- export const componentOptional = (record: EntityRecord, ordinal: number): { readonly present: boolean; get(): unknown } =>
201
+ export const componentOptional = (record: EntityRecord, ordinal: number): { readonly present: boolean; get(): unknown; value(): unknown } =>
171
202
  new OptionalComponentCell(record, ordinal)
172
203
 
173
204
  export const storeRead = (store: Map<symbol, unknown>, key: symbol): { get(): unknown } =>
@@ -198,9 +229,10 @@ export const derivedRead = <T>(read: () => T): { get(): T } => ({ get: read })
198
229
  export const derivedOptional = <T>(
199
230
  isPresent: () => boolean,
200
231
  read: () => T
201
- ): { readonly present: boolean; get(): T } => ({
232
+ ): { readonly present: boolean; get(): T; value(): T | undefined } => ({
202
233
  get present() {
203
234
  return isPresent()
204
235
  },
205
- get: read
236
+ get: read,
237
+ value: () => (isPresent() ? read() : undefined)
206
238
  })
@@ -282,6 +282,9 @@ export interface DescribeInput {
282
282
  readonly ranSystems: ReadonlySet<string>
283
283
  /** Commands skipped because they would have broken a `Schema.requires` requirement. */
284
284
  readonly requirementViolations: ReadonlyArray<Debug.RequirementViolation>
285
+ readonly nextStateReplacements: ReadonlyArray<Debug.NextStateReplacement>
286
+ readonly staleTargets: ReadonlyArray<Debug.StaleTarget>
287
+ readonly streamLosses: ReadonlyArray<Debug.StreamLoss>
285
288
  readonly hasResource: (descriptor: Descriptor.Any) => boolean
286
289
  readonly schedules: ReadonlyArray<readonly [string, AnySchedule]>
287
290
  }
@@ -293,19 +296,38 @@ export const describe = (input: DescribeInput): Debug.Description => {
293
296
  const relations = Object.values(input.schema.relations) as ReadonlyArray<Relation.Relation.Any>
294
297
 
295
298
  const schedules = collectSchedules(input.schedules)
299
+ // A system gated by Schedule.when / pauseWhen is a copy pointing at its
300
+ // base: both are one system, described once with every placement.
301
+ const baseOf = (system: AnySystem): AnySystem => (system as { readonly base?: AnySystem }).base ?? system
296
302
  const placements = new Map<AnySystem, Array<string>>()
303
+ const steps = new Set<AnySystem>()
297
304
  for (const [name, schedule] of schedules) {
298
305
  schedule.steps.forEach((step, index) => {
299
306
  if (!Schedule.isSystemStep(step)) return
300
- const system = step as AnySystem
307
+ const system = baseOf(step as AnySystem)
308
+ steps.add(step as AnySystem)
301
309
  const list = placements.get(system)
302
310
  const placement = `${name}#${index}`
303
311
  if (list) list.push(placement)
304
312
  else placements.set(system, [placement])
305
313
  })
306
314
  }
307
- const described = new Map([...placements].map(([system, where]) => [system, describeSystem(system, where)] as const))
308
- const systems = [...described.values()]
315
+ // Gates differ per placement: the description lists the conditions of all of them.
316
+ const gatesOf = new Map<AnySystem, { when: Set<unknown>; pauseWhen: Set<unknown> }>()
317
+ for (const step of steps) {
318
+ const base = baseOf(step)
319
+ const gates = gatesOf.get(base) ?? { when: new Set(), pauseWhen: new Set() }
320
+ for (const condition of step.spec.when as ReadonlyArray<unknown>) gates.when.add(condition)
321
+ for (const condition of step.spec.pauseWhen as ReadonlyArray<unknown>) gates.pauseWhen.add(condition)
322
+ gatesOf.set(base, gates)
323
+ }
324
+ const byBase = new Map([...placements].map(([system, where]) => {
325
+ const gates = gatesOf.get(system)!
326
+ const merged = { ...system, spec: { ...system.spec, when: [...gates.when], pauseWhen: [...gates.pauseWhen] } } as AnySystem
327
+ return [system, describeSystem(merged, where)] as const
328
+ }))
329
+ const described = new Map<AnySystem, Debug.SystemDescription>([...steps].map((step) => [step, byBase.get(baseOf(step))!] as const))
330
+ const systems = [...byBase.values()]
309
331
 
310
332
  const components = new Index(componentDescriptors.map((descriptor) => descriptor.name))
311
333
  const resources = new Index(resourceDescriptors.map((descriptor) => descriptor.name))
@@ -314,7 +336,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
314
336
  for (const name of input.externallyWrittenEvents) events.write(name, "runtime.send")
315
337
  for (const system of systems) {
316
338
  for (const query of system.queries) {
317
- for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed]) {
339
+ for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed, ...anyOfNames(query)]) {
318
340
  components.read(name, system.name)
319
341
  }
320
342
  for (const name of query.writes) {
@@ -401,7 +423,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
401
423
  }
402
424
  }
403
425
  const failureReaders = new Set(systems.flatMap((system) => system.relationFailures))
404
- // Declared `relates` permissions, and relations that commands were seen to relate (drafts relate without one).
426
+ // Declared `relates` permissions, and relations that commands were seen to relate (erased types can relate without one).
405
427
  const relaters = new Map<string, Set<string>>()
406
428
  for (const system of systems) {
407
429
  for (const name of system.commands.relates) relaters.set(name, new Set([...(relaters.get(name) ?? []), system.name]))
@@ -452,15 +474,6 @@ export const describe = (input: DescribeInput): Debug.Description => {
452
474
  })
453
475
  }
454
476
  }
455
- for (const violation of input.requirementViolations) {
456
- const times = violation.count === 1 ? "once" : `${violation.count} times`
457
- lints.push({
458
- severity: "warning",
459
- code: "requirement-violated",
460
- subject: violation.system,
461
- message: `${violation.system} queued ${violation.command === "insert" ? "an" : "a"} ${violation.command} that would have left ${violation.component} without ${violation.missing} (last on entity ${violation.entity}), so it was skipped ${times}`
462
- })
463
- }
464
477
  for (const entry of components.list()) {
465
478
  if (entry.writers.length > 0 && entry.readers.every((reader) => entry.writers.includes(reader))) {
466
479
  lints.push({
@@ -472,6 +485,52 @@ export const describe = (input: DescribeInput): Debug.Description => {
472
485
  }
473
486
  }
474
487
  }
488
+ // Observed with debug, whether or not schedules are named.
489
+ for (const violation of input.requirementViolations) {
490
+ const times = violation.count === 1 ? "once" : `${violation.count} times`
491
+ lints.push({
492
+ severity: "warning",
493
+ code: "requirement-violated",
494
+ subject: violation.system,
495
+ message: `${violation.system} queued ${violation.command === "insert" ? "an" : "a"} ${violation.command} that would have left ${violation.component} without ${violation.missing} (last on entity ${violation.entity}), so it was skipped ${times}`
496
+ })
497
+ }
498
+ for (const replacement of input.nextStateReplacements) {
499
+ const times = replacement.count === 1 ? "" : `, ${replacement.count} times`
500
+ lints.push({
501
+ severity: "warning",
502
+ code: "next-state-replaced",
503
+ subject: replacement.machine,
504
+ message: `${replacement.by.system} queued ${replacement.machine}=${String(replacement.by.value)}, replacing ${String(replacement.replaced.value)} queued by ${replacement.replaced.system} before the same applyStateTransitions()${times}: the last value wins`
505
+ })
506
+ }
507
+ for (const stale of input.staleTargets) {
508
+ const times = stale.count === 1 ? "once" : `${stale.count} times`
509
+ lints.push({
510
+ severity: "warning",
511
+ code: "stale-target",
512
+ subject: stale.system,
513
+ message: `${stale.system} passed ${stale.command} an id spawned in an earlier run (last entity ${stale.entity}), so it was skipped ${times}`
514
+ })
515
+ }
516
+ for (const loss of input.streamLosses) {
517
+ if (loss.checksLagged) continue
518
+ if (loss.lostRuns > 0) {
519
+ lints.push({
520
+ severity: "warning",
521
+ code: "stream-loss-unread",
522
+ subject: loss.system,
523
+ message: `${loss.system} read ${loss.stream} after entries were dropped before it read them (${loss.lostRuns === 1 ? "once" : `${loss.lostRuns} runs`}), and never checks lagged()`
524
+ })
525
+ } else if (loss.paused) {
526
+ lints.push({
527
+ severity: "info",
528
+ code: "stream-loss-unread",
529
+ subject: loss.system,
530
+ message: `${loss.system} reads ${loss.stream} under pauseWhen and never checks lagged(): a long pause drops entries past the stream's capacity`
531
+ })
532
+ }
533
+ }
475
534
 
476
535
  return {
477
536
  version: 1,
@@ -548,6 +607,12 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
548
607
  return `fine if ${subject} is read outside the described schedules (rendering, inspectors); otherwise stop writing it`
549
608
  case "requirement-violated":
550
609
  return `keep what ${subject} spawns or inserts complete (every component its components require), and remove a required component only together with its requirers; a query proof can go stale when another system's command is applied first in the same applyDeferred()`
610
+ case "stream-loss-unread":
611
+ return `check lagged() in ${subject} where a missed entry matters (a node never destroyed, a count off), and rebuild from queries when it is true; fine if a missed entry is harmless`
612
+ case "stale-target":
613
+ return `an id spawn returns is valid only in the run that spawned it; in ${subject}, keep the entity id or a handle and resolve it again with a query (queries.x.get(id) / lookup.getHandle(handle, query)) and pass match.entity`
614
+ case "next-state-replaced":
615
+ return "decide between the values in one system, or check whether a next state is already queued (nextMachines.x.getPending()) before setting another"
551
616
  case "command-permission-unused":
552
617
  return `fine if the session did not reach the case where ${subject} uses it; otherwise drop the permission from its Game.System.commands(...)`
553
618
  case "read-before-write":
@@ -558,9 +623,13 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
558
623
  type Accesses = { readonly reads: ReadonlyArray<string>; readonly writes: ReadonlyArray<string> }
559
624
 
560
625
  /** What a system reads and writes, per kind. `with`/`without` filters test presence and are not reads. */
626
+ /** The components named by a query's `anyOf` filters. */
627
+ const anyOfNames = (query: Debug.QueryDescription): ReadonlyArray<string> =>
628
+ query.anyOf.flatMap((filter) => [...filter.added, ...filter.changed])
629
+
561
630
  const accessesOf = (system: Debug.SystemDescription): Record<"component" | "resource" | "event", Accesses> => ({
562
631
  component: {
563
- reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed]),
632
+ reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed, ...anyOfNames(query)]),
564
633
  writes: system.queries.flatMap((query) => query.writes)
565
634
  },
566
635
  resource: { reads: system.resources.reads, writes: system.resources.writes },
@@ -4,9 +4,14 @@
4
4
  * The compile-time counterpart is `ValidateFragments` in `Schema.ts`; these
5
5
  * guards cover schemas assembled from erased types.
6
6
  */
7
+ import { isTransient } from "../Descriptor.ts"
7
8
  import type { Schema, SchemaDefinition } from "../Schema.ts"
8
9
 
9
- type NamedRegistry = Record<string, { readonly name: string }>
10
+ type NamedRegistry = Record<string, { readonly name: string; readonly kind?: string }>
11
+
12
+ /** The descriptor kind each registry holds (relations carry their own shape). */
13
+ const expectedKind: Partial<Record<(typeof kinds)[number], string>> = { components: "component", resources: "resource", events: "event" }
14
+ const article = (kind: string) => kind === "event" ? "an" : "a"
10
15
 
11
16
  const kinds = ["components", "resources", "events", "relations"] as const
12
17
 
@@ -21,9 +26,15 @@ export const emptySchema = (): SchemaDefinition => ({
21
26
  /**
22
27
  * Merges two registries after checking for duplicate keys and descriptor names.
23
28
  */
24
- const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry => {
29
+ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry, expected: string | undefined): NamedRegistry => {
25
30
  const names = new Set(Object.values(left).map((entry) => entry.name))
26
31
  for (const [key, entry] of Object.entries(right)) {
32
+ if (expected !== undefined && entry.kind !== expected) {
33
+ const actual = String(entry.kind)
34
+ throw new Error(actual === "service"
35
+ ? `${key} is a service, not ${article(expected)} ${expected}: provide services to Game.Runtime.make({ services }) instead of registering them`
36
+ : `${key} is ${article(actual)} ${actual}, not ${article(expected)} ${expected}: move it to ${actual}s: { ... }`)
37
+ }
27
38
  if (key in left) {
28
39
  throw new Error(`Duplicate schema key: ${key}`)
29
40
  }
@@ -38,7 +49,7 @@ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry
38
49
  export const mergeSchemas = (left: Schema.Any, right: Schema.Any): Schema.Any => {
39
50
  const merged: Record<string, unknown> = {}
40
51
  for (const kind of kinds) {
41
- merged[kind] = mergeRegistry(left[kind], right[kind])
52
+ merged[kind] = mergeRegistry(left[kind], right[kind], expectedKind[kind])
42
53
  }
43
54
  merged["requires"] = [...left.requires, ...right.requires]
44
55
  return merged as unknown as Schema.Any
@@ -56,6 +67,15 @@ export const buildSchema = (fragments: ReadonlyArray<Schema.Any>): Schema.Any =>
56
67
  throw new Error(`${descriptor.name} is named in Schema.requires(...) but no fragment registers it: add it to a fragment's components`)
57
68
  }
58
69
  }
70
+ for (const required of requirement.required) {
71
+ if (isTransient(required) && !isTransient(requirement.component)) {
72
+ throw new Error(transientRequirementMessage(requirement.component.name, required.name))
73
+ }
74
+ }
59
75
  }
60
76
  return current
61
77
  }
78
+
79
+ /** Why a saved component cannot require a transient one: a restore would bring it back without it. */
80
+ export const transientRequirementMessage = (requirer: string, required: string): string =>
81
+ `${requirer} requires ${required}, which is transient and not saved: make ${requirer} transient too, or give ${required} a constructor`
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as Command from "../Command.ts"
8
8
  import type * as Debug from "../Debug.ts"
9
+ import type * as Snapshot from "../Snapshot.ts"
9
10
  import type { Descriptor } from "../Descriptor.ts"
10
11
  import * as Condition from "../Condition.ts"
11
12
  import * as Entity from "../Entity.ts"
@@ -15,11 +16,16 @@ import * as Machine from "../Machine.ts"
15
16
  import * as QueryModule from "../Query.ts"
16
17
  import * as Relation from "../Relation.ts"
17
18
  import * as Runtime from "../Runtime.ts"
19
+ import * as Requirement from "../Requirement.ts"
18
20
  import * as Schedule from "../Schedule.ts"
19
21
  import type { Schema } from "../Schema.ts"
20
22
  import * as System from "../System.ts"
21
23
  import { requirementIndex, withRequired } from "./requirements.ts"
22
24
 
25
+ /** Why an exit phase (`onExit`, `onTransition`) cannot contain `applyDeferred()`. */
26
+ export const exitApplyDeferredMessage =
27
+ "onExit and onTransition schedules cannot contain applyDeferred(): the exit phase rolls back as one when a system in it fails, so its commands are applied at the next applyDeferred() after the marker" as const
28
+
23
29
  export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Schema.Game<S, Root> => {
24
30
  const requirements = requirementIndex(schema)
25
31
  const definedMachines: Array<Machine.StateMachine.Any> = []
@@ -77,7 +83,14 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
77
83
  const transitionSchedule = (
78
84
  transition: Machine.TransitionScheduleDefinition["transition"],
79
85
  plan: ReadonlyArray<Schedule.ScheduleEntry>
80
- ) => ({ ...Schedule.make(schema, plan), transition })
86
+ ) => {
87
+ const schedule = Schedule.make(schema, plan)
88
+ if (transition.phase !== "enter" && schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyDeferred")) {
89
+ throw new Error(exitApplyDeferredMessage)
90
+ }
91
+ // The machine is a requirement: a runtime without it could never run the schedule.
92
+ return { ...schedule, requirements: Requirement.collect([...schedule.requirements, transition.machine]), transition }
93
+ }
81
94
 
82
95
  const game = {
83
96
  schema,
@@ -85,7 +98,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
85
98
  Inspector: (name: string, spec: object, read: (context: any) => unknown) =>
86
99
  Inspector.make(name, { schema, ...spec } as never, read),
87
100
  Entity: {
88
- handle: Entity.handle
101
+ handle: Entity.handle,
102
+ id: (handle: number) => Entity.makeEntityId(handle)
89
103
  },
90
104
  Query: Object.assign((spec: never) => QueryModule.Query(spec), {
91
105
  read: QueryModule.read,
@@ -129,7 +143,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
129
143
  commands: Command.commands,
130
144
  // `spawns(Player)` also allows what `Player` requires (`Schema.requires`), matching its type.
131
145
  spawns: (...components: ReadonlyArray<Descriptor<"component", string, any>>) =>
132
- Command.spawns(...withRequired(requirements, components)),
146
+ // Internally erased: the bound signature already requires at least one component.
147
+ Command.spawns(...(withRequired(requirements, components) as unknown as readonly [Descriptor<"component", string, any>])),
133
148
  inserts: Command.inserts,
134
149
  removes: Command.removes,
135
150
  despawns: Command.despawns,
@@ -172,6 +187,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
172
187
  readonly resources?: object
173
188
  readonly machines?: Runtime.RuntimeMachines<any>
174
189
  readonly debug?: Debug.Option
190
+ readonly saves?: Snapshot.Migrations
175
191
  }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines, scopeDefinitions: definedScopes } as never),
176
192
  service: Runtime.service,
177
193
  services: Runtime.services,
@@ -14,6 +14,7 @@
14
14
  * Matches are always returned in ascending entity id order, which is spawn
15
15
  * order, so iteration is deterministic.
16
16
  */
17
+ import type { Descriptor } from "../Descriptor.ts"
17
18
  import * as DescriptorModule from "../Descriptor.ts"
18
19
  import type * as Entity from "../Entity.ts"
19
20
  import * as Query from "../Query.ts"
@@ -124,6 +125,31 @@ export const makeQueryEngine = (world: World) => {
124
125
  const states = new WeakMap<AnyQuery, QueryState>()
125
126
  let nextOrdinal = 0
126
127
 
128
+ /**
129
+ * The ordinal of a selected component, checking that the descriptor the
130
+ * query names behaves like the one the schema registers under that name:
131
+ * identity is (kind, name), so a look-alike built elsewhere must not add
132
+ * or drop behaviour (entity-state transitions, a constructor, immutability,
133
+ * transience) the schema does not declare.
134
+ */
135
+ const registeredOrdinal = (descriptor: Descriptor.Any): number => {
136
+ const ordinal = world.ordinalOf(descriptor as never)
137
+ const registered = world.descriptorAt(ordinal) as Descriptor.Any
138
+ if (registered === descriptor) return ordinal
139
+ const traits = (candidate: Descriptor.Any) => [
140
+ DescriptorModule.isEntityState(candidate as never) ? "an entity state" : undefined,
141
+ DescriptorModule.hasConstructor(candidate) ? "constructed" : undefined,
142
+ DescriptorModule.isImmutable(candidate) ? "immutable" : undefined,
143
+ DescriptorModule.isTransient(candidate) ? "transient" : undefined
144
+ ].filter((trait) => trait !== undefined).join(", ") || "plain"
145
+ const expected = traits(registered)
146
+ const actual = traits(descriptor)
147
+ if (expected !== actual) {
148
+ throw new Error(`${descriptor.name} is ${expected} in this schema, but this query names a descriptor that is ${actual}: use the descriptor the schema registers`)
149
+ }
150
+ return ordinal
151
+ }
152
+
127
153
  const compile = (query: AnyQuery): QueryState => {
128
154
  const required: Array<number> = query.with.map((descriptor) => world.ordinalOf(descriptor))
129
155
  const withRelations: Array<Relation.Relation.Any> = [...query.withRelations]
@@ -136,7 +162,7 @@ export const makeQueryEngine = (world: World) => {
136
162
  switch (access.mode) {
137
163
  case "read":
138
164
  case "write": {
139
- const ordinal = world.ordinalOf(access.descriptor)
165
+ const ordinal = registeredOrdinal(access.descriptor)
140
166
  required.push(ordinal)
141
167
  proofSlots.push([slot, ordinal])
142
168
  if (access.mode === "write") {
@@ -154,7 +180,7 @@ export const makeQueryEngine = (world: World) => {
154
180
  break
155
181
  }
156
182
  case "optional":
157
- slots.push({ slot, mode: "optional", ordinal: world.ordinalOf(access.descriptor) })
183
+ slots.push({ slot, mode: "optional", ordinal: registeredOrdinal(access.descriptor) })
158
184
  break
159
185
  case "readRelation":
160
186
  withRelations.push(access.descriptor)
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Builds relation pairs for `Descriptor.Hierarchy` and `Descriptor.Relation`,
3
+ * the one public way to define a relation.
4
+ */
5
+ import type { PairBrand, RelatedDefinition, RelationDefinition, RelationKind } from "../Relation.ts"
6
+
7
+ const relationTypeId = "~ritmo/Relation"
8
+
9
+ export const makePair = <
10
+ const Name extends string,
11
+ const RelatedName extends string,
12
+ const Kind extends RelationKind
13
+ >(
14
+ kind: Kind,
15
+ name: Name,
16
+ relatedName: RelatedName
17
+ ): {
18
+ readonly relation: RelationDefinition<Name, RelatedName, Kind, PairBrand<Name, RelatedName, Kind>>
19
+ readonly related: RelatedDefinition<RelatedName, Name, Kind, PairBrand<Name, RelatedName, Kind>>
20
+ } => {
21
+ type Pair = PairBrand<Name, RelatedName, Kind>
22
+ const related = {
23
+ kind: "related",
24
+ relatedName,
25
+ relationName: name,
26
+ key: Symbol.for(`ritmo/related/${name}`),
27
+ relationKind: kind,
28
+ [relationTypeId]: {
29
+ _Pair: (_: never) => undefined as unknown as Pair
30
+ }
31
+ } as RelatedDefinition<RelatedName, Name, Kind, Pair>
32
+
33
+ const relation = {
34
+ kind: "relation",
35
+ name,
36
+ relatedName,
37
+ key: Symbol.for(`ritmo/relation/${name}`),
38
+ relationKind: kind,
39
+ linkedDespawn: kind === "hierarchy",
40
+ allowSelf: false,
41
+ ordered: kind === "hierarchy",
42
+ related,
43
+ [relationTypeId]: {
44
+ _Pair: (_: never) => undefined as unknown as Pair
45
+ }
46
+ } as RelationDefinition<Name, RelatedName, Kind, Pair>
47
+
48
+ return {
49
+ relation,
50
+ related
51
+ }
52
+ }