@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
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Deterministic fixed-step timing for any host: browsers, native shells,
3
+ * desktop runtimes, and headless tests.
4
+ *
5
+ * The loop owns the timing policy that is easy to get subtly wrong in every
6
+ * game: frame clamping, catch-up limits, interpolation, cancellation, and
7
+ * typed update failures. It does not know about an ECS runtime, a renderer,
8
+ * or a platform: frames arrive through a `TickSource` that the host supplies.
9
+ * Use a platform adapter such as `BrowserTickSource.make(window)` from
10
+ * `@typeonce/ritmo-browser`, or wrap a renderer's own ticker with `source`.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const loop = FixedLoop.start({
15
+ * source: BrowserTickSource.make(window),
16
+ * step: Clock.hz(60),
17
+ * maxFrameSeconds: 0.25,
18
+ * maxStepsPerFrame: 5,
19
+ * update: () => runtime.tick(update),
20
+ * render: (frame) => draw(frame.alpha),
21
+ * onFailure: (failure) => console.error(failure)
22
+ * })
23
+ * ```
24
+ *
25
+ * @module FixedLoop
26
+ * @docGroup runtime
27
+ */
28
+ import type * as Clock from "./Clock.ts"
29
+ import * as Result from "./Result.ts"
30
+
31
+ /** One host frame: milliseconds elapsed since the previous one. */
32
+ export interface Tick {
33
+ readonly deltaMS: number
34
+ }
35
+
36
+ /** A stoppable host subscription. Calling `stop` more than once is safe. */
37
+ export interface Subscription {
38
+ stop(): void
39
+ }
40
+
41
+ /**
42
+ * Where host frames come from. Implemented by platform adapters
43
+ * (`BrowserTickSource.make(window)`), by `source` around a renderer's ticker,
44
+ * and by deterministic tests.
45
+ */
46
+ export interface TickSource<TTick extends Tick = Tick> {
47
+ subscribe(onTick: (tick: TTick) => void): Subscription
48
+ }
49
+
50
+ /** Validated fixed-step timing policy. */
51
+ export interface Options {
52
+ /**
53
+ * The fixed step every update advances (`Clock.hz(60)`): the same value
54
+ * the game's clock resource runs at, so the loop and the clock agree.
55
+ */
56
+ readonly step: Clock.Step
57
+ /** Maximum host-frame duration admitted into the accumulator. */
58
+ readonly maxFrameSeconds: number
59
+ /** Maximum number of simulation updates performed for one host frame. */
60
+ readonly maxStepsPerFrame: number
61
+ }
62
+
63
+ /** Configuration error returned before a loop subscribes to its host. */
64
+ export interface InvalidOptions {
65
+ readonly kind: "InvalidFixedLoopOptions"
66
+ readonly field: keyof Options
67
+ readonly reason: "mustBeFiniteAndPositive" | "mustBePositiveInteger"
68
+ readonly value: number
69
+ }
70
+
71
+ /** Data passed to the renderer after all fixed updates for one host frame. */
72
+ export interface Frame {
73
+ /** Clamped time admitted for this host frame. */
74
+ readonly elapsedSeconds: number
75
+ /** Number of fixed updates completed for this host frame. */
76
+ readonly fixedSteps: number
77
+ /** Remaining accumulator fraction in the range `[0, 1)`. */
78
+ readonly alpha: number
79
+ /** Time discarded by frame clamping or the catch-up limit. */
80
+ readonly droppedSeconds: number
81
+ }
82
+
83
+ /** Failure delivered by an active loop before it stops itself. */
84
+ export type Failure<UpdateError> =
85
+ | {
86
+ readonly kind: "InvalidTickDelta"
87
+ readonly deltaMS: number
88
+ }
89
+ | {
90
+ readonly kind: "UpdateFailure"
91
+ readonly error: UpdateError
92
+ }
93
+
94
+ /** A running fixed-step driver. */
95
+ export interface Driver extends Subscription {
96
+ /** Clears accumulated partial time without removing the host subscription. */
97
+ reset(): void
98
+ }
99
+
100
+ /** Input for a fixed-step driver. */
101
+ export interface StartOptions<TTick extends Tick, UpdateError> extends Options {
102
+ readonly source: TickSource<TTick>
103
+ readonly update: (stepSeconds: number) => Result.Result<void, UpdateError>
104
+ readonly render: (frame: Frame) => void
105
+ readonly onFailure: (failure: Failure<UpdateError>) => void
106
+ }
107
+
108
+ /** Wraps a host-specific subscription function in the canonical source type. */
109
+ export const source = <TTick extends Tick>(
110
+ subscribe: TickSource<TTick>["subscribe"]
111
+ ): TickSource<TTick> => ({ subscribe })
112
+
113
+ const validate = (options: Options): Result.Result<void, InvalidOptions> => {
114
+ for (const field of ["maxFrameSeconds"] as const) {
115
+ const value = options[field]
116
+ if (!Number.isFinite(value) || value <= 0) {
117
+ return Result.failure({
118
+ kind: "InvalidFixedLoopOptions",
119
+ field,
120
+ reason: "mustBeFiniteAndPositive",
121
+ value
122
+ })
123
+ }
124
+ }
125
+ if (!Number.isInteger(options.maxStepsPerFrame) || options.maxStepsPerFrame <= 0) {
126
+ return Result.failure({
127
+ kind: "InvalidFixedLoopOptions",
128
+ field: "maxStepsPerFrame",
129
+ reason: "mustBePositiveInteger",
130
+ value: options.maxStepsPerFrame
131
+ })
132
+ }
133
+ return Result.success(undefined)
134
+ }
135
+
136
+ /**
137
+ * Starts a fixed-step loop after validating its timing policy.
138
+ *
139
+ * Update failures and malformed host ticks are delivered to `onFailure`, then
140
+ * the driver stops. Render defects are deliberately not caught: they are
141
+ * programmer defects, not expected game outcomes.
142
+ */
143
+ export const start = <TTick extends Tick, UpdateError>(
144
+ options: StartOptions<TTick, UpdateError>
145
+ ): Result.Result<Driver, InvalidOptions> => {
146
+ const validity = validate(options)
147
+ if (!validity.ok) {
148
+ return validity
149
+ }
150
+
151
+ let accumulator = 0
152
+ let stopped = false
153
+ let hostSubscription: Subscription | undefined
154
+
155
+ const stop = (): void => {
156
+ if (stopped) {
157
+ return
158
+ }
159
+ stopped = true
160
+ hostSubscription?.stop()
161
+ }
162
+
163
+ const fail = (failure: Failure<UpdateError>): void => {
164
+ stop()
165
+ options.onFailure(failure)
166
+ }
167
+
168
+ const onTick = (tick: TTick): void => {
169
+ if (stopped) {
170
+ return
171
+ }
172
+ if (!Number.isFinite(tick.deltaMS) || tick.deltaMS < 0) {
173
+ fail({ kind: "InvalidTickDelta", deltaMS: tick.deltaMS })
174
+ return
175
+ }
176
+
177
+ const rawSeconds = tick.deltaMS / 1000
178
+ const elapsedSeconds = Math.min(rawSeconds, options.maxFrameSeconds)
179
+ let droppedSeconds = rawSeconds - elapsedSeconds
180
+ accumulator += elapsedSeconds
181
+
182
+ let fixedSteps = 0
183
+ while (accumulator >= options.step.dt && fixedSteps < options.maxStepsPerFrame) {
184
+ const update = options.update(options.step.dt)
185
+ if (!update.ok) {
186
+ fail({ kind: "UpdateFailure", error: update.error })
187
+ return
188
+ }
189
+ accumulator -= options.step.dt
190
+ fixedSteps += 1
191
+ }
192
+
193
+ if (accumulator >= options.step.dt) {
194
+ const droppedWholeSteps = Math.floor(accumulator / options.step.dt) * options.step.dt
195
+ accumulator -= droppedWholeSteps
196
+ droppedSeconds += droppedWholeSteps
197
+ }
198
+
199
+ options.render({
200
+ elapsedSeconds,
201
+ fixedSteps,
202
+ alpha: accumulator / options.step.dt,
203
+ droppedSeconds
204
+ })
205
+ }
206
+
207
+ hostSubscription = options.source.subscribe(onTick)
208
+ if (stopped) {
209
+ hostSubscription.stop()
210
+ }
211
+
212
+ return Result.success({
213
+ stop,
214
+ reset() {
215
+ accumulator = 0
216
+ }
217
+ })
218
+ }
package/src/HostClock.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @example
15
15
  * ```ts
16
- * const runtime = Game.Runtime.make({ services, debug: { hostClock: HostClock.standard } })
16
+ * const runtime = Game.Runtime.make({ services, debug: { hostClock: HostClock.standard } }).value
17
17
  * ```
18
18
  *
19
19
  * @module HostClock
@@ -33,6 +33,7 @@ export interface HostClock {
33
33
  * 0. Prefer a monotonic platform clock when there is one
34
34
  * (`BrowserHostClock.make(window)`).
35
35
  */
36
+ // oxlint-disable-next-line ritmo/use-clock-for-time -- HostClock is host time, for hosts and tools; systems read Clock.
36
37
  export const standard: HostClock = { now: () => Date.now() }
37
38
 
38
39
  /** `adjust` must leave a finite clock reading and never move backwards. */
package/src/Machine.ts CHANGED
@@ -39,6 +39,7 @@
39
39
  */
40
40
  import type { AnyScheduleOrder, Schedule } from "./Schedule.ts"
41
41
  import * as Requirement from "./Requirement.ts"
42
+ import type * as Result from "./Result.ts"
42
43
  import type { Schema } from "./Schema.ts"
43
44
  import type { SystemFailure } from "./System.ts"
44
45
 
@@ -56,60 +57,71 @@ export type StateValue = string | number
56
57
  export interface StateMachineDefinition<
57
58
  out Name extends string = string,
58
59
  out Values extends readonly [StateValue, ...StateValue[]] = readonly [StateValue, ...StateValue[]],
59
- out Root = unknown
60
+ out Root = unknown,
61
+ out Parent = unknown
60
62
  > {
61
63
  readonly kind: "stateMachine"
62
64
  readonly name: Name
63
65
  readonly key: symbol
64
66
  readonly values: Values
67
+ /** For a sub-state: the machine and the state it exists in. */
68
+ readonly within?: SubStateParent | undefined
65
69
  readonly __schemaRoot?: Root | undefined
70
+ /** The machine a sub-state exists within; `unknown` for a top-level machine. */
71
+ readonly __parent?: (_: never) => Parent
72
+ }
73
+
74
+ /** Where a sub-state exists: while `machine` is in `state`. */
75
+ export interface SubStateParent {
76
+ readonly machine: StateMachine.Any
77
+ readonly state: StateValue
66
78
  }
67
79
 
68
80
  /**
69
- * The runtime-visible transition payload for one machine.
81
+ * Whether `M` is a sub-state (declared with `within`): it has no value while
82
+ * its parent machine is in another state.
70
83
  */
71
- export interface TransitionSnapshot<M extends StateMachineDefinition = StateMachineDefinition> {
72
- readonly from: StateMachine.Value<M>
73
- readonly to: StateMachine.Value<M>
74
- }
84
+ export type IsSubState<M> =
85
+ M extends { readonly __parent?: (_: never) => infer Parent } ? unknown extends Parent ? false : true : false
75
86
 
76
87
  /**
77
- * A typed read-only view over the current committed state.
88
+ * The committed value of `M`. A sub-state has none (`undefined`) while its
89
+ * parent machine is not in the state it exists in; a top-level machine
90
+ * always has one.
78
91
  */
79
- export interface MachineView<M extends StateMachineDefinition = StateMachineDefinition> {
80
- get(): StateMachine.Value<M>
81
- is(value: StateMachine.Value<M>): boolean
82
- }
92
+ export type CurrentValue<M extends StateMachine.Any> =
93
+ IsSubState<M> extends true ? StateMachine.Value<M> | undefined : StateMachine.Value<M>
83
94
 
84
95
  /**
85
- * A typed queued-write view over the next state.
96
+ * Why queuing a sub-state's next value failed: its parent machine is not in
97
+ * the state the sub-state exists in, so there is nothing to change.
86
98
  */
87
- export interface NextMachineView<M extends StateMachineDefinition = StateMachineDefinition> {
88
- getPending(): StateMachine.Value<M> | undefined
89
- set(value: StateMachine.Value<M>): void
90
- setIfChanged(value: StateMachine.Value<M>): void
91
- reset(): void
99
+ export interface SubStateInactive {
100
+ readonly _tag: "SubStateInactive"
101
+ /** The sub-state's name. */
102
+ readonly machine: string
103
+ /** The parent machine's name. */
104
+ readonly parent: string
105
+ /** The parent state the sub-state exists in. */
106
+ readonly requires: StateValue
92
107
  }
93
108
 
94
109
  /**
95
- * A typed read-only view over the last applied transition.
96
- *
97
- * This view is only meaningful inside transition schedules and is therefore
98
- * only exposed to systems that explicitly declare transition access.
110
+ * What `nextMachines.x.set(...)` returns: nothing for a top-level machine,
111
+ * and for a sub-state a `Result` that fails with `SubStateInactive` when its
112
+ * parent is in another state.
99
113
  */
100
- export interface TransitionView<M extends StateMachineDefinition = StateMachineDefinition> {
101
- get(): TransitionSnapshot<M>
102
- }
114
+ export type SetResult<M extends StateMachine.Any> =
115
+ IsSubState<M> extends true ? Result.Result<void, SubStateInactive> : void
103
116
 
104
117
  /**
105
- * A typed read-only stream of committed machine transition events.
106
- *
107
- * These snapshots are published by `applyStateTransitions(...)` when a
108
- * transition commits. Like events, each reader sees the transitions published
109
- * since its own previous run.
118
+ * The runtime-visible transition payload for one machine. For a sub-state,
119
+ * `from` is `undefined` when it starts existing (its parent entered its
120
+ * state) and `to` is `undefined` when it stops.
110
121
  */
111
- export interface TransitionEventView<M extends StateMachineDefinition = StateMachineDefinition> {
112
- all(): ReadonlyArray<TransitionSnapshot<M>>
122
+ export interface TransitionSnapshot<M extends StateMachineDefinition = StateMachineDefinition> {
123
+ readonly from: CurrentValue<M>
124
+ readonly to: CurrentValue<M>
113
125
  }
114
126
 
115
127
  /**
@@ -263,7 +275,7 @@ export interface TransitionScheduleDefinition<
263
275
  * Type-level and value-level helpers for finite-state machines.
264
276
  */
265
277
  export namespace StateMachine {
266
- export type Any = StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], unknown>
278
+ export type Any = StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], unknown, unknown>
267
279
  export type Value<M extends Any> = M["values"][number]
268
280
  export type Root<M extends Any> = M extends StateMachineDefinition<string, readonly [StateValue, ...StateValue[]], infer R> ? R : never
269
281
  export type AnyCondition<Root = unknown> = Condition<Root>
@@ -271,6 +283,23 @@ export namespace StateMachine {
271
283
  TransitionScheduleDefinition<S, Any, Requirement.Requirement, Root, SystemFailure>
272
284
  }
273
285
 
286
+ /**
287
+ * `unknown` when `M` was defined by the game rooted at `Root`; otherwise a
288
+ * type naming the fix, so passing another game's machine fails with that
289
+ * message.
290
+ */
291
+ export type SameGame<M, Root> =
292
+ M extends StateMachineDefinition<infer Name, readonly [StateValue, ...StateValue[]], infer MachineRoot>
293
+ ? [Root] extends [MachineRoot]
294
+ ? [MachineRoot] extends [Root]
295
+ ? unknown
296
+ : ForeignMachine<`${Name} is a state machine of another game: use a machine defined with this game's Game.StateMachine(...)`>
297
+ : ForeignMachine<`${Name} is a state machine of another game: use a machine defined with this game's Game.StateMachine(...)`>
298
+ : unknown
299
+
300
+ /** The message type `SameGame` resolves to: a type error prints `Message`. */
301
+ type ForeignMachine<Message extends string> = { readonly __foreignMachine__: Message }
302
+
274
303
  /** Extracts the machine token from one declared machine access slot. */
275
304
  type MachineFromAccess<Access> =
276
305
  Access extends { readonly machine: infer M extends StateMachine.Any } ? M : never
@@ -314,6 +343,9 @@ export type MachineNeedsFromConditions<C extends ReadonlyArray<Condition>> =
314
343
  *
315
344
  * Use `Descriptor.EntityState(...)` instead when every entity carries its own
316
345
  * state (per-entity, written immediately, no enter/exit schedules).
346
+ *
347
+ * Pass `within` for a sub-state: a machine that exists only while another
348
+ * machine is in one state (`Game.StateMachine(name, values, { within })`).
317
349
  */
318
350
  export const StateMachine = <
319
351
  const Name extends string,
@@ -321,12 +353,14 @@ export const StateMachine = <
321
353
  Root = unknown
322
354
  >(
323
355
  name: Name,
324
- values: Values
356
+ values: Values,
357
+ within?: SubStateParent
325
358
  ): StateMachineDefinition<Name, Values, Root> => ({
326
359
  kind: "stateMachine",
327
360
  name,
328
361
  key: Symbol.for(`ritmo/stateMachine/${name}`),
329
- values
362
+ values,
363
+ ...(within === undefined ? {} : { within })
330
364
  }) as StateMachineDefinition<Name, Values, Root>
331
365
 
332
366
  /**
@@ -384,7 +418,10 @@ export const inState = <M extends StateMachine.Any>(
384
418
  })
385
419
 
386
420
  /**
387
- * Creates a condition that passes when the machine changed during the current schedule execution.
421
+ * Creates a condition that passes when the machine changed during the current schedule execution:
422
+ * from the `applyStateTransitions(...)` that committed the change to the end of
423
+ * that schedule (or the next `applyStateTransitions(...)`), not for the rest of
424
+ * the tick call or the next schedule.
388
425
  */
389
426
  export const stateChanged = <M extends StateMachine.Any>(
390
427
  machine: M
package/src/Query.ts CHANGED
@@ -69,6 +69,7 @@
69
69
  * Query authoring helpers for selecting components and declaring explicit filters.
70
70
  */
71
71
  import type { DecodeError } from "./Decode.ts"
72
+ import * as DescriptorModule from "./Descriptor.ts"
72
73
  import type { Descriptor, EntityStateValue } from "./Descriptor.ts"
73
74
  import type { EntityId, EntityMut, EntityRef } from "./Entity.ts"
74
75
  import type * as Result from "./Result.ts"
@@ -179,10 +180,24 @@ export interface ChangedFilter<D extends ComponentDescriptor> {
179
180
  readonly descriptor: D
180
181
  }
181
182
 
182
- export type Filter<D extends ComponentDescriptor> =
183
+ /** An `added` or `changed` filter: the filters `anyOf` combines. */
184
+ export type LifecycleFilter<D extends ComponentDescriptor> =
183
185
  | AddedFilter<D>
184
186
  | ChangedFilter<D>
185
187
 
188
+ /**
189
+ * A filter that matches entities passing at least one of its `added` /
190
+ * `changed` filters since the reading system's previous run.
191
+ */
192
+ export interface AnyOfFilter<D extends ComponentDescriptor> {
193
+ readonly kind: "anyOf"
194
+ readonly filters: ReadonlyArray<LifecycleFilter<D>>
195
+ }
196
+
197
+ export type Filter<D extends ComponentDescriptor> =
198
+ | LifecycleFilter<D>
199
+ | AnyOfFilter<D>
200
+
186
201
  /**
187
202
  * Declares read-only access to a component in a query selection.
188
203
  *
@@ -203,11 +218,29 @@ export const read = <D extends ComponentDescriptor>(descriptor: D): ReadAccess<D
203
218
  * place. A write slot both requires component presence and exposes the slot as
204
219
  * a `WriteCell`, so mutation capability stays visible in the query spec rather
205
220
  * than appearing ad hoc in the loop body.
221
+ *
222
+ * Components declared `immutable: true` cannot be written: replace them with
223
+ * `commands.insert(...)` instead.
206
224
  */
207
- export const write = <D extends ComponentDescriptor>(descriptor: D): WriteAccess<D> => ({
208
- mode: "write",
209
- descriptor
210
- })
225
+ export const write = <D extends ComponentDescriptor>(descriptor: D & Writable<D>): WriteAccess<D> => {
226
+ if (DescriptorModule.isImmutable(descriptor)) {
227
+ throw new Error(immutableMessage(descriptor.name))
228
+ }
229
+ return { mode: "write", descriptor }
230
+ }
231
+
232
+ const immutableMessage = (name: string) =>
233
+ `${name} is immutable: replace it with commands.insert(entity, Game.Command.entry(${name}, ...))`
234
+
235
+ type NameOf<D> = D extends { readonly name: infer Name extends string } ? Name : never
236
+
237
+ /**
238
+ * `unknown` for a component a query may write; for one declared
239
+ * `immutable: true`, an argument error naming the fix.
240
+ */
241
+ export type Writable<D> = D extends DescriptorModule.Immutable
242
+ ? { readonly __immutable__: `${NameOf<D>} is immutable: replace it with commands.insert(entity, Game.Command.entry(${NameOf<D>}, ...))` }
243
+ : unknown
211
244
 
212
245
  /**
213
246
  * Declares maybe-present read-only access to a component in a query
@@ -265,7 +298,8 @@ export const added = <D extends ComponentDescriptor>(descriptor: D): AddedFilter
265
298
  *
266
299
  * Any write through a write cell counts, even when the value is equal to the
267
300
  * previous one. Writes from a system whose run failed are rolled back and do
268
- * not count.
301
+ * not count. A system never sees its own writes as changes on its next run
302
+ * (it does see the events it emitted).
269
303
  *
270
304
  * @example
271
305
  * ```ts
@@ -282,6 +316,34 @@ export const changed = <D extends ComponentDescriptor>(descriptor: D): ChangedFi
282
316
  descriptor
283
317
  })
284
318
 
319
+ /**
320
+ * Declares a filter that matches entities passing at least one of `filters`
321
+ * (`added` / `changed`) since the reading system's previous run. Each entity
322
+ * is matched once, however many of its filters pass.
323
+ *
324
+ * Filters listed side by side in `filters: [...]` must all pass; use `anyOf`
325
+ * when any one of them is enough, for example to redraw when either the
326
+ * position or the sprite changed.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * const RedrawQuery = Game.Query({
331
+ * selection: { position: Game.Query.read(Position), sprite: Game.Query.read(Sprite) },
332
+ * filters: [Game.Query.anyOf(Game.Query.changed(Position), Game.Query.changed(Sprite))]
333
+ * })
334
+ * ```
335
+ */
336
+ export const anyOf = <
337
+ const Filters extends readonly [
338
+ LifecycleFilter<ComponentDescriptor>,
339
+ LifecycleFilter<ComponentDescriptor>,
340
+ ...ReadonlyArray<LifecycleFilter<ComponentDescriptor>>
341
+ ]
342
+ >(...filters: Filters): AnyOfFilter<Filters[number]["descriptor"]> => ({
343
+ kind: "anyOf",
344
+ filters
345
+ })
346
+
285
347
  /**
286
348
  * A fully explicit query specification.
287
349
  *
@@ -366,6 +428,8 @@ export namespace Query {
366
428
  */
367
429
  export type Root<T extends Any> = T extends QuerySpec<any, any, any, any, any, any, any, any, infer R> ? R : never
368
430
 
431
+ type FilteredDescriptors<F> = F extends { readonly kind: "added" | "changed"; readonly descriptor: infer D } ? D : never
432
+
369
433
  type RequiredSelectionDescriptors<T extends Any> = {
370
434
  readonly [K in keyof T["selection"]]:
371
435
  T["selection"][K] extends ReadAccess<infer D> | WriteAccess<infer D> ? D : never
@@ -380,12 +444,19 @@ export namespace Query {
380
444
  export type ProvesComponent<
381
445
  T extends Any,
382
446
  D extends ComponentDescriptor
383
- > = [Extract<RequiredSelectionDescriptors<T> | T["with"][number], D>] extends [never]
447
+ > = [Extract<Proven<T>, D>] extends [never]
384
448
  ? false
385
449
  : true
386
450
 
387
- /** The component descriptors a query proves its matches have: required selection slots and `with`. */
388
- export type Proven<T extends Any> = RequiredSelectionDescriptors<T> | T["with"][number]
451
+ /**
452
+ * The component descriptors a query proves its matches have: required
453
+ * selection slots, `with`, and the component of each `added` / `changed`
454
+ * filter (a match has it). A `Query.anyOf` member proves nothing alone.
455
+ */
456
+ export type Proven<T extends Any> = RequiredSelectionDescriptors<T> | T["with"][number] | FilteredDescriptors<T["filters"][number]>
457
+
458
+ /** The component descriptors a query proves its matches lack: its `without`. */
459
+ export type Excluded<T extends Any> = T["without"][number]
389
460
 
390
461
  /**
391
462
  * The readable proof produced by a query.
@@ -498,13 +569,14 @@ export interface ReadCell<T> {
498
569
  */
499
570
  export interface WriteCell<T> extends ReadCell<T> {
500
571
  /**
501
- * Replaces the current value.
572
+ * Replaces the current value. Takes the readonly shape reads return, so a
573
+ * value read from any cell can be written back as it is.
502
574
  */
503
- set(value: T): void
575
+ set(value: ReadonlyValue<T>): void
504
576
  /**
505
577
  * Updates the current value based on the previous one.
506
578
  */
507
- update(f: (current: ReadonlyValue<T>) => T): void
579
+ update(f: (current: ReadonlyValue<T>) => ReadonlyValue<T>): void
508
580
  }
509
581
 
510
582
  /**
@@ -562,7 +634,10 @@ export interface EntityStateMismatchError<T> {
562
634
  */
563
635
  export interface EntityStateWriteCell<T, Transitions> extends ConstructedWriteCell<T, T, DecodeError> {
564
636
  /**
565
- * Moves from `from` to `to` if the component is still in `from`.
637
+ * Moves from `from` to `to` if the component is still in `from`. Use it for
638
+ * every move the transition table describes; `set` writes any state
639
+ * without checking the table, for resets that must not depend on the
640
+ * current state.
566
641
  *
567
642
  * @example
568
643
  * ```ts
@@ -596,6 +671,8 @@ export type WriteCellForDescriptor<D extends ComponentDescriptor> =
596
671
  */
597
672
  export interface PresentOptionalReadCell<T> extends ReadCell<T> {
598
673
  readonly present: true
674
+ /** The value, which is present. */
675
+ value(): T
599
676
  }
600
677
 
601
678
  /**
@@ -603,12 +680,17 @@ export interface PresentOptionalReadCell<T> extends ReadCell<T> {
603
680
  */
604
681
  export interface AbsentOptionalReadCell {
605
682
  readonly present: false
683
+ /** `undefined`: the entity does not hold the component. */
684
+ value(): undefined
606
685
  }
607
686
 
608
687
  /**
609
688
  * A query slot that may or may not be present on the matched entity.
610
689
  *
611
- * Callers must narrow on `present` before reading the value.
690
+ * Read it with `value()`: the value, or `undefined` when the entity does not
691
+ * hold the component. Narrow on `present` (then `get()`) only when the
692
+ * component's own value can be `undefined`, where `value()` cannot tell the
693
+ * two apart.
612
694
  */
613
695
  export type OptionalReadCell<T> = PresentOptionalReadCell<T> | AbsentOptionalReadCell
614
696
 
@@ -621,11 +703,11 @@ export type OptionalReadCell<T> = PresentOptionalReadCell<T> | AbsentOptionalRea
621
703
  export type QueryMatch<S extends Schema.Any, Q extends Query.Any> =
622
704
  keyof Query.WriteProof<Q> extends never
623
705
  ? {
624
- readonly entity: EntityRef<S, Query.ReadProof<Q>, Query.Root<Q>, Query.Proven<Q>>
706
+ readonly entity: EntityRef<S, Query.ReadProof<Q>, Query.Root<Q>, Query.Proven<Q>, Query.Excluded<Q>>
625
707
  readonly data: Query.Cells<Q>
626
708
  }
627
709
  : {
628
- readonly entity: EntityMut<S, Query.ReadProof<Q>, Query.WriteProof<Q>, Query.Root<Q>, Query.Proven<Q>>
710
+ readonly entity: EntityMut<S, Query.ReadProof<Q>, Query.WriteProof<Q>, Query.Root<Q>, Query.Proven<Q>, Query.Excluded<Q>>
629
711
  readonly data: Query.Cells<Q>
630
712
  }
631
713
 
@@ -692,6 +774,25 @@ export const multipleEntitiesError = (count: number): Query.MultipleEntitiesErro
692
774
  // `T` appears only as the check type, which inference never reads.
693
775
  export type Uninferred<T, Bound> = [T] extends [infer U extends Bound] ? U : never
694
776
 
777
+ type DescriptorName<D> = D extends { readonly name: infer Name extends string } ? Name : never
778
+
779
+ /** The names a query requires: its read and write slots, `with`, and plain `added` / `changed` filters. */
780
+ type RequiredNames<Selection, With extends ReadonlyArray<unknown>, Filters extends ReadonlyArray<unknown>> =
781
+ | DescriptorName<{ readonly [K in keyof Selection]: Selection[K] extends { readonly mode: "read" | "write"; readonly descriptor: infer D } ? D : never }[keyof Selection]>
782
+ | DescriptorName<With[number]>
783
+ | DescriptorName<Filters[number] extends infer F ? F extends { readonly kind: "added" | "changed"; readonly descriptor: infer D } ? D : never : never>
784
+
785
+ /**
786
+ * Rejects a `without` naming a component the query also requires: such a
787
+ * query can never match. Use it on the `without` list of a query spec.
788
+ */
789
+ export type ExclusionsPossible<Selection, With extends ReadonlyArray<unknown>, Without extends ReadonlyArray<unknown>, Filters extends ReadonlyArray<unknown>> =
790
+ [Extract<DescriptorName<Without[number]>, RequiredNames<Selection, With, Filters>>] extends [never]
791
+ ? unknown
792
+ : {
793
+ readonly __fixQuery__: `${Extract<DescriptorName<Without[number]>, RequiredNames<Selection, With, Filters>>} is both required and excluded (without): this query can never match; remove it from one side`
794
+ }
795
+
695
796
  export const Query = <
696
797
  const Selection extends Record<string, SelectionAccess<any, Root>>,
697
798
  const With extends ReadonlyArray<ComponentDescriptor> = [],
@@ -705,7 +806,7 @@ export const Query = <
705
806
  >(spec: {
706
807
  readonly selection: Selection
707
808
  readonly with?: With
708
- readonly without?: Without
809
+ readonly without?: Without & ExclusionsPossible<Selection, With, Without, Filters>
709
810
  readonly filters?: Filters
710
811
  readonly withRelations?: WithRelations
711
812
  readonly withoutRelations?: WithoutRelations