@typeonce/ritmo 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +3 -2
  2. package/dist/Clock.d.ts +19 -4
  3. package/dist/Clock.d.ts.map +1 -1
  4. package/dist/Clock.js +18 -2
  5. package/dist/Clock.js.map +1 -1
  6. package/dist/Command.d.ts +181 -40
  7. package/dist/Command.d.ts.map +1 -1
  8. package/dist/Command.js +31 -11
  9. package/dist/Command.js.map +1 -1
  10. package/dist/Condition.d.ts +12 -1
  11. package/dist/Condition.d.ts.map +1 -1
  12. package/dist/Condition.js.map +1 -1
  13. package/dist/Debug.d.ts +127 -13
  14. package/dist/Debug.d.ts.map +1 -1
  15. package/dist/Descriptor.d.ts +61 -10
  16. package/dist/Descriptor.d.ts.map +1 -1
  17. package/dist/Descriptor.js +22 -10
  18. package/dist/Descriptor.js.map +1 -1
  19. package/dist/Entity.d.ts +22 -7
  20. package/dist/Entity.d.ts.map +1 -1
  21. package/dist/Entity.js.map +1 -1
  22. package/dist/EntityScope.d.ts +20 -1
  23. package/dist/EntityScope.d.ts.map +1 -1
  24. package/dist/EntityScope.js +8 -2
  25. package/dist/EntityScope.js.map +1 -1
  26. package/dist/FixedLoop.d.ts +106 -0
  27. package/dist/FixedLoop.d.ts.map +1 -0
  28. package/dist/FixedLoop.js +97 -0
  29. package/dist/FixedLoop.js.map +1 -0
  30. package/dist/HostClock.d.ts +1 -1
  31. package/dist/HostClock.d.ts.map +1 -1
  32. package/dist/HostClock.js +2 -1
  33. package/dist/HostClock.js.map +1 -1
  34. package/dist/Machine.d.ts +59 -34
  35. package/dist/Machine.d.ts.map +1 -1
  36. package/dist/Machine.js +10 -3
  37. package/dist/Machine.js.map +1 -1
  38. package/dist/Query.d.ts +100 -14
  39. package/dist/Query.d.ts.map +1 -1
  40. package/dist/Query.js +34 -5
  41. package/dist/Query.js.map +1 -1
  42. package/dist/Relation.d.ts +5 -14
  43. package/dist/Relation.d.ts.map +1 -1
  44. package/dist/Relation.js +0 -32
  45. package/dist/Relation.js.map +1 -1
  46. package/dist/Runtime.d.ts +61 -15
  47. package/dist/Runtime.d.ts.map +1 -1
  48. package/dist/Runtime.js +594 -70
  49. package/dist/Runtime.js.map +1 -1
  50. package/dist/Schedule.d.ts +24 -15
  51. package/dist/Schedule.d.ts.map +1 -1
  52. package/dist/Schedule.js +2 -1
  53. package/dist/Schedule.js.map +1 -1
  54. package/dist/Schema.d.ts +233 -48
  55. package/dist/Schema.d.ts.map +1 -1
  56. package/dist/Schema.js +33 -7
  57. package/dist/Schema.js.map +1 -1
  58. package/dist/Snapshot.d.ts +90 -1
  59. package/dist/Snapshot.d.ts.map +1 -1
  60. package/dist/Snapshot.js +42 -1
  61. package/dist/Snapshot.js.map +1 -1
  62. package/dist/System.d.ts +132 -31
  63. package/dist/System.d.ts.map +1 -1
  64. package/dist/System.js +32 -0
  65. package/dist/System.js.map +1 -1
  66. package/dist/index.d.ts +4 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +4 -0
  69. package/dist/index.js.map +1 -1
  70. package/dist/internal/cells.d.ts +2 -0
  71. package/dist/internal/cells.d.ts.map +1 -1
  72. package/dist/internal/cells.js +34 -8
  73. package/dist/internal/cells.js.map +1 -1
  74. package/dist/internal/debug.d.ts +10 -0
  75. package/dist/internal/debug.d.ts.map +1 -1
  76. package/dist/internal/debug.js +171 -12
  77. package/dist/internal/debug.js.map +1 -1
  78. package/dist/internal/equal.d.ts +8 -0
  79. package/dist/internal/equal.d.ts.map +1 -0
  80. package/dist/internal/equal.js +38 -0
  81. package/dist/internal/equal.js.map +1 -0
  82. package/dist/internal/fragments.d.ts +2 -6
  83. package/dist/internal/fragments.d.ts.map +1 -1
  84. package/dist/internal/fragments.js +36 -3
  85. package/dist/internal/fragments.js.map +1 -1
  86. package/dist/internal/game.d.ts +2 -0
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +48 -7
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts.map +1 -1
  91. package/dist/internal/order.js +4 -2
  92. package/dist/internal/order.js.map +1 -1
  93. package/dist/internal/queries.d.ts.map +1 -1
  94. package/dist/internal/queries.js +57 -26
  95. package/dist/internal/queries.js.map +1 -1
  96. package/dist/internal/relations.d.ts +10 -0
  97. package/dist/internal/relations.d.ts.map +1 -0
  98. package/dist/internal/relations.js +32 -0
  99. package/dist/internal/relations.js.map +1 -0
  100. package/dist/internal/requirements.d.ts +26 -0
  101. package/dist/internal/requirements.d.ts.map +1 -0
  102. package/dist/internal/requirements.js +46 -0
  103. package/dist/internal/requirements.js.map +1 -0
  104. package/dist/internal/streams.d.ts +2 -0
  105. package/dist/internal/streams.d.ts.map +1 -1
  106. package/dist/internal/streams.js +10 -0
  107. package/dist/internal/streams.js.map +1 -1
  108. package/dist/internal/world.d.ts +7 -1
  109. package/dist/internal/world.d.ts.map +1 -1
  110. package/dist/internal/world.js +53 -3
  111. package/dist/internal/world.js.map +1 -1
  112. package/package.json +1 -1
  113. package/src/Clock.ts +29 -4
  114. package/src/Command.ts +281 -67
  115. package/src/Condition.ts +17 -1
  116. package/src/Debug.ts +124 -15
  117. package/src/Descriptor.ts +92 -21
  118. package/src/Entity.ts +23 -7
  119. package/src/EntityScope.ts +21 -2
  120. package/src/FixedLoop.ts +218 -0
  121. package/src/HostClock.ts +2 -1
  122. package/src/Machine.ts +72 -35
  123. package/src/Query.ts +118 -17
  124. package/src/Relation.ts +5 -67
  125. package/src/Runtime.ts +659 -85
  126. package/src/Schedule.ts +31 -19
  127. package/src/Schema.ts +297 -57
  128. package/src/Snapshot.ts +94 -2
  129. package/src/System.ts +151 -29
  130. package/src/index.ts +4 -0
  131. package/src/internal/cells.ts +42 -10
  132. package/src/internal/debug.ts +179 -14
  133. package/src/internal/equal.ts +31 -0
  134. package/src/internal/fragments.ts +35 -5
  135. package/src/internal/game.ts +69 -7
  136. package/src/internal/order.ts +8 -4
  137. package/src/internal/queries.ts +62 -12
  138. package/src/internal/relations.ts +52 -0
  139. package/src/internal/requirements.ts +71 -0
  140. package/src/internal/streams.ts +11 -0
  141. package/src/internal/world.ts +60 -4
package/src/Snapshot.ts CHANGED
@@ -24,10 +24,17 @@
24
24
  *
25
25
  * Restoring despawns every current entity and spawns the saved ones with their
26
26
  * original ids, so change detection and renderer sync see a restore as normal
27
- * despawns and spawns. Pending commands and events are dropped. Transient
27
+ * despawns and spawns. Pending commands and events emitted by systems are
28
+ * dropped; events the host sent with `runtime.send` and no system has read
29
+ * yet are kept, since they are input that has not been handled. Transient
28
30
  * components are absent from restored entities; transient resources keep their
29
31
  * current value.
30
32
  *
33
+ * When the game's save format changes, give the runtime its migrations
34
+ * (`Game.Runtime.make({ saves: Snapshot.migrations(...) })`): saves carry a
35
+ * `gameVersion`, and `restore` upgrades older ones step by step before
36
+ * validating them.
37
+ *
31
38
  * @module Snapshot
32
39
  * @docGroup runtime
33
40
  *
@@ -90,6 +97,12 @@ export const version = 2
90
97
 
91
98
  export interface WorldSnapshot {
92
99
  readonly version: typeof version
100
+ /**
101
+ * The game's own save version: the last `Snapshot.migration` the runtime
102
+ * that wrote it was given (0 without migrations, and for saves written
103
+ * before game versions existed).
104
+ */
105
+ readonly gameVersion: number
93
106
  /** The next id the runtime will allocate. */
94
107
  readonly nextEntity: number
95
108
  readonly entities: ReadonlyArray<EntitySnapshot>
@@ -113,15 +126,23 @@ export interface WorldSnapshot {
113
126
  export type RestoreError =
114
127
  | { readonly _tag: "InvalidSnapshot"; readonly path: string }
115
128
  | { readonly _tag: "UnsupportedVersion"; readonly version: unknown }
129
+ /** A save written by a newer game than this runtime's migrations reach. */
130
+ | { readonly _tag: "NewerGameVersion"; readonly gameVersion: number; readonly latest: number }
116
131
  | { readonly _tag: "UnknownComponent"; readonly entityId: number; readonly name: string }
117
132
  | { readonly _tag: "InvalidComponent"; readonly entityId: number; readonly name: string; readonly error: unknown }
133
+ /** A saved entity holds `requiredBy` but lacks `name`, which `Schema.requires` says it needs. */
134
+ | { readonly _tag: "MissingRequiredComponent"; readonly entityId: number; readonly name: string; readonly requiredBy: string }
118
135
  | { readonly _tag: "UnknownResource"; readonly name: string }
119
136
  | { readonly _tag: "InvalidResource"; readonly name: string; readonly error: unknown }
120
137
  | { readonly _tag: "UnknownRelation"; readonly name: string }
121
138
  | { readonly _tag: "InvalidRelation"; readonly name: string; readonly sourceId: number; readonly targetId: number }
122
139
  | { readonly _tag: "UnknownMachine"; readonly name: string }
123
140
  | { readonly _tag: "InvalidMachineState"; readonly name: string; readonly value: unknown }
141
+ /** A saved sub-state value while its parent machine is not in the state the sub-state exists in. */
142
+ | { readonly _tag: "InactiveSubState"; readonly name: string; readonly parent: string; readonly requires: unknown }
124
143
  | { readonly _tag: "DuplicateEntity"; readonly entityId: number }
144
+ /** A scope name that no `Game.EntityScope(...)` of this game defines. */
145
+ | { readonly _tag: "UnknownScope"; readonly name: string }
125
146
  /** A scope member that is not a saved entity, or is already in another scope. */
126
147
  | { readonly _tag: "InvalidScope"; readonly name: string; readonly entityId: number }
127
148
  | { readonly _tag: "UnvalidatedDescriptor"; readonly name: string }
@@ -141,6 +162,8 @@ const invalid = (path: string) => Result.failure<RestoreError>({ _tag: "InvalidS
141
162
  export const parse = (data: unknown): Result.Result<WorldSnapshot, RestoreError> => {
142
163
  if (!isRecord(data)) return invalid("$")
143
164
  if (data["version"] !== version) return Result.failure({ _tag: "UnsupportedVersion", version: data["version"] })
165
+ const gameVersion = data["gameVersion"] ?? 0
166
+ if (typeof gameVersion !== "number" || !Number.isSafeInteger(gameVersion) || gameVersion < 0) return invalid("$.gameVersion")
144
167
  if (!isEntityId(data["nextEntity"])) return invalid("$.nextEntity")
145
168
  const entities = data["entities"]
146
169
  if (!Array.isArray(entities)) return invalid("$.entities")
@@ -172,5 +195,74 @@ export const parse = (data: unknown): Result.Result<WorldSnapshot, RestoreError>
172
195
  for (const [name, members] of Object.entries(scopes)) {
173
196
  if (!Array.isArray(members) || !members.every(isEntityId)) return invalid(`$.scopes.${name}`)
174
197
  }
175
- return Result.success(data as unknown as WorldSnapshot)
198
+ return Result.success({ ...data, gameVersion } as unknown as WorldSnapshot)
199
+ }
200
+
201
+ /**
202
+ * One step of a game's save format: turns a save of version `to - 1` into
203
+ * version `to`. A pure function on the save's plain data, tested like one.
204
+ */
205
+ export interface Migration<To extends number = number> {
206
+ readonly to: To
207
+ readonly migrate: (save: WorldSnapshot) => WorldSnapshot
208
+ }
209
+
210
+ /** A game's save migrations, given to `Game.Runtime.make({ saves })`. Built with {@link migrations}. */
211
+ export interface Migrations<Latest extends number = number> {
212
+ readonly kind: "SnapshotMigrations"
213
+ /** The game version `snapshot()` writes. */
214
+ readonly latest: Latest
215
+ readonly steps: ReadonlyArray<Migration>
216
+ }
217
+
218
+ /**
219
+ * Declares the step that upgrades saves to game version `to` (from `to - 1`).
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * Snapshot.migration(2, (save) => ({ ...save, resources: { ...save.resources, "Town/Gold": 0 } }))
224
+ * ```
225
+ */
226
+ export const migration = <const To extends number>(to: To, migrate: (save: WorldSnapshot) => WorldSnapshot): Migration<To> => ({ to, migrate })
227
+
228
+ type Last<Steps> = Steps extends readonly [...Array<unknown>, infer L extends Migration] ? L["to"] : number
229
+
230
+ /** `unknown` when the steps are numbered 1, 2, 3, ... in order; otherwise the message naming the misplaced one. */
231
+ type Consecutive<Steps, Count extends Array<0> = []> =
232
+ Steps extends readonly [infer Head extends Migration, ...infer Tail]
233
+ ? number extends Head["to"] ? unknown
234
+ : Head["to"] extends [...Count, 0]["length"] ? Consecutive<Tail, [...Count, 0]>
235
+ : { readonly __fixMigrations__: `migration ${Head["to"]} is listed where migration ${[...Count, 0]["length"]} belongs: number migrations 1, 2, 3, ... in order` }
236
+ : unknown
237
+
238
+ /**
239
+ * A game's save migrations, numbered 1, 2, 3, ... in order (checked at
240
+ * compile time for literal numbers, and at runtime). Pass them to
241
+ * `Game.Runtime.make({ saves })`: `snapshot()` then writes the last number as
242
+ * the save's `gameVersion`, and `restore(data)` runs every step after the
243
+ * save's version, in order, before validating it as usual. A save newer than
244
+ * the last step fails with `NewerGameVersion`. Use it instead of editing raw
245
+ * save data before `restore`.
246
+ *
247
+ * @example
248
+ * ```ts
249
+ * const runtime = Game.Runtime.make({
250
+ * services,
251
+ * saves: Snapshot.migrations(
252
+ * Snapshot.migration(1, (save) => ({ ...save, scopes: {} })),
253
+ * Snapshot.migration(2, renameGold)
254
+ * )
255
+ * }).value
256
+ * runtime.restore(raw)
257
+ * ```
258
+ */
259
+ export const migrations = <const Steps extends readonly [Migration, ...Array<Migration>]>(
260
+ ...steps: Steps & Consecutive<Steps>
261
+ ): Migrations<Last<Steps>> => {
262
+ steps.forEach((step, index) => {
263
+ if (step.to !== index + 1) {
264
+ throw new Error(`migration ${step.to} is listed where migration ${index + 1} belongs: number migrations 1, 2, 3, ... in order`)
265
+ }
266
+ })
267
+ return { kind: "SnapshotMigrations", latest: steps.length as Last<Steps>, steps }
176
268
  }
package/src/System.ts CHANGED
@@ -193,6 +193,50 @@ export const writeResource = <D extends Descriptor<"resource", string, any>>(
193
193
  descriptor
194
194
  })
195
195
 
196
+ /**
197
+ * Declares read access to a resource plus whether it changed since this
198
+ * system's previous completed run.
199
+ */
200
+ export type ResourceChanged<D extends Descriptor<"resource", string, any>> = {
201
+ readonly mode: "changed"
202
+ readonly descriptor: D
203
+ }
204
+
205
+ /**
206
+ * Creates a resource read that also reports `changed()`: whether a committed
207
+ * write left a different value since this system's previous completed run.
208
+ *
209
+ * Use it for work worth doing only when a value moved: rewriting a HUD label,
210
+ * rebuilding a cache from settings. Use `readResource` when the system needs
211
+ * the value every run anyway.
212
+ *
213
+ * A write that leaves an equal value is not a change: primitives compare
214
+ * with `Object.is`, plain objects and arrays by their entries, other objects
215
+ * by reference. This differs from `Game.Query.changed`, where every write
216
+ * through a component write cell counts. The first run reports `true`; a
217
+ * failed run (rolled back) is not a change, and a failed reader sees the same
218
+ * change again next run. Its own writes are not reported to the writer.
219
+ *
220
+ * Conditions (`Game.Condition.check`) cannot declare it: they own no cursor.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * const SyncHud = Game.System("SyncHud", {
225
+ * resources: { score: Game.System.changedResource(Score) },
226
+ * services: { hud: Game.System.service(Hud) }
227
+ * }, ({ resources, services }) => {
228
+ * if (!resources.score.changed()) return
229
+ * services.hud.score.textContent = `${resources.score.get()}`
230
+ * })
231
+ * ```
232
+ */
233
+ export const changedResource = <D extends Descriptor<"resource", string, any>>(
234
+ descriptor: D
235
+ ): ResourceChanged<D> => ({
236
+ mode: "changed",
237
+ descriptor
238
+ })
239
+
196
240
  /**
197
241
  * Declares read-only access to an event stream.
198
242
  */
@@ -314,7 +358,7 @@ export interface ThisTick {
314
358
  * ```
315
359
  */
316
360
  export const thisTick = <
317
- Read extends EventRead<Descriptor<"event", string, any>> | ResourceRead<Descriptor<"resource", string, any>> | Query.Any<any>
361
+ Read extends EventRead<Descriptor<"event", string, any>> | ResourceRead<Descriptor<"resource", string, any>> | ResourceChanged<Descriptor<"resource", string, any>> | Query.Any<any>
318
362
  >(read: Read): Read & ThisTick => ({ ...read, thisTick: true })
319
363
 
320
364
  /**
@@ -367,7 +411,10 @@ export const readTransitionEvent = <M extends Machine.StateMachine.Any>(
367
411
  /**
368
412
  * Internal union for all supported resource access declarations.
369
413
  */
370
- type ResourceAccess = ResourceRead<Descriptor<"resource", string, any>> | ResourceWrite<Descriptor<"resource", string, any>>
414
+ type ResourceAccess =
415
+ | ResourceRead<Descriptor<"resource", string, any>>
416
+ | ResourceChanged<Descriptor<"resource", string, any>>
417
+ | ResourceWrite<Descriptor<"resource", string, any>>
371
418
  /**
372
419
  * Internal union for all supported event access declarations.
373
420
  */
@@ -378,6 +425,15 @@ type EventAccess = EventRead<Descriptor<"event", string, any>> | EventWrite<Desc
378
425
  */
379
426
  export interface ResourceReadView<T> extends ReadCell<T> {}
380
427
 
428
+ /**
429
+ * A read-only view over a resource value that also reports whether it
430
+ * changed since this system's previous completed run (`changedResource`).
431
+ */
432
+ export interface ResourceChangedView<T> extends ReadCell<T> {
433
+ /** Whether a committed write left a different value since this system's previous completed run. `true` on the first run. */
434
+ changed(): boolean
435
+ }
436
+
381
437
  /**
382
438
  * A mutable view over a resource value.
383
439
  */
@@ -405,14 +461,16 @@ export interface EventReadView<T> {
405
461
  * A writable event stream view.
406
462
  */
407
463
  export interface EventWriteView<T> {
408
- 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
409
466
  }
410
467
 
411
468
  /**
412
469
  * A read-only view over one committed finite-state-machine value.
413
470
  */
414
471
  export interface MachineReadView<M extends Machine.StateMachine.Any = Machine.StateMachine.Any> {
415
- get(): Machine.StateMachine.Value<M>
472
+ /** The committed value; `undefined` only for a sub-state whose parent is in another state. */
473
+ get(): Machine.CurrentValue<M>
416
474
  is(value: Machine.StateMachine.Value<M>): boolean
417
475
  }
418
476
 
@@ -421,20 +479,38 @@ export interface MachineReadView<M extends Machine.StateMachine.Any = Machine.St
421
479
  */
422
480
  export interface NextMachineWriteView<M extends Machine.StateMachine.Any = Machine.StateMachine.Any> {
423
481
  getPending(): Machine.StateMachine.Value<M> | undefined
424
- set(value: Machine.StateMachine.Value<M>): void
425
- setIfChanged(value: Machine.StateMachine.Value<M>): void
482
+ /**
483
+ * Queues `value`, committed at the next `applyStateTransitions(...)`. For a
484
+ * sub-state it returns a `Result` that fails with `SubStateInactive` (and
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.
493
+ */
494
+ set(value: Machine.StateMachine.Value<M>): Machine.SetResult<M>
495
+ /** Like `set`, but committing the current value again is skipped (no exit or enter schedules). */
496
+ setIfChanged(value: Machine.StateMachine.Value<M>): Machine.SetResult<M>
426
497
  reset(): void
427
498
  }
428
499
 
429
500
  /**
430
501
  * A read-only view over the last applied transition payload for one machine.
502
+ *
503
+ * Only meaningful inside transition schedules, so it is only given to
504
+ * systems that declare `transitions: { x: Game.System.transition(M) }`.
431
505
  */
432
506
  export interface TransitionReadView<M extends Machine.StateMachine.Any = Machine.StateMachine.Any> {
433
507
  get(): Machine.TransitionSnapshot<M>
434
508
  }
435
509
 
436
510
  /**
437
- * A read-only event stream of committed machine transitions.
511
+ * A read-only event stream of committed machine transitions, published by
512
+ * `applyStateTransitions(...)`. Like events, each reader sees the
513
+ * transitions published since its own previous run.
438
514
  */
439
515
  export interface TransitionEventReadView<M extends Machine.StateMachine.Any = Machine.StateMachine.Any> {
440
516
  all(): ReadonlyArray<Machine.TransitionSnapshot<M>>
@@ -593,10 +669,47 @@ export interface RelationFailureReadView<
593
669
  * A runtime query handle exposed to system implementations.
594
670
  *
595
671
  * The handle is already typed from the query spec, so iterating it returns
596
- * 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.
597
676
  */
598
- 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> {
599
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> {
600
713
  /**
601
714
  * Retrieves the match for one specific entity id when it satisfies the query.
602
715
  *
@@ -643,24 +756,6 @@ export interface QueryHandle<S extends Schema.Any, Q extends Query.Any> {
643
756
  * Returns a single match when exactly one entity satisfies the query.
644
757
  */
645
758
  single(): Result.Result<QueryMatch<S, Q>, Query.SingleError>
646
- /**
647
- * Returns a single match when zero or one entity satisfies the query.
648
- *
649
- * Use this when absence is acceptable but multiplicity is still a bug. Zero
650
- * matches return `ok: true` with `value: undefined`; multiple matches remain
651
- * an explicit `MultipleEntities` failure.
652
- *
653
- * @example
654
- * ```ts
655
- * const player = queries.player.singleOptional()
656
- * if (!player.ok || !player.value) {
657
- * return
658
- * }
659
- *
660
- * player.value.data.position.set({ x: 0, y: 0 })
661
- * ```
662
- */
663
- singleOptional(): Result.Result<QueryMatch<S, Q> | undefined, Query.MultipleEntitiesError>
664
759
  }
665
760
 
666
761
  /**
@@ -791,10 +886,36 @@ export interface SystemAccessInput {
791
886
  */
792
887
  export type SystemAccessSpec = SystemAccessInput
793
888
 
794
- /** 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. */
795
890
  export type ExactAccess<Access extends SystemAccessInput> = Access & {
796
891
  readonly [Key in Exclude<keyof Access, keyof SystemAccessInput>]: never
797
- }
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
798
919
 
799
920
  type AccessField<
800
921
  Access,
@@ -847,6 +968,7 @@ export type AnySystemSpec = SystemSpec<any, any, any>
847
968
  type ResourceContext<Spec extends AnySystemSpec> = {
848
969
  readonly [K in keyof Spec["resources"]]:
849
970
  Spec["resources"][K] extends ResourceRead<infer D> ? ResourceReadView<Descriptor.Value<D>>
971
+ : Spec["resources"][K] extends ResourceChanged<infer D> ? ResourceChangedView<Descriptor.Value<D>>
850
972
  : Spec["resources"][K] extends ResourceWrite<infer D> ? ResourceWriteView<D>
851
973
  : never
852
974
  }
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
  })