@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/Runtime.ts CHANGED
@@ -23,7 +23,8 @@
23
23
  * Game.Runtime.service(Random, { next: Math.random })
24
24
  * )
25
25
  *
26
- * // Viewport is a constructed resource, so the runtime comes back as a Result.
26
+ * // make always returns a Result. Viewport is a constructed resource, so
27
+ * // validating it can fail and the Result must be checked.
27
28
  * const runtime = Game.Runtime.make({
28
29
  * services,
29
30
  * resources: {
@@ -88,7 +89,8 @@ import type {
88
89
  ResourceChangedView,
89
90
  EventWriteView,
90
91
  LookupApi,
91
- QueryHandle,
92
+ QueryIteration,
93
+ QueryLookups,
92
94
  RelationFailureReadView,
93
95
  RemovedReadView,
94
96
  TransitionEventReadView,
@@ -118,7 +120,7 @@ import type {
118
120
  * services: Game.Runtime.services(
119
121
  * Game.Runtime.service(Logger, { log: console.log })
120
122
  * )
121
- * })
123
+ * }).value
122
124
  *
123
125
  * runtime.tick(updateSchedule)
124
126
  * ```
@@ -157,6 +159,17 @@ export type RuntimeResources<S extends Schema.Any> = Partial<{
157
159
  : Schema.ResourceValue<S, K>
158
160
  }>
159
161
 
162
+ /**
163
+ * Rejects keys of `Provided` that are not resource keys of the schema, naming
164
+ * each one. A misspelled key next to known ones would otherwise compile and
165
+ * be dropped.
166
+ */
167
+ export type ExactResources<S extends Schema.Any, Provided> = {
168
+ readonly [K in Exclude<keyof Provided, keyof Schema.Resources<S>>]: {
169
+ readonly __fixResources__: `${K & string} is not a resource key of this schema: use a key of the fragment's resources: { ... }`
170
+ }
171
+ }
172
+
160
173
  type ConstructedKeys<S extends Schema.Any, Provided> = {
161
174
  readonly [K in keyof Provided]: K extends keyof Schema.Resources<S>
162
175
  ? Schema.Resources<S>[K] extends DescriptorModule.ConstructedDescriptor<"resource", string, any, any, any> ? K : never
@@ -193,8 +206,9 @@ export type WithDebug<R, Enabled, S extends Schema.Any, Root> =
193
206
  : R & Partial<Debug.Member<S, Root>>
194
207
 
195
208
  /**
196
- * The runtime returned by `make`, wrapped in a `Result` exactly when a
197
- * provided resource goes through a constructor and can therefore fail.
209
+ * What `make` returns: always a `Result`. It is a `Success` when nothing
210
+ * provided can fail, so `.value` is the runtime; when a provided resource
211
+ * goes through a constructor, it may be a `Failure` and must be checked.
198
212
  */
199
213
  export type MakeRuntimeResult<
200
214
  S extends Schema.Any,
@@ -204,7 +218,7 @@ export type MakeRuntimeResult<
204
218
  Machines extends Record<string, unknown>,
205
219
  Enabled = undefined
206
220
  > = [ConstructedKeys<S, Provided>] extends [never]
207
- ? WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>
221
+ ? Result.Success<WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>>
208
222
  : Result.Result<
209
223
  WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>,
210
224
  RuntimeConstructionError<S, Provided>
@@ -637,12 +651,16 @@ export interface Runtime<
637
651
  * resources are skipped.
638
652
  *
639
653
  * Callable only when every component and resource is constructed or
640
- * transient; see the `Snapshot` module.
654
+ * transient; see the `Snapshot` module. Take it between ticks: calling it
655
+ * while a schedule is running (from a system, a service, or a trace
656
+ * callback) throws.
641
657
  */
642
658
  readonly snapshot: Snapshot.Gate<S, () => Snapshot.WorldSnapshot>
643
659
  /**
644
660
  * Replaces the world with a snapshot after validating it against the
645
- * schema. On failure nothing changes. See the `Snapshot` module.
661
+ * schema. On failure nothing changes. See the `Snapshot` module. Restore
662
+ * between ticks: calling it while a schedule is running throws. A current
663
+ * resource the save lacks keeps its value.
646
664
  */
647
665
  readonly restore: Snapshot.Gate<S, (data: unknown) => Result.Result<void, Snapshot.RestoreError>>
648
666
  /**
@@ -661,7 +679,7 @@ export interface Runtime<
661
679
  * The event must belong to the schema. Calling it while a schedule is
662
680
  * running is a defect and throws: inside a tick, emit from a system.
663
681
  */
664
- readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: Descriptor.Value<D>) => void
682
+ readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: ReadonlyValue<Descriptor.Value<D>>) => void
665
683
  }
666
684
 
667
685
  /**
@@ -685,7 +703,7 @@ export interface Runtime<
685
703
  * resources: {
686
704
  * score: 0
687
705
  * }
688
- * })
706
+ * }).value
689
707
  * ```
690
708
  */
691
709
  /**
@@ -696,6 +714,23 @@ export interface Runtime<
696
714
  */
697
715
  export const streamCapacity = 65_536
698
716
 
717
+ /**
718
+ * Brings a parsed save to `migrations.latest`: runs every step after its
719
+ * game version, checking the structure after each.
720
+ */
721
+ const migrateSave = (save: Snapshot.WorldSnapshot, saves: Snapshot.Migrations | undefined): Result.Result<Snapshot.WorldSnapshot, Snapshot.RestoreError> => {
722
+ const latest = saves?.latest ?? 0
723
+ if (save.gameVersion > latest) return Result.failure({ _tag: "NewerGameVersion", gameVersion: save.gameVersion, latest })
724
+ let current = save
725
+ for (const step of saves?.steps ?? []) {
726
+ if (step.to <= current.gameVersion) continue
727
+ const next = Snapshot.parse({ ...step.migrate(current), gameVersion: step.to })
728
+ if (!next.ok) return next
729
+ current = next.value
730
+ }
731
+ return Result.success(current)
732
+ }
733
+
699
734
  const makeValidatedRuntime = <
700
735
  S extends Schema.Any,
701
736
  Services extends Record<string, unknown>,
@@ -710,6 +745,7 @@ const makeValidatedRuntime = <
710
745
  readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
711
746
  readonly scopeDefinitions: ReadonlyArray<EntityScopeModule.EntityScope.Any>
712
747
  readonly debug: Debug.Option | undefined
748
+ readonly saves: Snapshot.Migrations | undefined
713
749
  }): Runtime<S, Services, Resources, Root, Machines> => {
714
750
  const world = makeWorld(options.schema, streamCapacity)
715
751
  const queries = makeQueryEngine(world)
@@ -724,7 +760,10 @@ const makeValidatedRuntime = <
724
760
  * Machine-keyed committed, pending, and previous values.
725
761
  */
726
762
  const currentMachines = new Map<symbol, unknown>()
727
- const pendingMachines = new Map<symbol, { value: unknown; skipIfSame: boolean }>()
763
+ /** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
764
+ const pendingMachines = new Map<symbol, { value: unknown; skipIfSame: boolean; by?: string }>()
765
+ /** Requests dropped because their sub-state stopped existing: skipped by the marker that queued them. */
766
+ const discardedPending = new WeakSet<object>()
728
767
  const previousMachines = new Map<symbol, unknown>()
729
768
  /**
730
769
  * Machine keys changed by transitions applied during the current schedule run.
@@ -886,6 +925,10 @@ const makeValidatedRuntime = <
886
925
  */
887
926
  const commandUses = new Map<string, Set<string>>()
888
927
  const ranSystems = new Set<string>()
928
+ /** With `debug`, the system running now: who queued a next state. */
929
+ let runningSystem = "(host)"
930
+ /** With `debug`, next states one system queued and another replaced before the marker. */
931
+ const nextStateReplacements = new Map<string, Debug.NextStateReplacement>()
889
932
  const noteCommand = (kind: string, name: string): void => {
890
933
  let uses = commandUses.get(commandOrigin)
891
934
  if (uses === undefined) {
@@ -925,6 +968,39 @@ const makeValidatedRuntime = <
925
968
  requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 })
926
969
  }
927
970
  }
971
+ const streamLosses = new Map<string, Debug.StreamLoss>()
972
+ const watchStreamLoss = <V extends { all(): unknown; lagged(): boolean }>(view: V, system: string, stream: string, paused: boolean): V => {
973
+ const key = `${system}/${stream}`
974
+ const entry = (): { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] } => {
975
+ let found = streamLosses.get(key)
976
+ if (found === undefined) {
977
+ found = { system, stream, lostRuns: 0, checksLagged: false, paused }
978
+ streamLosses.set(key, found)
979
+ }
980
+ return found as { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] }
981
+ }
982
+ return {
983
+ ...view,
984
+ all: () => {
985
+ const status = entry()
986
+ if (view.lagged()) status.lostRuns += 1
987
+ return view.all()
988
+ },
989
+ lagged: () => {
990
+ entry().checksLagged = true
991
+ return view.lagged()
992
+ }
993
+ }
994
+ }
995
+ const staleTargets = new Map<string, Debug.StaleTarget>()
996
+ const reportStaleTarget = (command: Debug.StaleTarget["command"], entity: number): void => {
997
+ if (tracing) recordEffect({ kind: "staleTarget", entity, command })
998
+ if (debugEnabled) {
999
+ const key = `${commandOrigin}/${command}`
1000
+ const seen = staleTargets.get(key)
1001
+ staleTargets.set(key, { system: commandOrigin, command, entity, count: (seen?.count ?? 0) + 1 })
1002
+ }
1003
+ }
928
1004
  const holds = (id: number) => {
929
1005
  const record = world.records.get(id)
930
1006
  return (descriptor: Descriptor<"component", string, any>): boolean =>
@@ -1007,6 +1083,9 @@ const makeValidatedRuntime = <
1007
1083
  world.writeComponent(id, descriptor, value)
1008
1084
  }
1009
1085
  },
1086
+ reportStaleTarget(command, id) {
1087
+ reportStaleTarget(command, id)
1088
+ },
1010
1089
  tryRelate(id, relation, target) {
1011
1090
  if (debugEnabled) {
1012
1091
  noteRelationMutation(relation)
@@ -1140,7 +1219,7 @@ const makeValidatedRuntime = <
1140
1219
  return lookup
1141
1220
  }
1142
1221
 
1143
- const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryHandle<S, Q> => {
1222
+ const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryIteration<S, Q> & QueryLookups<S, Q> => {
1144
1223
  const each = () => queries.each(query, reader.since) as ReadonlyArray<QueryMatch<S, Q>>
1145
1224
  const matching = (ids: ReadonlyArray<Entity.EntityId<S, Root>>): Array<QueryMatch<S, Q>> => {
1146
1225
  const matches: Array<QueryMatch<S, Q>> = []
@@ -1198,6 +1277,32 @@ const makeValidatedRuntime = <
1198
1277
  const machineOriginals = new Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>()
1199
1278
  const emittedEvents = new Map<symbol, Array<unknown>>()
1200
1279
 
1280
+ /**
1281
+ * The open phase, or `undefined`. A transition's exit phase (the exit
1282
+ * schedules of the sub-states it ends and of the state it leaves, then its
1283
+ * `onTransition` schedules) commits or rolls back as one: when one of its
1284
+ * systems fails, the writes, events, queued next states, queued commands,
1285
+ * and stream positions of the systems that ran before it are undone too, so
1286
+ * the retry at a later marker starts from the same state.
1287
+ */
1288
+ interface Phase {
1289
+ readonly tick: number
1290
+ readonly commands: number
1291
+ readonly origins: number
1292
+ readonly resources: Map<symbol, unknown>
1293
+ readonly resourceChangedAt: Map<symbol, number | undefined>
1294
+ readonly machines: Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>
1295
+ readonly readers: Map<ReaderState, readonly [since: number, lastRun: number, streamSince: number, streamLastRun: number]>
1296
+ }
1297
+ let phase: Phase | undefined
1298
+
1299
+ /** Keeps a reader's position from before the phase, the first time the phase moves it. */
1300
+ const journalReader = (reader: ReaderState): void => {
1301
+ if (phase !== undefined && !phase.readers.has(reader)) {
1302
+ phase.readers.set(reader, [reader.since, reader.lastRun, reader.streamSince, reader.streamLastRun])
1303
+ }
1304
+ }
1305
+
1201
1306
  const journalStoreWrite = (store: Map<symbol, unknown>, originals: Map<symbol, unknown>) =>
1202
1307
  (key: symbol, value: unknown): void => {
1203
1308
  if (!originals.has(key)) {
@@ -1228,10 +1333,19 @@ const makeValidatedRuntime = <
1228
1333
 
1229
1334
  const commitSystemTransaction = (): void => {
1230
1335
  world.commitTransaction()
1336
+ if (phase !== undefined) {
1337
+ for (const [key, before] of resourceOriginals) {
1338
+ if (!phase.resources.has(key)) phase.resources.set(key, before)
1339
+ }
1340
+ for (const [key, before] of machineOriginals) {
1341
+ if (!phase.machines.has(key)) phase.machines.set(key, before)
1342
+ }
1343
+ }
1231
1344
  if (watchedResources.size > 0) {
1232
1345
  for (const [key, before] of resourceOriginals) {
1233
1346
  if (!watchedResources.has(key)) continue
1234
1347
  if (before === absentValue || !sameValue(before, resources.get(key))) {
1348
+ if (phase !== undefined && !phase.resourceChangedAt.has(key)) phase.resourceChangedAt.set(key, resourceChangedAt.get(key))
1235
1349
  resourceChangedAt.set(key, world.currentTick())
1236
1350
  }
1237
1351
  }
@@ -1272,6 +1386,54 @@ const makeValidatedRuntime = <
1272
1386
  emittedEvents.clear()
1273
1387
  }
1274
1388
 
1389
+ /**
1390
+ * Runs `run` as one phase: on failure, or a thrown defect, everything the
1391
+ * systems in it committed is undone.
1392
+ */
1393
+ const runAsPhase = (run: () => Result.Result<void, SystemFailure>): Result.Result<void, SystemFailure> => {
1394
+ const opened: Phase = {
1395
+ tick: world.currentTick(),
1396
+ commands: pendingCommands.length,
1397
+ origins: pendingOrigins.length,
1398
+ resources: new Map(),
1399
+ resourceChangedAt: new Map(),
1400
+ machines: new Map(),
1401
+ readers: new Map()
1402
+ }
1403
+ phase = opened
1404
+ world.beginPhase()
1405
+ let result: Result.Result<void, SystemFailure> | undefined
1406
+ try {
1407
+ result = run()
1408
+ } finally {
1409
+ phase = undefined
1410
+ if (result?.ok === true) {
1411
+ world.commitPhase()
1412
+ } else {
1413
+ world.rollbackPhase()
1414
+ restoreStore(resources, opened.resources)
1415
+ for (const [key, value] of opened.machines) {
1416
+ if (value === absentValue) pendingMachines.delete(key)
1417
+ else pendingMachines.set(key, value)
1418
+ }
1419
+ for (const [key, tick] of opened.resourceChangedAt) {
1420
+ if (tick === undefined) resourceChangedAt.delete(key)
1421
+ else resourceChangedAt.set(key, tick)
1422
+ }
1423
+ for (const [reader, [since, lastRun, streamSince, streamLastRun]] of opened.readers) {
1424
+ reader.since = since
1425
+ reader.lastRun = lastRun
1426
+ reader.streamSince = streamSince
1427
+ reader.streamLastRun = streamLastRun
1428
+ }
1429
+ events.retract(opened.tick)
1430
+ pendingCommands.length = opened.commands
1431
+ pendingOrigins.length = opened.origins
1432
+ }
1433
+ }
1434
+ return result
1435
+ }
1436
+
1275
1437
  const makeResourceWriteView = (
1276
1438
  descriptor: Descriptor<"resource", string, any>,
1277
1439
  store: Map<symbol, unknown>,
@@ -1371,7 +1533,23 @@ const makeValidatedRuntime = <
1371
1533
  } satisfies Machine.SubStateInactive) as Machine.SetResult<M>
1372
1534
  }
1373
1535
  journalMachine(stateMachine.key)
1374
- pendingMachines.set(stateMachine.key, { value, skipIfSame })
1536
+ if (debugEnabled) {
1537
+ const previous = pendingMachines.get(stateMachine.key)
1538
+ // A set from a transition schedule waits for the next marker: it replaces nothing queued for this one.
1539
+ if (previous?.by !== undefined && previous.by !== runningSystem && previous.value !== value && activeTransitions.size === 0) {
1540
+ const key = `${stateMachine.name}/${previous.by}/${runningSystem}/${String(previous.value)}/${String(value)}`
1541
+ const seen = nextStateReplacements.get(key)
1542
+ nextStateReplacements.set(key, {
1543
+ machine: stateMachine.name,
1544
+ replaced: { system: previous.by, value: previous.value as Machine.StateValue },
1545
+ by: { system: runningSystem, value: value as Machine.StateValue },
1546
+ count: (seen?.count ?? 0) + 1
1547
+ })
1548
+ }
1549
+ pendingMachines.set(stateMachine.key, { value, skipIfSame, by: runningSystem })
1550
+ } else {
1551
+ pendingMachines.set(stateMachine.key, { value, skipIfSame })
1552
+ }
1375
1553
  return (within === undefined ? undefined : succeeded) as Machine.SetResult<M>
1376
1554
  }
1377
1555
 
@@ -1433,9 +1611,14 @@ const makeValidatedRuntime = <
1433
1611
  const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState, queue: Command.CommandQueue<S, Root>): SystemContext<any> => {
1434
1612
  const spec = system.spec
1435
1613
  const lookup = makeLookup(reader)
1614
+ const paused = (spec.pauseWhen as ReadonlyArray<unknown>).length > 0
1615
+ /** On a debug runtime, records reads after a loss and calls of `lagged()`, for the `stream-loss-unread` lint. */
1616
+ const watch = <V extends { all(): unknown; lagged(): boolean }>(view: V, stream: string): V =>
1617
+ debugEnabled ? watchStreamLoss(view, system.name, stream, paused) : view
1436
1618
  return {
1437
1619
  local: (spec.local as (() => unknown) | undefined)?.(),
1438
- queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup)),
1620
+ // A filtered query hands out the same object; its type hides the lookups.
1621
+ queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup) as never),
1439
1622
  lookup,
1440
1623
  resources: mapRecord(spec.resources as Record<string, any>, (access) =>
1441
1624
  access.mode === "read"
@@ -1445,15 +1628,15 @@ const makeValidatedRuntime = <
1445
1628
  : makeResourceWriteView(access.descriptor, resources, writeResource)),
1446
1629
  events: mapRecord(spec.events as Record<string, any>, (access) =>
1447
1630
  access.mode === "read"
1448
- ? makeEventReadView(access.descriptor.key, reader)
1631
+ ? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
1449
1632
  : makeEventWriteView(access.descriptor.key)),
1450
1633
  machines: mapRecord(spec.machines as Record<string, any>, (access) => makeMachineReadView(access.machine)),
1451
1634
  nextMachines: mapRecord(spec.nextMachines as Record<string, any>, (access) => makeNextMachineWriteView(access.machine)),
1452
- transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => makeTransitionEventReadView(access.machine, reader)),
1635
+ transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => watch(makeTransitionEventReadView(access.machine, reader), `transition events ${access.machine.name}`)),
1453
1636
  transitions: mapRecord(spec.transitions as Record<string, any>, (access) => makeTransitionReadView(access.machine)),
1454
- removed: mapRecord(spec.removed as Record<string, any>, (access) => makeRemovedReadView(access.descriptor, reader)),
1455
- despawned: mapRecord(spec.despawned as Record<string, any>, () => makeDespawnedReadView(reader)),
1456
- relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => makeRelationFailureReadView(access.relation, reader)),
1637
+ removed: mapRecord(spec.removed as Record<string, any>, (access) => watch(makeRemovedReadView(access.descriptor, reader), `removed ${access.descriptor.name}`)),
1638
+ despawned: mapRecord(spec.despawned as Record<string, any>, () => watch(makeDespawnedReadView(reader), "despawned")),
1639
+ relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => watch(makeRelationFailureReadView(access.relation, reader), `relation failures ${access.relation.name}`)),
1457
1640
  services: mapRecord(spec.services as Record<string, any>, (access) =>
1458
1641
  providedServices[access.descriptor.name as keyof Services]),
1459
1642
  // The queue serves every declared permission set; the spec's types narrow it.
@@ -1646,6 +1829,7 @@ const makeValidatedRuntime = <
1646
1829
  // neither holds them nor receives a backlog when it runs again.
1647
1830
  const skipped = slotOf(system, true)
1648
1831
  if (tracing) traceSkipped(system, condition, skipped.reader, false)
1832
+ journalReader(skipped.reader)
1649
1833
  skipped.reader.streamLastRun = world.currentTick()
1650
1834
  return succeeded
1651
1835
  }
@@ -1661,7 +1845,11 @@ const makeValidatedRuntime = <
1661
1845
  }
1662
1846
  }
1663
1847
  const { context, commands, reader } = slotOf(system, true)
1664
- if (debugEnabled) ranSystems.add(system.name)
1848
+ if (debugEnabled) {
1849
+ ranSystems.add(system.name)
1850
+ runningSystem = system.name
1851
+ }
1852
+ journalReader(reader)
1665
1853
  // Changes stamped after the previous completed run are visible to this run.
1666
1854
  reader.since = reader.lastRun
1667
1855
  reader.streamSince = reader.streamLastRun
@@ -1833,11 +2021,17 @@ const makeValidatedRuntime = <
1833
2021
 
1834
2022
  const schedules = bundle?.entries ?? []
1835
2023
  for (const [machineKey, pending] of scheduledTransitions) {
1836
- if (pendingMachines.get(machineKey) !== pending) {
2024
+ if (discardedPending.has(pending)) {
1837
2025
  // Discarded earlier in this marker: its parent left the state it exists in.
1838
2026
  continue
1839
2027
  }
1840
- pendingMachines.delete(machineKey)
2028
+ // A transition schedule earlier in this marker may have queued a newer
2029
+ // value for this machine: that one waits for the next marker.
2030
+ if (pendingMachines.get(machineKey) === pending) pendingMachines.delete(machineKey)
2031
+ /** Keeps this request queued after a failure, unless a newer one replaced it. */
2032
+ const requeue = () => {
2033
+ if (!pendingMachines.has(machineKey)) pendingMachines.set(machineKey, pending)
2034
+ }
1841
2035
  const current = currentMachines.get(machineKey)
1842
2036
  if (current === undefined) {
1843
2037
  continue
@@ -1855,48 +2049,44 @@ const makeValidatedRuntime = <
1855
2049
  // Sub-states that exist in the state being left exit first, deepest
1856
2050
  // first: their exit schedules run before the parent's, while they
1857
2051
  // still have their values, and they lose them at the parent's commit.
2052
+ // The exit phase is one transaction: a failure undoes all of it, and
2053
+ // the transition stays queued for a later attempt.
1858
2054
  const leaving = activeSubStatesIn(machineKey, snapshot.from)
1859
- for (const sub of leaving) {
1860
- const value = currentMachines.get(sub.key)
1861
- const result = runPhase(sub.key, "exit", value, { from: value, to: undefined } as unknown as Machine.TransitionSnapshot, schedules)
1862
- if (!result.ok) {
1863
- pendingMachines.set(machineKey, pending)
1864
- if (tracing) traceTransition(machineKey, snapshot, "failed")
1865
- return result
1866
- }
1867
- }
1868
-
1869
- for (const schedule of schedules) {
1870
- const transition = schedule.transition
1871
- if (transition.phase === "exit" && transition.machine.key === machineKey && transition.state === snapshot.from) {
1872
- const result = runTransitionSchedule(schedule, snapshot)
1873
- if (!result.ok) {
1874
- // The transition did not happen; keep it queued for a later attempt.
1875
- pendingMachines.set(machineKey, pending)
1876
- if (tracing) traceTransition(machineKey, snapshot, "failed")
1877
- return result
1878
- }
2055
+ const exited = runAsPhase(() => {
2056
+ for (const sub of leaving) {
2057
+ const value = currentMachines.get(sub.key)
2058
+ const result = runPhase(sub.key, "exit", value, { from: value, to: undefined } as unknown as Machine.TransitionSnapshot, schedules)
2059
+ if (!result.ok) return result
1879
2060
  }
1880
- }
1881
- for (const schedule of schedules) {
1882
- const transition = schedule.transition
1883
- if (
1884
- transition.phase === "transition"
1885
- && transition.machine.key === machineKey
1886
- && transition.from === snapshot.from
1887
- && transition.to === snapshot.to
1888
- ) {
1889
- const result = runTransitionSchedule(schedule, snapshot)
1890
- if (!result.ok) {
1891
- pendingMachines.set(machineKey, pending)
1892
- if (tracing) traceTransition(machineKey, snapshot, "failed")
1893
- return result
2061
+ const left = runPhase(machineKey, "exit", snapshot.from, snapshot, schedules)
2062
+ if (!left.ok) return left
2063
+ for (const schedule of schedules) {
2064
+ const transition = schedule.transition
2065
+ if (
2066
+ transition.phase === "transition"
2067
+ && transition.machine.key === machineKey
2068
+ && transition.from === snapshot.from
2069
+ && transition.to === snapshot.to
2070
+ ) {
2071
+ const result = runTransitionSchedule(schedule, snapshot)
2072
+ if (!result.ok) return result
1894
2073
  }
1895
2074
  }
2075
+ return succeeded
2076
+ })
2077
+ if (!exited.ok) {
2078
+ requeue()
2079
+ if (tracing) traceTransition(machineKey, snapshot, "failed")
2080
+ return exited
1896
2081
  }
1897
2082
 
1898
2083
  for (const sub of leaving) commitMachine(sub.key, currentMachines.get(sub.key), undefined)
1899
2084
  commitMachine(machineKey, current, pending.value)
2085
+ // Sub-states that exist in the entered state enter their value together
2086
+ // with the parent, parents first, before any onEnter schedule runs: a
2087
+ // failing onEnter cannot leave them without a value, and their scope
2088
+ // triggers fire before what the onEnter schedules spawn.
2089
+ const entered = commitSubStates(machineKey, snapshot.to)
1900
2090
 
1901
2091
  for (const schedule of schedules) {
1902
2092
  const transition = schedule.transition
@@ -1908,9 +2098,8 @@ const makeValidatedRuntime = <
1908
2098
  }
1909
2099
  }
1910
2100
  }
1911
- // Sub-states that exist in the entered state enter their value, parents first.
1912
- const entered = enterSubStates(machineKey, snapshot.to, schedules)
1913
- if (!entered.ok) return entered
2101
+ const enteredSchedules = runSubStateEnters(entered, schedules)
2102
+ if (!enteredSchedules.ok) return enteredSchedules
1914
2103
  }
1915
2104
  return succeeded
1916
2105
  }
@@ -1925,6 +2114,8 @@ const makeValidatedRuntime = <
1925
2114
  if (from !== undefined) previousMachines.set(machineKey, from)
1926
2115
  if (to === undefined) {
1927
2116
  currentMachines.delete(machineKey)
2117
+ const discarded = pendingMachines.get(machineKey)
2118
+ if (discarded !== undefined) discardedPending.add(discarded)
1928
2119
  pendingMachines.delete(machineKey)
1929
2120
  } else {
1930
2121
  currentMachines.set(machineKey, to)
@@ -1968,22 +2159,28 @@ const makeValidatedRuntime = <
1968
2159
 
1969
2160
  /**
1970
2161
  * Gives the sub-states of `parentKey` that exist in `state` their entry
1971
- * value, then runs their `onEnter` schedules and enters their own
1972
- * sub-states.
2162
+ * value, and theirs, parents first. Returns what it entered, in that order.
1973
2163
  */
1974
- const enterSubStates = (parentKey: symbol, state: unknown, schedules: TransitionSchedules): Result.Result<void, SystemFailure> => {
2164
+ const commitSubStates = (parentKey: symbol, state: unknown): Array<readonly [symbol, unknown]> => {
2165
+ const entered: Array<readonly [symbol, unknown]> = []
1975
2166
  for (const sub of subStates.get(parentKey) ?? []) {
1976
2167
  if (sub.within!.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key)) continue
1977
2168
  const value = subStateEntry.get(sub.key)
1978
2169
  commitMachine(sub.key, undefined, value)
2170
+ entered.push([sub.key, value], ...commitSubStates(sub.key, value))
2171
+ }
2172
+ return entered
2173
+ }
2174
+
2175
+ /** Runs the onEnter schedules of sub-states entered by {@link commitSubStates}, in that order. */
2176
+ const runSubStateEnters = (entered: ReadonlyArray<readonly [symbol, unknown]>, schedules: TransitionSchedules): Result.Result<void, SystemFailure> => {
2177
+ for (const [key, value] of entered) {
1979
2178
  const snapshot = { from: undefined, to: value } as unknown as Machine.TransitionSnapshot
1980
- const result = runPhase(sub.key, "enter", value, snapshot, schedules)
2179
+ const result = runPhase(key, "enter", value, snapshot, schedules)
1981
2180
  if (!result.ok) {
1982
- if (tracing) traceTransition(sub.key, snapshot, "enterFailed")
2181
+ if (tracing) traceTransition(key, snapshot, "enterFailed")
1983
2182
  return result
1984
2183
  }
1985
- const nested = enterSubStates(sub.key, value, schedules)
1986
- if (!nested.ok) return nested
1987
2184
  }
1988
2185
  return succeeded
1989
2186
  }
@@ -2020,8 +2217,14 @@ const makeValidatedRuntime = <
2020
2217
  return succeeded
2021
2218
  }
2022
2219
 
2023
- /** Whether a schedule is running now: `send` is not allowed meanwhile. */
2220
+ /** Whether `runtime.tick` is on the stack: a nested `tick` is a defect. */
2024
2221
  let ticking = false
2222
+ /**
2223
+ * Whether a tick is advancing its frame or running its schedules (in
2224
+ * `tick` and `tryTick`): `send`, `snapshot`, and `restore` are defects
2225
+ * meanwhile. It is cleared before `tick.end` is announced to host listeners.
2226
+ */
2227
+ let inTick = false
2025
2228
 
2026
2229
  /**
2027
2230
  * Runs one schedule. A failing system stops the schedule; systems that
@@ -2045,7 +2248,7 @@ const makeValidatedRuntime = <
2045
2248
  let hostEvents = new Set<string>()
2046
2249
 
2047
2250
  const send = (descriptor: Descriptor<"event", string, any>, value: unknown): void => {
2048
- if (ticking) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
2251
+ if (inTick) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
2049
2252
  if (!eventsByKey.has(descriptor.key)) throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`)
2050
2253
  const published = world.advanceTick()
2051
2254
  events.append(descriptor.key, published, [value], true)
@@ -2116,6 +2319,7 @@ const makeValidatedRuntime = <
2116
2319
  if (ticking) throw new Error("runtime.tick cannot run inside another tick")
2117
2320
  if (debugEnabled) reportUnread("next-tick")
2118
2321
  ticking = true
2322
+ inTick = true
2119
2323
  try {
2120
2324
  advanceFrame()
2121
2325
  return watchResult(observedTick(schedules, () => {
@@ -2127,6 +2331,7 @@ const makeValidatedRuntime = <
2127
2331
  }), schedules)
2128
2332
  } finally {
2129
2333
  ticking = false
2334
+ inTick = false
2130
2335
  }
2131
2336
  }
2132
2337
 
@@ -2141,9 +2346,11 @@ const makeValidatedRuntime = <
2141
2346
  try {
2142
2347
  result = run()
2143
2348
  } catch (defect) {
2349
+ inTick = false
2144
2350
  emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect })
2145
2351
  throw defect
2146
2352
  }
2353
+ inTick = false
2147
2354
  emitHost(result.ok
2148
2355
  ? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
2149
2356
  : { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error })
@@ -2182,8 +2389,14 @@ const makeValidatedRuntime = <
2182
2389
  return watchResult(Result.failure({ kind: "TickOrderViolation" as const, issues }), [schedule])
2183
2390
  }
2184
2391
 
2185
- advanceFrame()
2186
- return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
2392
+ if (inTick) throw new Error("runtime.tryTick cannot run inside another tick")
2393
+ inTick = true
2394
+ try {
2395
+ advanceFrame()
2396
+ return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
2397
+ } finally {
2398
+ inTick = false
2399
+ }
2187
2400
  }
2188
2401
 
2189
2402
  /**
@@ -2220,6 +2433,7 @@ const makeValidatedRuntime = <
2220
2433
  for (const provision of machineEntries) machinesByName.set(provision.machine.name, provision.machine)
2221
2434
 
2222
2435
  const snapshot = (): Snapshot.WorldSnapshot => {
2436
+ if (inTick) throw new Error("runtime.snapshot was called while a schedule is running: it would capture writes a failing system rolls back; take it between ticks")
2223
2437
  const savedResources: Record<string, unknown> = {}
2224
2438
  for (const [name, descriptor] of resourcesByName) {
2225
2439
  if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
@@ -2238,6 +2452,7 @@ const makeValidatedRuntime = <
2238
2452
  }
2239
2453
  return {
2240
2454
  version: Snapshot.version,
2455
+ gameVersion: options.saves?.latest ?? 0,
2241
2456
  nextEntity: world.nextEntityValue(),
2242
2457
  entities: world.exportEntities(DescriptorModule.isTransient),
2243
2458
  relations: world.exportRelations(),
@@ -2255,7 +2470,9 @@ const makeValidatedRuntime = <
2255
2470
  const restore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
2256
2471
  const parsed = Snapshot.parse(data)
2257
2472
  if (!parsed.ok) return parsed
2258
- const saved = parsed.value
2473
+ const migrated = migrateSave(parsed.value, options.saves)
2474
+ if (!migrated.ok) return migrated
2475
+ const saved = migrated.value
2259
2476
 
2260
2477
  // Validate everything before touching the world.
2261
2478
  const ids = new Set<number>()
@@ -2343,6 +2560,7 @@ const makeValidatedRuntime = <
2343
2560
  const scoped = new Set<number>()
2344
2561
  const memberships: Array<readonly [number, symbol]> = []
2345
2562
  for (const [name, members] of Object.entries(saved.scopes)) {
2563
+ if (!options.scopeDefinitions.some((scope) => scope.name === name)) return Result.failure({ _tag: "UnknownScope", name })
2346
2564
  for (const id of members) {
2347
2565
  if (!ids.has(id) || scoped.has(id)) return Result.failure({ _tag: "InvalidScope", name, entityId: id })
2348
2566
  scoped.add(id)
@@ -2382,6 +2600,7 @@ const makeValidatedRuntime = <
2382
2600
  }
2383
2601
 
2384
2602
  const tracedRestore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
2603
+ if (inTick) throw new Error("runtime.restore was called while a schedule is running: the running systems' queued commands would land on the restored world; restore between ticks")
2385
2604
  const result = restore(data)
2386
2605
  if (tracing) emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok })
2387
2606
  return result
@@ -2533,6 +2752,9 @@ const makeValidatedRuntime = <
2533
2752
  commandUses,
2534
2753
  ranSystems,
2535
2754
  requirementViolations: [...requirementViolations.values()],
2755
+ nextStateReplacements: [...nextStateReplacements.values()],
2756
+ staleTargets: [...staleTargets.values()],
2757
+ streamLosses: [...streamLosses.values()],
2536
2758
  hasResource: (descriptor) => resources.has(descriptor.key),
2537
2759
  schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
2538
2760
  }),
@@ -2573,8 +2795,9 @@ const makeValidatedRuntime = <
2573
2795
  * Creates a runtime for a fully built schema and a set of external services.
2574
2796
  *
2575
2797
  * Resources owned by constructed descriptors take raw input and are validated
2576
- * here; when any are provided the runtime comes back as a `Result`. Otherwise
2577
- * the runtime is returned directly.
2798
+ * here. The runtime always comes back as a `Result`: typed `Success` when
2799
+ * nothing provided can fail (read `.value`), and a `Result` to check when a
2800
+ * constructed resource is provided.
2578
2801
  *
2579
2802
  * The runtime does not own the outer frame loop. It only owns ECS state plus
2580
2803
  * the host-provided services that systems are allowed to depend on.
@@ -2587,7 +2810,7 @@ const makeValidatedRuntime = <
2587
2810
  * ),
2588
2811
  * resources: { Score: 0 },
2589
2812
  * machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
2590
- * })
2813
+ * }).value
2591
2814
  * ```
2592
2815
  */
2593
2816
  export const make = <
@@ -2600,19 +2823,16 @@ export const make = <
2600
2823
  >(options: {
2601
2824
  readonly schema: S
2602
2825
  readonly services: ProvidedServices
2603
- readonly resources?: Resources
2826
+ readonly resources?: Resources & ExactResources<S, Resources>
2604
2827
  readonly machines?: ProvidedMachines
2605
2828
  readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
2606
2829
  /** Scopes whose members a committed state change despawns; `Game.Runtime.make` passes every `Game.EntityScope`. */
2607
2830
  readonly scopeDefinitions?: ReadonlyArray<EntityScopeModule.EntityScope.Any>
2608
2831
  /** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
2609
2832
  readonly debug?: Enabled
2833
+ /** The game's save migrations (`Snapshot.migrations`), applied by `restore`. */
2834
+ readonly saves?: Snapshot.Migrations
2610
2835
  }): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
2611
- const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) =>
2612
- options.resources !== undefined
2613
- && (options.resources as Record<string, unknown>)[key] !== undefined
2614
- && DescriptorModule.hasConstructor(descriptor)
2615
- )
2616
2836
  const validated = collectConstructedRegistryValues(options.schema.resources, options.resources)
2617
2837
  if (!validated.ok) {
2618
2838
  return Result.failure({ resources: validated.error }) as never
@@ -2624,9 +2844,43 @@ export const make = <
2624
2844
  machines: options.machines,
2625
2845
  machineDefinitions: options.machineDefinitions ?? [],
2626
2846
  scopeDefinitions: options.scopeDefinitions ?? [],
2627
- debug: options.debug
2847
+ debug: options.debug,
2848
+ saves: options.saves
2628
2849
  })
2629
- return (fallible ? Result.success(runtime) : runtime) as never
2850
+ return Result.success(runtime) as never
2851
+ }
2852
+
2853
+ /** Names listed more than once in `Names` (a tuple), in order of their second listing. */
2854
+ type RepeatedNames<Names extends ReadonlyArray<string>, Seen extends string = never> =
2855
+ Names extends readonly [infer Head extends string, ...infer Tail extends ReadonlyArray<string>]
2856
+ ? (string extends Head ? never : Head extends Seen ? Head : never) | RepeatedNames<Tail, Seen | Head>
2857
+ : never
2858
+
2859
+ type ProvidedServiceNames<Entries extends ReadonlyArray<ServiceProvision>> = {
2860
+ readonly [K in keyof Entries]: Entries[K] extends ServiceProvision<infer D> ? D["name"] : string
2861
+ }
2862
+ type ProvidedMachineNames<Entries extends ReadonlyArray<MachineProvision>> = {
2863
+ readonly [K in keyof Entries]: Entries[K] extends MachineProvision<infer M> ? M["name"] : string
2864
+ }
2865
+
2866
+ /** Rejects a service provided twice: only the last would be used. */
2867
+ type UniqueServices<Entries extends ReadonlyArray<ServiceProvision>> =
2868
+ [RepeatedNames<ProvidedServiceNames<Entries>>] extends [never] ? unknown : {
2869
+ readonly __fixServices__: `${RepeatedNames<ProvidedServiceNames<Entries>>} is provided twice: keep one Game.Runtime.service(...) for it`
2870
+ }
2871
+
2872
+ /** Rejects a machine started twice: only the last initial value would be used. */
2873
+ type UniqueMachines<Entries extends ReadonlyArray<MachineProvision>> =
2874
+ [RepeatedNames<ProvidedMachineNames<Entries>>] extends [never] ? unknown : {
2875
+ readonly __fixMachines__: `${RepeatedNames<ProvidedMachineNames<Entries>>} is provided twice: keep one Game.Runtime.machine(...) for it`
2876
+ }
2877
+
2878
+ const rejectRepeated = (names: ReadonlyArray<string>, kind: "service" | "machine"): void => {
2879
+ const seen = new Set<string>()
2880
+ for (const name of names) {
2881
+ if (seen.has(name)) throw new Error(`${name} is provided twice: keep one Game.Runtime.${kind}(...) for it`)
2882
+ seen.add(name)
2883
+ }
2630
2884
  }
2631
2885
 
2632
2886
  /**
@@ -2638,6 +2892,8 @@ export const make = <
2638
2892
  *
2639
2893
  * The helper keeps the runtime map keyed by service descriptors instead of
2640
2894
  * ad hoc strings, so the provision site cannot drift from the declaration site.
2895
+ * Each service is provided once: a second provision of the same one does not
2896
+ * compile.
2641
2897
  *
2642
2898
  * @example
2643
2899
  * ```ts
@@ -2650,7 +2906,8 @@ export const make = <
2650
2906
  */
2651
2907
  export const services = <
2652
2908
  const Entries extends ReadonlyArray<ServiceProvision>
2653
- >(...entries: Entries): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
2909
+ >(...entries: Entries & UniqueServices<Entries>): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
2910
+ rejectRepeated(entries.map((entry) => entry.descriptor.name), "service")
2654
2911
  const provided: Record<string, unknown> = {}
2655
2912
  for (const { descriptor, implementation } of entries) {
2656
2913
  provided[descriptor.name] = implementation
@@ -2680,11 +2937,14 @@ export const service = <
2680
2937
  * Builds the machine initialization environment from machine definitions.
2681
2938
  *
2682
2939
  * Use this when gameplay phases need a known committed starting state before
2683
- * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`.
2940
+ * any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`. Each
2941
+ * machine is provided once: a second provision of the same one does not
2942
+ * compile.
2684
2943
  */
2685
2944
  export const machines = <
2686
2945
  const Entries extends ReadonlyArray<MachineProvision>
2687
- >(...entries: Entries): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
2946
+ >(...entries: Entries & UniqueMachines<Entries>): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
2947
+ rejectRepeated(entries.map((entry) => entry.machine.name), "machine")
2688
2948
  const provided: Record<string, unknown> = {}
2689
2949
  for (const { machine, initial } of entries) {
2690
2950
  provided[machine.name] = initial