@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
@@ -6,6 +6,7 @@
6
6
  import type * as Debug from "../Debug.ts"
7
7
  import * as DescriptorModule from "../Descriptor.ts"
8
8
  import type { Descriptor } from "../Descriptor.ts"
9
+ import type * as EntityScope from "../EntityScope.ts"
9
10
  import type * as Inspector from "../Inspector.ts"
10
11
  import type * as Machine from "../Machine.ts"
11
12
  import type * as Query from "../Query.ts"
@@ -72,6 +73,9 @@ export const transitionScheduleName = (schedule: TransitionSchedule): string =>
72
73
  const names = <A>(record: Record<string, A>, name: (value: A) => string): Array<string> =>
73
74
  Object.values(record).map(name)
74
75
 
76
+ const lifecycleNames = (filters: ReadonlyArray<Query.Filter<any>>, kind: "added" | "changed"): Array<string> =>
77
+ filters.flatMap((filter) => filter.kind === kind ? [filter.descriptor.name] : [])
78
+
75
79
  const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDescription => {
76
80
  const reads: Array<string> = []
77
81
  const writes: Array<string> = []
@@ -102,8 +106,11 @@ const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDe
102
106
  optional,
103
107
  with: query.with.map((descriptor) => descriptor.name),
104
108
  without: query.without.map((descriptor) => descriptor.name),
105
- added: query.filters.filter((filter) => filter.kind === "added").map((filter) => filter.descriptor.name),
106
- changed: query.filters.filter((filter) => filter.kind === "changed").map((filter) => filter.descriptor.name),
109
+ added: lifecycleNames(query.filters, "added"),
110
+ changed: lifecycleNames(query.filters, "changed"),
111
+ anyOf: query.filters.flatMap((filter) => filter.kind === "anyOf"
112
+ ? [{ added: lifecycleNames(filter.filters, "added"), changed: lifecycleNames(filter.filters, "changed") }]
113
+ : []),
107
114
  relations: [
108
115
  ...relations,
109
116
  ...relationNames(query.withRelations, "with"),
@@ -114,7 +121,7 @@ const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDe
114
121
  }
115
122
  }
116
123
 
117
- type Mode = { readonly mode: "read" | "write"; readonly descriptor: Descriptor.Any }
124
+ type Mode = { readonly mode: "read" | "changed" | "write"; readonly descriptor: Descriptor.Any }
118
125
  type CommandAccess = { readonly permissions: ReadonlyArray<{ readonly permission: keyof Debug.CommandPermissions; readonly values: ReadonlyArray<{ readonly name: string }> }> }
119
126
 
120
127
  const permissionsOf = (access: CommandAccess | undefined): Debug.CommandPermissions => {
@@ -142,7 +149,7 @@ type ThisTickAccess = Mode & { readonly thisTick?: true }
142
149
 
143
150
  export const describeSystem = (system: AnySystem, placements: ReadonlyArray<string>): Debug.SystemDescription => {
144
151
  const spec = system.spec
145
- const byMode = (record: Record<string, Mode>, mode: "read" | "write") =>
152
+ const byMode = (record: Record<string, Mode>, mode: Mode["mode"]) =>
146
153
  Object.values(record).filter((access) => access.mode === mode).map((access) => access.descriptor.name)
147
154
  const machineName = (access: { readonly machine: Machine.StateMachine.Any }) => access.machine.name
148
155
  // A system also reads whatever its `check` conditions read.
@@ -160,7 +167,8 @@ export const describeSystem = (system: AnySystem, placements: ReadonlyArray<stri
160
167
  Object.entries(check.spec.queries as Record<string, Query.Query.Any<any>>).map(([slot, query]) => describeQuery(`check(${check.name}).${slot}`, query)))
161
168
  ],
162
169
  resources: {
163
- reads: unique([...byMode(spec.resources, "read"), ...checks.flatMap((check) => byMode(check.spec.resources, "read"))]),
170
+ reads: unique([...byMode(spec.resources, "read"), ...byMode(spec.resources, "changed"), ...checks.flatMap((check) => byMode(check.spec.resources, "read"))]),
171
+ changed: byMode(spec.resources, "changed"),
164
172
  writes: byMode(spec.resources, "write")
165
173
  },
166
174
  events: { reads: byMode(spec.events, "read"), writes: byMode(spec.events, "write") },
@@ -260,6 +268,10 @@ export interface DescribeInput {
260
268
  readonly schema: Schema.Any
261
269
  readonly machines: ReadonlyArray<Machine.StateMachine.Any>
262
270
  readonly currentMachine: (machine: Machine.StateMachine.Any) => Machine.StateValue | undefined
271
+ /** Whether the runtime was given an initial value for the machine. */
272
+ readonly providesMachine: (machine: Machine.StateMachine.Any) => boolean
273
+ /** Every scope defined through the game, with its despawn triggers. */
274
+ readonly scopes: ReadonlyArray<EntityScope.EntityScope.Any>
263
275
  readonly providedServices: ReadonlyArray<string>
264
276
  readonly externallyWrittenEvents: ReadonlySet<string>
265
277
  /** Relations commands have tried to relate or reorder, by the queuing systems. */
@@ -268,6 +280,11 @@ export interface DescribeInput {
268
280
  readonly commandUses: ReadonlyMap<string, ReadonlySet<string>>
269
281
  /** Systems that ran in this session. */
270
282
  readonly ranSystems: ReadonlySet<string>
283
+ /** Commands skipped because they would have broken a `Schema.requires` requirement. */
284
+ readonly requirementViolations: ReadonlyArray<Debug.RequirementViolation>
285
+ readonly nextStateReplacements: ReadonlyArray<Debug.NextStateReplacement>
286
+ readonly staleTargets: ReadonlyArray<Debug.StaleTarget>
287
+ readonly streamLosses: ReadonlyArray<Debug.StreamLoss>
271
288
  readonly hasResource: (descriptor: Descriptor.Any) => boolean
272
289
  readonly schedules: ReadonlyArray<readonly [string, AnySchedule]>
273
290
  }
@@ -279,19 +296,38 @@ export const describe = (input: DescribeInput): Debug.Description => {
279
296
  const relations = Object.values(input.schema.relations) as ReadonlyArray<Relation.Relation.Any>
280
297
 
281
298
  const schedules = collectSchedules(input.schedules)
299
+ // A system gated by Schedule.when / pauseWhen is a copy pointing at its
300
+ // base: both are one system, described once with every placement.
301
+ const baseOf = (system: AnySystem): AnySystem => (system as { readonly base?: AnySystem }).base ?? system
282
302
  const placements = new Map<AnySystem, Array<string>>()
303
+ const steps = new Set<AnySystem>()
283
304
  for (const [name, schedule] of schedules) {
284
305
  schedule.steps.forEach((step, index) => {
285
306
  if (!Schedule.isSystemStep(step)) return
286
- const system = step as AnySystem
307
+ const system = baseOf(step as AnySystem)
308
+ steps.add(step as AnySystem)
287
309
  const list = placements.get(system)
288
310
  const placement = `${name}#${index}`
289
311
  if (list) list.push(placement)
290
312
  else placements.set(system, [placement])
291
313
  })
292
314
  }
293
- const described = new Map([...placements].map(([system, where]) => [system, describeSystem(system, where)] as const))
294
- const systems = [...described.values()]
315
+ // Gates differ per placement: the description lists the conditions of all of them.
316
+ const gatesOf = new Map<AnySystem, { when: Set<unknown>; pauseWhen: Set<unknown> }>()
317
+ for (const step of steps) {
318
+ const base = baseOf(step)
319
+ const gates = gatesOf.get(base) ?? { when: new Set(), pauseWhen: new Set() }
320
+ for (const condition of step.spec.when as ReadonlyArray<unknown>) gates.when.add(condition)
321
+ for (const condition of step.spec.pauseWhen as ReadonlyArray<unknown>) gates.pauseWhen.add(condition)
322
+ gatesOf.set(base, gates)
323
+ }
324
+ const byBase = new Map([...placements].map(([system, where]) => {
325
+ const gates = gatesOf.get(system)!
326
+ const merged = { ...system, spec: { ...system.spec, when: [...gates.when], pauseWhen: [...gates.pauseWhen] } } as AnySystem
327
+ return [system, describeSystem(merged, where)] as const
328
+ }))
329
+ const described = new Map<AnySystem, Debug.SystemDescription>([...steps].map((step) => [step, byBase.get(baseOf(step))!] as const))
330
+ const systems = [...byBase.values()]
295
331
 
296
332
  const components = new Index(componentDescriptors.map((descriptor) => descriptor.name))
297
333
  const resources = new Index(resourceDescriptors.map((descriptor) => descriptor.name))
@@ -300,7 +336,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
300
336
  for (const name of input.externallyWrittenEvents) events.write(name, "runtime.send")
301
337
  for (const system of systems) {
302
338
  for (const query of system.queries) {
303
- for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed]) {
339
+ for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed, ...anyOfNames(query)]) {
304
340
  components.read(name, system.name)
305
341
  }
306
342
  for (const name of query.writes) {
@@ -325,6 +361,20 @@ export const describe = (input: DescribeInput): Debug.Description => {
325
361
  schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyStateTransitions"))
326
362
 
327
363
  const lints: Array<Omit<Debug.Lint, "fix">> = []
364
+ const triggerName = (trigger: EntityScope.Trigger) =>
365
+ `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`
366
+ for (const scope of input.scopes) {
367
+ for (const trigger of scope.despawnOn) {
368
+ const machine = input.machines.find((candidate) => candidate.key === trigger.machine.key)
369
+ if (machine !== undefined && input.providesMachine(machine)) continue
370
+ lints.push({
371
+ severity: "warning",
372
+ code: "scope-trigger-never-applied",
373
+ subject: scope.name,
374
+ message: `${scope.name} is despawned on ${triggerName(trigger)}, but the runtime does not provide ${trigger.machine.name}, so its state never changes and the scope is never despawned`
375
+ })
376
+ }
377
+ }
328
378
  if (schedules.length > 0) {
329
379
  for (const entry of events.list()) {
330
380
  if (entry.writers.length > 0 && entry.readers.length === 0) {
@@ -354,8 +404,26 @@ export const describe = (input: DescribeInput): Debug.Description => {
354
404
  })
355
405
  }
356
406
  }
407
+ for (const scope of input.scopes) {
408
+ for (const trigger of scope.despawnOn) {
409
+ const machine = input.machines.find((candidate) => candidate.key === trigger.machine.key)
410
+ if (machine === undefined || !input.providesMachine(machine)) continue
411
+ const reason = !appliesTransitions
412
+ ? "no described schedule has an applyStateTransitions() marker"
413
+ : !queuedWithParents(machine, nextStates)
414
+ ? `no described system queues a next state of ${machine.name}`
415
+ : undefined
416
+ if (reason === undefined) continue
417
+ lints.push({
418
+ severity: "warning",
419
+ code: "scope-trigger-never-applied",
420
+ subject: scope.name,
421
+ message: `${scope.name} is despawned on ${triggerName(trigger)}, but ${reason}, so the scope is never despawned`
422
+ })
423
+ }
424
+ }
357
425
  const failureReaders = new Set(systems.flatMap((system) => system.relationFailures))
358
- // Declared `relates` permissions, and relations that commands were seen to relate (drafts relate without one).
426
+ // Declared `relates` permissions, and relations that commands were seen to relate (erased types can relate without one).
359
427
  const relaters = new Map<string, Set<string>>()
360
428
  for (const system of systems) {
361
429
  for (const name of system.commands.relates) relaters.set(name, new Set([...(relaters.get(name) ?? []), system.name]))
@@ -370,6 +438,20 @@ export const describe = (input: DescribeInput): Debug.Description => {
370
438
  message: `${name} is related by commands from ${[...queuers].sort().join(", ")}, but no described system reads its failures (Game.System.readRelationFailures(${name})), so a failed relate or reorder is dropped silently`
371
439
  })
372
440
  }
441
+ const resourceWriters = new Set(systems.flatMap((system) => system.resources.writes))
442
+ const changedReaders = new Map<string, Array<string>>()
443
+ for (const system of systems) {
444
+ for (const name of system.resources.changed) changedReaders.set(name, [...(changedReaders.get(name) ?? []), system.name])
445
+ }
446
+ for (const [name, readers] of changedReaders) {
447
+ if (resourceWriters.has(name)) continue
448
+ lints.push({
449
+ severity: "warning",
450
+ code: "resource-changed-never-written",
451
+ subject: name,
452
+ message: `${name} is read with changedResource by ${readers.join(", ")}, but no described system writes it, so changed() is true only on the first run`
453
+ })
454
+ }
373
455
  lints.push(...readBeforeWriteLints(schedules, described, input.externallyWrittenEvents))
374
456
  // Each named schedule is checked as one tick; `tick` checks this at compile time.
375
457
  for (const [name, schedule] of input.schedules) {
@@ -403,10 +485,63 @@ export const describe = (input: DescribeInput): Debug.Description => {
403
485
  }
404
486
  }
405
487
  }
488
+ // Observed with debug, whether or not schedules are named.
489
+ for (const violation of input.requirementViolations) {
490
+ const times = violation.count === 1 ? "once" : `${violation.count} times`
491
+ lints.push({
492
+ severity: "warning",
493
+ code: "requirement-violated",
494
+ subject: violation.system,
495
+ message: `${violation.system} queued ${violation.command === "insert" ? "an" : "a"} ${violation.command} that would have left ${violation.component} without ${violation.missing} (last on entity ${violation.entity}), so it was skipped ${times}`
496
+ })
497
+ }
498
+ for (const replacement of input.nextStateReplacements) {
499
+ const times = replacement.count === 1 ? "" : `, ${replacement.count} times`
500
+ lints.push({
501
+ severity: "warning",
502
+ code: "next-state-replaced",
503
+ subject: replacement.machine,
504
+ message: `${replacement.by.system} queued ${replacement.machine}=${String(replacement.by.value)}, replacing ${String(replacement.replaced.value)} queued by ${replacement.replaced.system} before the same applyStateTransitions()${times}: the last value wins`
505
+ })
506
+ }
507
+ for (const stale of input.staleTargets) {
508
+ const times = stale.count === 1 ? "once" : `${stale.count} times`
509
+ lints.push({
510
+ severity: "warning",
511
+ code: "stale-target",
512
+ subject: stale.system,
513
+ message: `${stale.system} passed ${stale.command} an id spawned in an earlier run (last entity ${stale.entity}), so it was skipped ${times}`
514
+ })
515
+ }
516
+ for (const loss of input.streamLosses) {
517
+ if (loss.checksLagged) continue
518
+ if (loss.lostRuns > 0) {
519
+ lints.push({
520
+ severity: "warning",
521
+ code: "stream-loss-unread",
522
+ subject: loss.system,
523
+ message: `${loss.system} read ${loss.stream} after entries were dropped before it read them (${loss.lostRuns === 1 ? "once" : `${loss.lostRuns} runs`}), and never checks lagged()`
524
+ })
525
+ } else if (loss.paused) {
526
+ lints.push({
527
+ severity: "info",
528
+ code: "stream-loss-unread",
529
+ subject: loss.system,
530
+ message: `${loss.system} reads ${loss.stream} under pauseWhen and never checks lagged(): a long pause drops entries past the stream's capacity`
531
+ })
532
+ }
533
+ }
406
534
 
407
535
  return {
408
536
  version: 1,
409
- components: componentDescriptors.map((descriptor) => ({ name: descriptor.name, storage: storageOf(descriptor) })),
537
+ components: componentDescriptors.map((descriptor) => ({
538
+ name: descriptor.name,
539
+ storage: storageOf(descriptor),
540
+ immutable: DescriptorModule.isImmutable(descriptor),
541
+ requires: [...new Set(input.schema.requires
542
+ .filter((requirement) => requirement.component.name === descriptor.name)
543
+ .flatMap((requirement) => requirement.required.map((required) => required.name)))]
544
+ })),
410
545
  resources: resourceDescriptors.map((descriptor) => ({
411
546
  name: descriptor.name,
412
547
  storage: storageOf(descriptor),
@@ -423,8 +558,11 @@ export const describe = (input: DescribeInput): Debug.Description => {
423
558
  machines: input.machines.map((machine) => ({
424
559
  name: machine.name,
425
560
  states: machine.values,
426
- current: input.currentMachine(machine)
561
+ current: input.currentMachine(machine),
562
+ provided: input.providesMachine(machine),
563
+ within: machine.within === undefined ? undefined : `${machine.within.machine.name}=${String(machine.within.state)}`
427
564
  })),
565
+ scopes: input.scopes.map((scope) => ({ name: scope.name, despawnOn: scope.despawnOn.map(triggerName) })),
428
566
  services: describeServices(input.providedServices, systems),
429
567
  schedules: schedules.map(([name, schedule]) => ({ name, steps: schedule.steps.map(describeStep) })),
430
568
  systems,
@@ -437,6 +575,17 @@ export const describe = (input: DescribeInput): Debug.Description => {
437
575
  }
438
576
  }
439
577
 
578
+ /**
579
+ * Whether a described system queues a next state of `machine`, or of a
580
+ * machine it exists within: a parent leaving its state changes a sub-state too.
581
+ */
582
+ const queuedWithParents = (machine: Machine.StateMachine.Any, nextStates: ReadonlyMap<string, unknown>): boolean => {
583
+ for (let current: Machine.StateMachine.Any | undefined = machine; current !== undefined; current = current.within?.machine) {
584
+ if (nextStates.has(current.name)) return true
585
+ }
586
+ return false
587
+ }
588
+
440
589
  /** The fix each lint names, by code. */
441
590
  const fixFor = (code: Debug.Lint["code"], subject: string): string => {
442
591
  switch (code) {
@@ -446,12 +595,24 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
446
595
  return `emit ${subject} from a system with Game.System.writeEvent, or stop reading it; if the host sends it with runtime.send, declare it (debug.declareHostEvents, or hostEvents in a Session or lint target)`
447
596
  case "next-state-never-applied":
448
597
  return `add Game.Schedule.applyStateTransitions(...) to a schedule that runs after the system that queues ${subject}`
598
+ case "resource-changed-never-written":
599
+ return `write ${subject} from a system with Game.System.writeResource, or read it with Game.System.readResource; if only a render schedule writes it, pass that schedule as describe-only`
600
+ case "scope-trigger-never-applied":
601
+ return `provide the machine of ${subject}'s trigger in Game.Runtime.make({ machines }), queue its next state from a system, and apply it with Game.Schedule.applyStateTransitions(...); or despawn ${subject} with commands.despawnScope(...) instead`
449
602
  case "relation-failure-never-read":
450
603
  return `read the failures with Game.System.readRelationFailures(${subject}) in a system and handle them`
451
604
  case "this-tick-order":
452
605
  return "move the writer before the reader in the schedule (the message names both), or read without Game.System.thisTick"
453
606
  case "component-never-read":
454
607
  return `fine if ${subject} is read outside the described schedules (rendering, inspectors); otherwise stop writing it`
608
+ case "requirement-violated":
609
+ return `keep what ${subject} spawns or inserts complete (every component its components require), and remove a required component only together with its requirers; a query proof can go stale when another system's command is applied first in the same applyDeferred()`
610
+ case "stream-loss-unread":
611
+ return `check lagged() in ${subject} where a missed entry matters (a node never destroyed, a count off), and rebuild from queries when it is true; fine if a missed entry is harmless`
612
+ case "stale-target":
613
+ return `an id spawn returns is valid only in the run that spawned it; in ${subject}, keep the entity id or a handle and resolve it again with a query (queries.x.get(id) / lookup.getHandle(handle, query)) and pass match.entity`
614
+ case "next-state-replaced":
615
+ return "decide between the values in one system, or check whether a next state is already queued (nextMachines.x.getPending()) before setting another"
455
616
  case "command-permission-unused":
456
617
  return `fine if the session did not reach the case where ${subject} uses it; otherwise drop the permission from its Game.System.commands(...)`
457
618
  case "read-before-write":
@@ -462,12 +623,16 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
462
623
  type Accesses = { readonly reads: ReadonlyArray<string>; readonly writes: ReadonlyArray<string> }
463
624
 
464
625
  /** What a system reads and writes, per kind. `with`/`without` filters test presence and are not reads. */
626
+ /** The components named by a query's `anyOf` filters. */
627
+ const anyOfNames = (query: Debug.QueryDescription): ReadonlyArray<string> =>
628
+ query.anyOf.flatMap((filter) => [...filter.added, ...filter.changed])
629
+
465
630
  const accessesOf = (system: Debug.SystemDescription): Record<"component" | "resource" | "event", Accesses> => ({
466
631
  component: {
467
- reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed]),
632
+ reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed, ...anyOfNames(query)]),
468
633
  writes: system.queries.flatMap((query) => query.writes)
469
634
  },
470
- resource: system.resources,
635
+ resource: { reads: system.resources.reads, writes: system.resources.writes },
471
636
  event: system.events
472
637
  })
473
638
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Structural equality for plain data: primitives by `Object.is`, arrays and
3
+ * plain objects by their entries. Other objects (class instances, maps,
4
+ * sets) compare by reference. Used to decide whether a resource write
5
+ * changed the value (`Game.System.changedResource`).
6
+ */
7
+ export const sameValue = (left: unknown, right: unknown): boolean => {
8
+ if (Object.is(left, right)) return true
9
+ if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) return false
10
+ if (Array.isArray(left)) {
11
+ if (!Array.isArray(right) || left.length !== right.length) return false
12
+ for (let index = 0; index < left.length; index++) {
13
+ if (!sameValue(left[index], right[index])) return false
14
+ }
15
+ return true
16
+ }
17
+ if (Array.isArray(right) || !isPlain(left) || !isPlain(right)) return false
18
+ const leftKeys = Object.keys(left)
19
+ if (leftKeys.length !== Object.keys(right).length) return false
20
+ const leftRecord = left as Record<string, unknown>
21
+ const rightRecord = right as Record<string, unknown>
22
+ for (const key of leftKeys) {
23
+ if (!(key in rightRecord) || !sameValue(leftRecord[key], rightRecord[key])) return false
24
+ }
25
+ return true
26
+ }
27
+
28
+ const isPlain = (value: object): boolean => {
29
+ const prototype = Object.getPrototypeOf(value) as object | null
30
+ return prototype === null || prototype === Object.prototype
31
+ }
@@ -4,9 +4,14 @@
4
4
  * The compile-time counterpart is `ValidateFragments` in `Schema.ts`; these
5
5
  * guards cover schemas assembled from erased types.
6
6
  */
7
+ import { isTransient } from "../Descriptor.ts"
7
8
  import type { Schema, SchemaDefinition } from "../Schema.ts"
8
9
 
9
- type NamedRegistry = Record<string, { readonly name: string }>
10
+ type NamedRegistry = Record<string, { readonly name: string; readonly kind?: string }>
11
+
12
+ /** The descriptor kind each registry holds (relations carry their own shape). */
13
+ const expectedKind: Partial<Record<(typeof kinds)[number], string>> = { components: "component", resources: "resource", events: "event" }
14
+ const article = (kind: string) => kind === "event" ? "an" : "a"
10
15
 
11
16
  const kinds = ["components", "resources", "events", "relations"] as const
12
17
 
@@ -14,15 +19,22 @@ export const emptySchema = (): SchemaDefinition => ({
14
19
  components: {},
15
20
  resources: {},
16
21
  events: {},
17
- relations: {}
22
+ relations: {},
23
+ requires: []
18
24
  })
19
25
 
20
26
  /**
21
27
  * Merges two registries after checking for duplicate keys and descriptor names.
22
28
  */
23
- const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry => {
29
+ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry, expected: string | undefined): NamedRegistry => {
24
30
  const names = new Set(Object.values(left).map((entry) => entry.name))
25
31
  for (const [key, entry] of Object.entries(right)) {
32
+ if (expected !== undefined && entry.kind !== expected) {
33
+ const actual = String(entry.kind)
34
+ throw new Error(actual === "service"
35
+ ? `${key} is a service, not ${article(expected)} ${expected}: provide services to Game.Runtime.make({ services }) instead of registering them`
36
+ : `${key} is ${article(actual)} ${actual}, not ${article(expected)} ${expected}: move it to ${actual}s: { ... }`)
37
+ }
26
38
  if (key in left) {
27
39
  throw new Error(`Duplicate schema key: ${key}`)
28
40
  }
@@ -35,10 +47,11 @@ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry
35
47
  }
36
48
 
37
49
  export const mergeSchemas = (left: Schema.Any, right: Schema.Any): Schema.Any => {
38
- const merged: Record<string, NamedRegistry> = {}
50
+ const merged: Record<string, unknown> = {}
39
51
  for (const kind of kinds) {
40
- merged[kind] = mergeRegistry(left[kind], right[kind])
52
+ merged[kind] = mergeRegistry(left[kind], right[kind], expectedKind[kind])
41
53
  }
54
+ merged["requires"] = [...left.requires, ...right.requires]
42
55
  return merged as unknown as Schema.Any
43
56
  }
44
57
 
@@ -47,5 +60,22 @@ export const buildSchema = (fragments: ReadonlyArray<Schema.Any>): Schema.Any =>
47
60
  for (const fragment of fragments) {
48
61
  current = mergeSchemas(current, fragment)
49
62
  }
63
+ const registered = new Set(Object.values(current.components).map((descriptor) => descriptor.name))
64
+ for (const requirement of current.requires) {
65
+ for (const descriptor of [requirement.component, ...requirement.required]) {
66
+ if (!registered.has(descriptor.name)) {
67
+ throw new Error(`${descriptor.name} is named in Schema.requires(...) but no fragment registers it: add it to a fragment's components`)
68
+ }
69
+ }
70
+ for (const required of requirement.required) {
71
+ if (isTransient(required) && !isTransient(requirement.component)) {
72
+ throw new Error(transientRequirementMessage(requirement.component.name, required.name))
73
+ }
74
+ }
75
+ }
50
76
  return current
51
77
  }
78
+
79
+ /** Why a saved component cannot require a transient one: a restore would bring it back without it. */
80
+ export const transientRequirementMessage = (requirer: string, required: string): string =>
81
+ `${requirer} requires ${required}, which is transient and not saved: make ${requirer} transient too, or give ${required} a constructor`
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import * as Command from "../Command.ts"
8
8
  import type * as Debug from "../Debug.ts"
9
+ import type * as Snapshot from "../Snapshot.ts"
10
+ import type { Descriptor } from "../Descriptor.ts"
9
11
  import * as Condition from "../Condition.ts"
10
12
  import * as Entity from "../Entity.ts"
11
13
  import * as EntityScope from "../EntityScope.ts"
@@ -14,36 +16,90 @@ import * as Machine from "../Machine.ts"
14
16
  import * as QueryModule from "../Query.ts"
15
17
  import * as Relation from "../Relation.ts"
16
18
  import * as Runtime from "../Runtime.ts"
19
+ import * as Requirement from "../Requirement.ts"
17
20
  import * as Schedule from "../Schedule.ts"
18
21
  import type { Schema } from "../Schema.ts"
19
22
  import * as System from "../System.ts"
23
+ import { requirementIndex, withRequired } from "./requirements.ts"
24
+
25
+ /** Why an exit phase (`onExit`, `onTransition`) cannot contain `applyDeferred()`. */
26
+ export const exitApplyDeferredMessage =
27
+ "onExit and onTransition schedules cannot contain applyDeferred(): the exit phase rolls back as one when a system in it fails, so its commands are applied at the next applyDeferred() after the marker" as const
20
28
 
21
29
  export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Schema.Game<S, Root> => {
30
+ const requirements = requirementIndex(schema)
22
31
  const definedMachines: Array<Machine.StateMachine.Any> = []
23
32
  const definedMachineNames = new Set<string>()
24
33
 
25
- const defineMachine = (name: string, values: readonly [Machine.StateValue, ...Machine.StateValue[]]) => {
34
+ const defineMachine = (
35
+ name: string,
36
+ values: readonly [Machine.StateValue, ...Machine.StateValue[]],
37
+ options?: { readonly within: readonly [Machine.StateMachine.Any, Machine.StateValue] }
38
+ ) => {
26
39
  if (definedMachineNames.has(name)) {
27
40
  throw new Error(`Duplicate state machine name: ${name}`)
28
41
  }
29
- const machine = Machine.StateMachine(name, values)
42
+ const machine = Machine.StateMachine(
43
+ name,
44
+ values,
45
+ options === undefined ? undefined : { machine: options.within[0], state: options.within[1] }
46
+ )
30
47
  definedMachines.push(machine)
31
48
  definedMachineNames.add(name)
32
49
  return machine
33
50
  }
34
51
 
52
+ /**
53
+ * Every scope defined through this game, in definition order. The runtime
54
+ * reads their triggers at `Runtime.make`, so a scope only ever used with
55
+ * `spawnIn` is still despawned by its state change.
56
+ */
57
+ const definedScopes: Array<EntityScope.EntityScope.Any> = []
58
+ const definedScopeNames = new Set<string>()
59
+
60
+ const defineScope = (
61
+ name: string,
62
+ options?: {
63
+ readonly despawnOnEnter?: readonly [Machine.StateMachine.Any, Machine.StateValue]
64
+ readonly despawnOnExit?: readonly [Machine.StateMachine.Any, Machine.StateValue]
65
+ }
66
+ ) => {
67
+ if (definedScopeNames.has(name)) {
68
+ throw new Error(`Duplicate entity scope name: ${name}`)
69
+ }
70
+ const triggers: Array<EntityScope.Trigger> = []
71
+ if (options?.despawnOnExit !== undefined) {
72
+ triggers.push({ phase: "exit", machine: options.despawnOnExit[0], state: options.despawnOnExit[1] })
73
+ }
74
+ if (options?.despawnOnEnter !== undefined) {
75
+ triggers.push({ phase: "enter", machine: options.despawnOnEnter[0], state: options.despawnOnEnter[1] })
76
+ }
77
+ const scope = EntityScope.make(name, triggers)
78
+ definedScopes.push(scope)
79
+ definedScopeNames.add(name)
80
+ return scope
81
+ }
82
+
35
83
  const transitionSchedule = (
36
84
  transition: Machine.TransitionScheduleDefinition["transition"],
37
85
  plan: ReadonlyArray<Schedule.ScheduleEntry>
38
- ) => ({ ...Schedule.make(schema, plan), transition })
86
+ ) => {
87
+ const schedule = Schedule.make(schema, plan)
88
+ if (transition.phase !== "enter" && schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyDeferred")) {
89
+ throw new Error(exitApplyDeferredMessage)
90
+ }
91
+ // The machine is a requirement: a runtime without it could never run the schedule.
92
+ return { ...schedule, requirements: Requirement.collect([...schedule.requirements, transition.machine]), transition }
93
+ }
39
94
 
40
95
  const game = {
41
96
  schema,
42
- EntityScope: (name: string) => EntityScope.make(name),
97
+ EntityScope: defineScope,
43
98
  Inspector: (name: string, spec: object, read: (context: any) => unknown) =>
44
99
  Inspector.make(name, { schema, ...spec } as never, read),
45
100
  Entity: {
46
- handle: Entity.handle
101
+ handle: Entity.handle,
102
+ id: (handle: number) => Entity.makeEntityId(handle)
47
103
  },
48
104
  Query: Object.assign((spec: never) => QueryModule.Query(spec), {
49
105
  read: QueryModule.read,
@@ -51,6 +107,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
51
107
  optional: QueryModule.optional,
52
108
  added: QueryModule.added,
53
109
  changed: QueryModule.changed,
110
+ anyOf: QueryModule.anyOf,
54
111
  readRelation: Relation.read,
55
112
  optionalRelation: Relation.optional,
56
113
  readRelated: Relation.readRelated,
@@ -79,11 +136,15 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
79
136
  {
80
137
  readResource: System.readResource,
81
138
  writeResource: System.writeResource,
139
+ changedResource: System.changedResource,
82
140
  readEvent: System.readEvent,
83
141
  writeEvent: System.writeEvent,
84
142
  thisTick: System.thisTick,
85
143
  commands: Command.commands,
86
- spawns: Command.spawns,
144
+ // `spawns(Player)` also allows what `Player` requires (`Schema.requires`), matching its type.
145
+ spawns: (...components: ReadonlyArray<Descriptor<"component", string, any>>) =>
146
+ // Internally erased: the bound signature already requires at least one component.
147
+ Command.spawns(...(withRequired(requirements, components) as unknown as readonly [Descriptor<"component", string, any>])),
87
148
  inserts: Command.inserts,
88
149
  removes: Command.removes,
89
150
  despawns: Command.despawns,
@@ -126,7 +187,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
126
187
  readonly resources?: object
127
188
  readonly machines?: Runtime.RuntimeMachines<any>
128
189
  readonly debug?: Debug.Option
129
- }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines } as never),
190
+ readonly saves?: Snapshot.Migrations
191
+ }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines, scopeDefinitions: definedScopes } as never),
130
192
  service: Runtime.service,
131
193
  services: Runtime.services,
132
194
  machine: Runtime.machine,
@@ -4,19 +4,23 @@
4
4
  * `Schedule.ts` (`TickOrderErrors`) and produces the same messages.
5
5
  */
6
6
  import type { Descriptor } from "../Descriptor.ts"
7
+ import type * as Query from "../Query.ts"
7
8
  import * as Schedule from "../Schedule.ts"
8
9
  import type { SystemDefinition } from "../System.ts"
9
10
 
10
11
  type AnySystem = SystemDefinition<any, any, any, any, any>
11
12
  type OrderIssue = Schedule.OrderIssue
12
- type Access = { readonly mode: "read" | "write"; readonly descriptor: Descriptor.Any; readonly thisTick?: true }
13
+ type Access = { readonly mode: "read" | "changed" | "write"; readonly descriptor: Descriptor.Any; readonly thisTick?: true }
13
14
  type QueryAccess = {
14
15
  readonly thisTick?: true
15
16
  readonly selection: Record<string, { readonly mode?: string; readonly descriptor?: Descriptor.Any }>
16
17
  readonly with: ReadonlyArray<Descriptor.Any>
17
18
  readonly without: ReadonlyArray<Descriptor.Any>
18
- readonly filters: ReadonlyArray<{ readonly descriptor?: Descriptor.Any }>
19
+ readonly filters: ReadonlyArray<Query.Filter<any>>
19
20
  }
21
+ /** Every component a filter names: the one of `added` / `changed`, or each member of `anyOf`. */
22
+ const filterNames = (filter: Query.Filter<any>): ReadonlyArray<string> =>
23
+ filter.kind === "anyOf" ? filter.filters.map((member) => member.descriptor.name) : [filter.descriptor.name]
20
24
  type Permission = { readonly permission: string; readonly values: ReadonlyArray<{ readonly name: string }> }
21
25
 
22
26
  /** One position of a tick: a system, or a point where queued commands are applied. */
@@ -65,7 +69,7 @@ const changesQuery = (system: AnySystem, query: QueryAccess): boolean => {
65
69
  ...slots.flatMap((slot) => slot.descriptor ? [slot.descriptor.name] : []),
66
70
  ...query.with.map((d) => d.name),
67
71
  ...query.without.map((d) => d.name),
68
- ...query.filters.flatMap((filter) => filter.descriptor ? [filter.descriptor.name] : [])
72
+ ...query.filters.flatMap((filter) => filterNames(filter))
69
73
  ])
70
74
  const changes = [...permissionNames(system, "inserts"), ...permissionNames(system, "removes")]
71
75
  if (changes.some((name) => named.has(name))) return true
@@ -95,7 +99,7 @@ const computeIssues = (steps: ReadonlyArray<Schedule.ScheduleStep>): ReadonlyArr
95
99
  const systemsAt = items.flatMap((item, at) => item.kind === "system" ? [{ system: item.system, at }] : [])
96
100
  for (const { system: reader, at: index } of systemsAt) {
97
101
  for (const read of accesses(reader)) {
98
- if (read.mode !== "read" || read.thisTick !== true) continue
102
+ if ((read.mode !== "read" && read.mode !== "changed") || read.thisTick !== true) continue
99
103
  const data = read.descriptor.name
100
104
  for (const { system, at } of systemsAt) {
101
105
  if (at <= index || sameSystem(system, reader)) continue