@typeonce/ritmo 0.6.0 → 0.8.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 (148) hide show
  1. package/README.md +2 -0
  2. package/dist/Brand.d.ts +9 -4
  3. package/dist/Brand.d.ts.map +1 -1
  4. package/dist/Brand.js +9 -4
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Clock.d.ts +186 -0
  7. package/dist/Clock.d.ts.map +1 -0
  8. package/dist/Clock.js +111 -0
  9. package/dist/Clock.js.map +1 -0
  10. package/dist/Command.d.ts +250 -41
  11. package/dist/Command.d.ts.map +1 -1
  12. package/dist/Command.js +59 -23
  13. package/dist/Command.js.map +1 -1
  14. package/dist/Condition.d.ts +12 -1
  15. package/dist/Condition.d.ts.map +1 -1
  16. package/dist/Condition.js.map +1 -1
  17. package/dist/Debug.d.ts +163 -15
  18. package/dist/Debug.d.ts.map +1 -1
  19. package/dist/Debug.js.map +1 -1
  20. package/dist/Decode.d.ts +1 -1
  21. package/dist/Decode.js +1 -1
  22. package/dist/Descriptor.d.ts +67 -24
  23. package/dist/Descriptor.d.ts.map +1 -1
  24. package/dist/Descriptor.js +32 -15
  25. package/dist/Descriptor.js.map +1 -1
  26. package/dist/Entity.d.ts +22 -5
  27. package/dist/Entity.d.ts.map +1 -1
  28. package/dist/Entity.js +2 -2
  29. package/dist/Entity.js.map +1 -1
  30. package/dist/EntityScope.d.ts +26 -1
  31. package/dist/EntityScope.d.ts.map +1 -1
  32. package/dist/EntityScope.js +14 -2
  33. package/dist/EntityScope.js.map +1 -1
  34. package/dist/HostClock.d.ts +49 -0
  35. package/dist/HostClock.d.ts.map +1 -0
  36. package/dist/HostClock.js +46 -0
  37. package/dist/HostClock.js.map +1 -0
  38. package/dist/Inspector.d.ts +7 -1
  39. package/dist/Inspector.d.ts.map +1 -1
  40. package/dist/Inspector.js.map +1 -1
  41. package/dist/Machine.d.ts +67 -36
  42. package/dist/Machine.d.ts.map +1 -1
  43. package/dist/Machine.js +10 -3
  44. package/dist/Machine.js.map +1 -1
  45. package/dist/Query.d.ts +74 -26
  46. package/dist/Query.d.ts.map +1 -1
  47. package/dist/Query.js +32 -28
  48. package/dist/Query.js.map +1 -1
  49. package/dist/Runtime.d.ts +39 -16
  50. package/dist/Runtime.d.ts.map +1 -1
  51. package/dist/Runtime.js +556 -87
  52. package/dist/Runtime.js.map +1 -1
  53. package/dist/Schedule.d.ts +217 -10
  54. package/dist/Schedule.d.ts.map +1 -1
  55. package/dist/Schedule.js +5 -0
  56. package/dist/Schedule.js.map +1 -1
  57. package/dist/Schema.d.ts +196 -44
  58. package/dist/Schema.d.ts.map +1 -1
  59. package/dist/Schema.js +29 -11
  60. package/dist/Schema.js.map +1 -1
  61. package/dist/Snapshot.d.ts +14 -0
  62. package/dist/Snapshot.d.ts.map +1 -1
  63. package/dist/Snapshot.js.map +1 -1
  64. package/dist/System.d.ts +212 -58
  65. package/dist/System.d.ts.map +1 -1
  66. package/dist/System.js +84 -6
  67. package/dist/System.js.map +1 -1
  68. package/dist/index.d.ts +8 -7
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +8 -7
  71. package/dist/index.js.map +1 -1
  72. package/dist/internal/cells.d.ts +3 -3
  73. package/dist/internal/cells.d.ts.map +1 -1
  74. package/dist/internal/cells.js +9 -11
  75. package/dist/internal/cells.js.map +1 -1
  76. package/dist/internal/debug.d.ts +13 -0
  77. package/dist/internal/debug.d.ts.map +1 -1
  78. package/dist/internal/debug.js +192 -7
  79. package/dist/internal/debug.js.map +1 -1
  80. package/dist/internal/equal.d.ts +8 -0
  81. package/dist/internal/equal.d.ts.map +1 -0
  82. package/dist/internal/equal.js +38 -0
  83. package/dist/internal/equal.js.map +1 -0
  84. package/dist/internal/fragments.d.ts.map +1 -1
  85. package/dist/internal/fragments.js +11 -1
  86. package/dist/internal/fragments.js.map +1 -1
  87. package/dist/internal/game.d.ts.map +1 -1
  88. package/dist/internal/game.js +41 -6
  89. package/dist/internal/game.js.map +1 -1
  90. package/dist/internal/order.d.ts +10 -0
  91. package/dist/internal/order.d.ts.map +1 -0
  92. package/dist/internal/order.js +122 -0
  93. package/dist/internal/order.js.map +1 -0
  94. package/dist/internal/queries.d.ts.map +1 -1
  95. package/dist/internal/queries.js +32 -10
  96. package/dist/internal/queries.js.map +1 -1
  97. package/dist/internal/requirements.d.ts +26 -0
  98. package/dist/internal/requirements.d.ts.map +1 -0
  99. package/dist/internal/requirements.js +46 -0
  100. package/dist/internal/requirements.js.map +1 -0
  101. package/package.json +1 -1
  102. package/src/Brand.ts +9 -4
  103. package/src/Clock.ts +311 -0
  104. package/src/Command.ts +407 -89
  105. package/src/Condition.ts +17 -1
  106. package/src/Debug.ts +172 -14
  107. package/src/Decode.ts +1 -1
  108. package/src/Descriptor.ts +92 -37
  109. package/src/Entity.ts +24 -5
  110. package/src/EntityScope.ts +27 -2
  111. package/src/HostClock.ts +64 -0
  112. package/src/Inspector.ts +7 -2
  113. package/src/Machine.ts +81 -37
  114. package/src/Query.ts +110 -38
  115. package/src/Runtime.ts +607 -111
  116. package/src/Schedule.ts +380 -13
  117. package/src/Schema.ts +249 -63
  118. package/src/Snapshot.ts +4 -0
  119. package/src/System.ts +248 -93
  120. package/src/index.ts +8 -7
  121. package/src/internal/cells.ts +10 -12
  122. package/src/internal/debug.ts +208 -12
  123. package/src/internal/equal.ts +31 -0
  124. package/src/internal/fragments.ts +12 -2
  125. package/src/internal/game.ts +61 -7
  126. package/src/internal/order.ts +144 -0
  127. package/src/internal/queries.ts +37 -13
  128. package/src/internal/requirements.ts +71 -0
  129. package/dist/Definition.d.ts +0 -99
  130. package/dist/Definition.d.ts.map +0 -1
  131. package/dist/Definition.js +0 -68
  132. package/dist/Definition.js.map +0 -1
  133. package/dist/Feature.d.ts +0 -106
  134. package/dist/Feature.d.ts.map +0 -1
  135. package/dist/Feature.js +0 -87
  136. package/dist/Feature.js.map +0 -1
  137. package/dist/Fx.d.ts +0 -128
  138. package/dist/Fx.d.ts.map +0 -1
  139. package/dist/Fx.js +0 -112
  140. package/dist/Fx.js.map +0 -1
  141. package/dist/internal/definition.d.ts +0 -17
  142. package/dist/internal/definition.d.ts.map +0 -1
  143. package/dist/internal/definition.js +0 -25
  144. package/dist/internal/definition.js.map +0 -1
  145. package/src/Definition.ts +0 -106
  146. package/src/Feature.ts +0 -241
  147. package/src/Fx.ts +0 -176
  148. package/src/internal/definition.ts +0 -56
@@ -18,7 +18,8 @@ abstract class WriteCellBase<T> {
18
18
  abstract get(): T
19
19
  abstract set(value: T): void
20
20
 
21
- setResult<E>(result: Result.Result<T, E>): Result.Result<void, E> {
21
+ /** Writes a validated candidate only on success. Backs `setRaw` / `updateRaw`. */
22
+ writeResult<E>(result: Result.Result<T, E>): Result.Result<void, E> {
22
23
  if (!result.ok) {
23
24
  return Result.failure(result.error)
24
25
  }
@@ -30,9 +31,6 @@ abstract class WriteCellBase<T> {
30
31
  this.set(f(this.get()))
31
32
  }
32
33
 
33
- updateResult<E>(f: (current: T) => Result.Result<T, E>): Result.Result<void, E> {
34
- return this.setResult(f(this.get()))
35
- }
36
34
  }
37
35
 
38
36
  /**
@@ -43,22 +41,22 @@ const withConstructor = <T, Raw, Error>(
43
41
  constructor: ResultConstructor<T, Raw, Error>
44
42
  ) => Object.assign(cell, {
45
43
  setRaw(raw: Raw): Result.Result<void, Error> {
46
- return cell.setResult(constructor.result(raw))
44
+ return cell.writeResult(constructor.result(raw))
47
45
  },
48
46
  updateRaw(f: (current: T) => Raw): Result.Result<void, Error> {
49
- return cell.setResult(constructor.result(f(cell.get())))
47
+ return cell.writeResult(constructor.result(f(cell.get())))
50
48
  }
51
49
  })
52
50
 
53
51
  /**
54
- * Adds `transition` to the write cell of a state component: a compare-and-set
52
+ * Adds `transition` to the write cell of an entity state component: a compare-and-set
55
53
  * that writes `to` only while the current state is still `from`.
56
54
  */
57
- const withTransition = (cell: WriteCellBase<unknown>, state: string) => Object.assign(cell, {
58
- transition(from: unknown, to: unknown): Result.Result<void, { readonly _tag: "StateMismatch"; readonly state: string; readonly expected: unknown; readonly actual: unknown }> {
55
+ const withTransition = (cell: WriteCellBase<unknown>, component: string) => Object.assign(cell, {
56
+ transition(from: unknown, to: unknown): Result.Result<void, { readonly _tag: "EntityStateMismatch"; readonly component: string; readonly expected: unknown; readonly actual: unknown }> {
59
57
  const actual = cell.get()
60
58
  if (actual !== from) {
61
- return Result.failure({ _tag: "StateMismatch", state, expected: from, actual })
59
+ return Result.failure({ _tag: "EntityStateMismatch", component, expected: from, actual })
62
60
  }
63
61
  cell.set(to)
64
62
  return Result.success(undefined)
@@ -162,11 +160,11 @@ export const componentWrite = (
162
160
  ordinal: number,
163
161
  world: World,
164
162
  constructor: ResultConstructor<unknown, unknown, unknown> | undefined,
165
- state: string | undefined
163
+ entityState: string | undefined
166
164
  ): WriteCellBase<unknown> => {
167
165
  const cell = new ComponentWriteCell(record, ordinal, world)
168
166
  const constructed = constructor === undefined ? cell : withConstructor(cell, constructor)
169
- return state === undefined ? constructed : withTransition(constructed, state)
167
+ return entityState === undefined ? constructed : withTransition(constructed, entityState)
170
168
  }
171
169
 
172
170
  export const componentOptional = (record: EntityRecord, ordinal: number): { readonly present: boolean; get(): unknown } =>
@@ -6,11 +6,13 @@
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"
12
13
  import type * as Relation from "../Relation.ts"
13
14
  import * as Schedule from "../Schedule.ts"
15
+ import * as OrderInternal from "./order.ts"
14
16
  import type { Schema } from "../Schema.ts"
15
17
  import type { SystemDefinition } from "../System.ts"
16
18
 
@@ -71,6 +73,9 @@ export const transitionScheduleName = (schedule: TransitionSchedule): string =>
71
73
  const names = <A>(record: Record<string, A>, name: (value: A) => string): Array<string> =>
72
74
  Object.values(record).map(name)
73
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
+
74
79
  const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDescription => {
75
80
  const reads: Array<string> = []
76
81
  const writes: Array<string> = []
@@ -101,8 +106,11 @@ const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDe
101
106
  optional,
102
107
  with: query.with.map((descriptor) => descriptor.name),
103
108
  without: query.without.map((descriptor) => descriptor.name),
104
- added: query.filters.filter((filter) => filter.kind === "added").map((filter) => filter.descriptor.name),
105
- 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
+ : []),
106
114
  relations: [
107
115
  ...relations,
108
116
  ...relationNames(query.withRelations, "with"),
@@ -113,11 +121,35 @@ const describeQuery = (slot: string, query: Query.Query.Any<any>): Debug.QueryDe
113
121
  }
114
122
  }
115
123
 
116
- type Mode = { readonly mode: "read" | "write"; readonly descriptor: Descriptor.Any }
124
+ type Mode = { readonly mode: "read" | "changed" | "write"; readonly descriptor: Descriptor.Any }
125
+ type CommandAccess = { readonly permissions: ReadonlyArray<{ readonly permission: keyof Debug.CommandPermissions; readonly values: ReadonlyArray<{ readonly name: string }> }> }
126
+
127
+ const permissionsOf = (access: CommandAccess | undefined): Debug.CommandPermissions => {
128
+ const of = (kind: keyof Debug.CommandPermissions) =>
129
+ [...new Set((access?.permissions ?? []).filter((p) => p.permission === kind).flatMap((p) => p.values.map((value) => value.name)))]
130
+ return {
131
+ spawns: of("spawns"),
132
+ inserts: of("inserts"),
133
+ removes: of("removes"),
134
+ despawns: of("despawns"),
135
+ despawnsScope: of("despawnsScope"),
136
+ relates: of("relates")
137
+ }
138
+ }
139
+
140
+ const permissionVerb: Record<keyof Debug.CommandPermissions, string> = {
141
+ spawns: "spawn",
142
+ inserts: "insert",
143
+ removes: "remove",
144
+ despawns: "despawn",
145
+ despawnsScope: "despawn the scope",
146
+ relates: "relate"
147
+ }
148
+ type ThisTickAccess = Mode & { readonly thisTick?: true }
117
149
 
118
150
  export const describeSystem = (system: AnySystem, placements: ReadonlyArray<string>): Debug.SystemDescription => {
119
151
  const spec = system.spec
120
- const byMode = (record: Record<string, Mode>, mode: "read" | "write") =>
152
+ const byMode = (record: Record<string, Mode>, mode: Mode["mode"]) =>
121
153
  Object.values(record).filter((access) => access.mode === mode).map((access) => access.descriptor.name)
122
154
  const machineName = (access: { readonly machine: Machine.StateMachine.Any }) => access.machine.name
123
155
  // A system also reads whatever its `check` conditions read.
@@ -135,7 +167,8 @@ export const describeSystem = (system: AnySystem, placements: ReadonlyArray<stri
135
167
  Object.entries(check.spec.queries as Record<string, Query.Query.Any<any>>).map(([slot, query]) => describeQuery(`check(${check.name}).${slot}`, query)))
136
168
  ],
137
169
  resources: {
138
- 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"),
139
172
  writes: byMode(spec.resources, "write")
140
173
  },
141
174
  events: { reads: byMode(spec.events, "read"), writes: byMode(spec.events, "write") },
@@ -148,7 +181,16 @@ export const describeSystem = (system: AnySystem, placements: ReadonlyArray<stri
148
181
  removed: names(spec.removed as Record<string, { readonly descriptor: Descriptor.Any }>, (access) => access.descriptor.name),
149
182
  despawned: Object.keys(spec.despawned).length > 0,
150
183
  relationFailures: names(spec.relationFailures as Record<string, { readonly relation: Relation.Relation.Any }>, (access) => access.relation.name),
184
+ thisTick: [
185
+ ...[...Object.values(spec.events as Record<string, ThisTickAccess>), ...Object.values(spec.resources as Record<string, ThisTickAccess>)]
186
+ .filter((access) => access.thisTick === true)
187
+ .map((access) => access.descriptor.name),
188
+ ...Object.entries(spec.queries as Record<string, { readonly thisTick?: true }>)
189
+ .filter(([, query]) => query.thisTick === true)
190
+ .map(([slot]) => `queries.${slot}`)
191
+ ],
151
192
  services: names(spec.services as Record<string, { readonly descriptor: Descriptor.Any }>, (access) => access.descriptor.name),
193
+ commands: permissionsOf(spec.commands as CommandAccess | undefined),
152
194
  when: (spec.when as ReadonlyArray<Machine.Condition>).map(renderCondition),
153
195
  pauseWhen: (spec.pauseWhen as ReadonlyArray<Machine.Condition>).map(renderCondition),
154
196
  local: spec.local !== undefined
@@ -226,8 +268,20 @@ export interface DescribeInput {
226
268
  readonly schema: Schema.Any
227
269
  readonly machines: ReadonlyArray<Machine.StateMachine.Any>
228
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>
229
275
  readonly providedServices: ReadonlyArray<string>
230
276
  readonly externallyWrittenEvents: ReadonlySet<string>
277
+ /** Relations commands have tried to relate or reorder, by the queuing systems. */
278
+ readonly relationMutations: ReadonlyMap<string, ReadonlySet<string>>
279
+ /** What each system's applied commands did, as `kind:name`; see `Runtime`. */
280
+ readonly commandUses: ReadonlyMap<string, ReadonlySet<string>>
281
+ /** Systems that ran in this session. */
282
+ readonly ranSystems: ReadonlySet<string>
283
+ /** Commands skipped because they would have broken a `Schema.requires` requirement. */
284
+ readonly requirementViolations: ReadonlyArray<Debug.RequirementViolation>
231
285
  readonly hasResource: (descriptor: Descriptor.Any) => boolean
232
286
  readonly schedules: ReadonlyArray<readonly [string, AnySchedule]>
233
287
  }
@@ -269,6 +323,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
269
323
  }
270
324
  }
271
325
  for (const name of system.removed) components.read(name, system.name)
326
+ for (const name of [...system.commands.spawns, ...system.commands.inserts, ...system.commands.removes]) components.write(name, system.name)
272
327
  for (const name of system.resources.reads) resources.read(name, system.name)
273
328
  for (const name of system.resources.writes) resources.write(name, system.name)
274
329
  for (const name of system.events.reads) events.read(name, system.name)
@@ -283,7 +338,21 @@ export const describe = (input: DescribeInput): Debug.Description => {
283
338
  const appliesTransitions = schedules.some(([, schedule]) =>
284
339
  schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyStateTransitions"))
285
340
 
286
- const lints: Array<Debug.Lint> = []
341
+ const lints: Array<Omit<Debug.Lint, "fix">> = []
342
+ const triggerName = (trigger: EntityScope.Trigger) =>
343
+ `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`
344
+ for (const scope of input.scopes) {
345
+ for (const trigger of scope.despawnOn) {
346
+ const machine = input.machines.find((candidate) => candidate.key === trigger.machine.key)
347
+ if (machine !== undefined && input.providesMachine(machine)) continue
348
+ lints.push({
349
+ severity: "warning",
350
+ code: "scope-trigger-never-applied",
351
+ subject: scope.name,
352
+ 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`
353
+ })
354
+ }
355
+ }
287
356
  if (schedules.length > 0) {
288
357
  for (const entry of events.list()) {
289
358
  if (entry.writers.length > 0 && entry.readers.length === 0) {
@@ -313,7 +382,85 @@ export const describe = (input: DescribeInput): Debug.Description => {
313
382
  })
314
383
  }
315
384
  }
385
+ for (const scope of input.scopes) {
386
+ for (const trigger of scope.despawnOn) {
387
+ const machine = input.machines.find((candidate) => candidate.key === trigger.machine.key)
388
+ if (machine === undefined || !input.providesMachine(machine)) continue
389
+ const reason = !appliesTransitions
390
+ ? "no described schedule has an applyStateTransitions() marker"
391
+ : !queuedWithParents(machine, nextStates)
392
+ ? `no described system queues a next state of ${machine.name}`
393
+ : undefined
394
+ if (reason === undefined) continue
395
+ lints.push({
396
+ severity: "warning",
397
+ code: "scope-trigger-never-applied",
398
+ subject: scope.name,
399
+ message: `${scope.name} is despawned on ${triggerName(trigger)}, but ${reason}, so the scope is never despawned`
400
+ })
401
+ }
402
+ }
403
+ const failureReaders = new Set(systems.flatMap((system) => system.relationFailures))
404
+ // Declared `relates` permissions, and relations that commands were seen to relate (drafts relate without one).
405
+ const relaters = new Map<string, Set<string>>()
406
+ for (const system of systems) {
407
+ for (const name of system.commands.relates) relaters.set(name, new Set([...(relaters.get(name) ?? []), system.name]))
408
+ }
409
+ for (const [name, queuers] of input.relationMutations) relaters.set(name, new Set([...(relaters.get(name) ?? []), ...queuers]))
410
+ for (const [name, queuers] of relaters) {
411
+ if (failureReaders.has(name)) continue
412
+ lints.push({
413
+ severity: "warning",
414
+ code: "relation-failure-never-read",
415
+ subject: name,
416
+ 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`
417
+ })
418
+ }
419
+ const resourceWriters = new Set(systems.flatMap((system) => system.resources.writes))
420
+ const changedReaders = new Map<string, Array<string>>()
421
+ for (const system of systems) {
422
+ for (const name of system.resources.changed) changedReaders.set(name, [...(changedReaders.get(name) ?? []), system.name])
423
+ }
424
+ for (const [name, readers] of changedReaders) {
425
+ if (resourceWriters.has(name)) continue
426
+ lints.push({
427
+ severity: "warning",
428
+ code: "resource-changed-never-written",
429
+ subject: name,
430
+ message: `${name} is read with changedResource by ${readers.join(", ")}, but no described system writes it, so changed() is true only on the first run`
431
+ })
432
+ }
316
433
  lints.push(...readBeforeWriteLints(schedules, described, input.externallyWrittenEvents))
434
+ // Each named schedule is checked as one tick; `tick` checks this at compile time.
435
+ for (const [name, schedule] of input.schedules) {
436
+ for (const issue of OrderInternal.tickOrderIssues(schedule.steps)) {
437
+ lints.push({ severity: "warning", code: "this-tick-order", subject: issue.data, message: `in ${name}, ${issue.message}` })
438
+ }
439
+ }
440
+ // Observed with debug: only systems that ran, so an unplayed session reports nothing.
441
+ for (const system of systems) {
442
+ if (!input.ranSystems.has(system.name)) continue
443
+ const uses = input.commandUses.get(system.name) ?? new Set<string>()
444
+ for (const kind of Object.keys(permissionVerb) as Array<keyof Debug.CommandPermissions>) {
445
+ const unused = system.commands[kind].filter((name) => !uses.has(`${kind}:${name}`))
446
+ if (unused.length === 0) continue
447
+ lints.push({
448
+ severity: "info",
449
+ code: "command-permission-unused",
450
+ subject: system.name,
451
+ message: `${system.name} may ${permissionVerb[kind]} ${unused.join(", ")} but never did in this session`
452
+ })
453
+ }
454
+ }
455
+ for (const violation of input.requirementViolations) {
456
+ const times = violation.count === 1 ? "once" : `${violation.count} times`
457
+ lints.push({
458
+ severity: "warning",
459
+ code: "requirement-violated",
460
+ subject: violation.system,
461
+ message: `${violation.system} queued ${violation.command === "insert" ? "an" : "a"} ${violation.command} that would have left ${violation.component} without ${violation.missing} (last on entity ${violation.entity}), so it was skipped ${times}`
462
+ })
463
+ }
317
464
  for (const entry of components.list()) {
318
465
  if (entry.writers.length > 0 && entry.readers.every((reader) => entry.writers.includes(reader))) {
319
466
  lints.push({
@@ -328,7 +475,14 @@ export const describe = (input: DescribeInput): Debug.Description => {
328
475
 
329
476
  return {
330
477
  version: 1,
331
- components: componentDescriptors.map((descriptor) => ({ name: descriptor.name, storage: storageOf(descriptor) })),
478
+ components: componentDescriptors.map((descriptor) => ({
479
+ name: descriptor.name,
480
+ storage: storageOf(descriptor),
481
+ immutable: DescriptorModule.isImmutable(descriptor),
482
+ requires: [...new Set(input.schema.requires
483
+ .filter((requirement) => requirement.component.name === descriptor.name)
484
+ .flatMap((requirement) => requirement.required.map((required) => required.name)))]
485
+ })),
332
486
  resources: resourceDescriptors.map((descriptor) => ({
333
487
  name: descriptor.name,
334
488
  storage: storageOf(descriptor),
@@ -345,8 +499,11 @@ export const describe = (input: DescribeInput): Debug.Description => {
345
499
  machines: input.machines.map((machine) => ({
346
500
  name: machine.name,
347
501
  states: machine.values,
348
- current: input.currentMachine(machine)
502
+ current: input.currentMachine(machine),
503
+ provided: input.providesMachine(machine),
504
+ within: machine.within === undefined ? undefined : `${machine.within.machine.name}=${String(machine.within.state)}`
349
505
  })),
506
+ scopes: input.scopes.map((scope) => ({ name: scope.name, despawnOn: scope.despawnOn.map(triggerName) })),
350
507
  services: describeServices(input.providedServices, systems),
351
508
  schedules: schedules.map(([name, schedule]) => ({ name, steps: schedule.steps.map(describeStep) })),
352
509
  systems,
@@ -355,7 +512,46 @@ export const describe = (input: DescribeInput): Debug.Description => {
355
512
  resources: resources.list(),
356
513
  events: events.list()
357
514
  },
358
- lints
515
+ lints: lints.map((lint) => ({ ...lint, fix: fixFor(lint.code, lint.subject) }))
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Whether a described system queues a next state of `machine`, or of a
521
+ * machine it exists within: a parent leaving its state changes a sub-state too.
522
+ */
523
+ const queuedWithParents = (machine: Machine.StateMachine.Any, nextStates: ReadonlyMap<string, unknown>): boolean => {
524
+ for (let current: Machine.StateMachine.Any | undefined = machine; current !== undefined; current = current.within?.machine) {
525
+ if (nextStates.has(current.name)) return true
526
+ }
527
+ return false
528
+ }
529
+
530
+ /** The fix each lint names, by code. */
531
+ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
532
+ switch (code) {
533
+ case "event-never-read":
534
+ return `read ${subject} in a system with Game.System.readEvent, or stop emitting it; if only a render schedule reads it, pass that schedule as describe-only`
535
+ case "event-never-written":
536
+ 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)`
537
+ case "next-state-never-applied":
538
+ return `add Game.Schedule.applyStateTransitions(...) to a schedule that runs after the system that queues ${subject}`
539
+ case "resource-changed-never-written":
540
+ 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`
541
+ case "scope-trigger-never-applied":
542
+ 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`
543
+ case "relation-failure-never-read":
544
+ return `read the failures with Game.System.readRelationFailures(${subject}) in a system and handle them`
545
+ case "this-tick-order":
546
+ return "move the writer before the reader in the schedule (the message names both), or read without Game.System.thisTick"
547
+ case "component-never-read":
548
+ return `fine if ${subject} is read outside the described schedules (rendering, inspectors); otherwise stop writing it`
549
+ case "requirement-violated":
550
+ 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()`
551
+ case "command-permission-unused":
552
+ return `fine if the session did not reach the case where ${subject} uses it; otherwise drop the permission from its Game.System.commands(...)`
553
+ case "read-before-write":
554
+ return "fine when a one-run lag is intended (input before movement); otherwise move the reader after the writer, or read with Game.System.thisTick to make it an error"
359
555
  }
360
556
  }
361
557
 
@@ -367,7 +563,7 @@ const accessesOf = (system: Debug.SystemDescription): Record<"component" | "reso
367
563
  reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed]),
368
564
  writes: system.queries.flatMap((query) => query.writes)
369
565
  },
370
- resource: system.resources,
566
+ resource: { reads: system.resources.reads, writes: system.resources.writes },
371
567
  event: system.events
372
568
  })
373
569
 
@@ -380,8 +576,8 @@ const readBeforeWriteLints = (
380
576
  schedules: ReadonlyArray<readonly [string, AnySchedule]>,
381
577
  described: ReadonlyMap<AnySystem, Debug.SystemDescription>,
382
578
  externallyWrittenEvents: ReadonlySet<string>
383
- ): Array<Debug.Lint> => {
384
- const lints: Array<Debug.Lint> = []
579
+ ): Array<Omit<Debug.Lint, "fix">> => {
580
+ const lints: Array<Omit<Debug.Lint, "fix">> = []
385
581
  for (const [name, schedule] of schedules) {
386
582
  const steps = schedule.steps.flatMap((step) =>
387
583
  Schedule.isSystemStep(step) ? [{ system: described.get(step as AnySystem)!, accesses: accessesOf(described.get(step as AnySystem)!) }] : [])
@@ -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
+ }
@@ -14,7 +14,8 @@ export const emptySchema = (): SchemaDefinition => ({
14
14
  components: {},
15
15
  resources: {},
16
16
  events: {},
17
- relations: {}
17
+ relations: {},
18
+ requires: []
18
19
  })
19
20
 
20
21
  /**
@@ -35,10 +36,11 @@ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry
35
36
  }
36
37
 
37
38
  export const mergeSchemas = (left: Schema.Any, right: Schema.Any): Schema.Any => {
38
- const merged: Record<string, NamedRegistry> = {}
39
+ const merged: Record<string, unknown> = {}
39
40
  for (const kind of kinds) {
40
41
  merged[kind] = mergeRegistry(left[kind], right[kind])
41
42
  }
43
+ merged["requires"] = [...left.requires, ...right.requires]
42
44
  return merged as unknown as Schema.Any
43
45
  }
44
46
 
@@ -47,5 +49,13 @@ export const buildSchema = (fragments: ReadonlyArray<Schema.Any>): Schema.Any =>
47
49
  for (const fragment of fragments) {
48
50
  current = mergeSchemas(current, fragment)
49
51
  }
52
+ const registered = new Set(Object.values(current.components).map((descriptor) => descriptor.name))
53
+ for (const requirement of current.requires) {
54
+ for (const descriptor of [requirement.component, ...requirement.required]) {
55
+ if (!registered.has(descriptor.name)) {
56
+ throw new Error(`${descriptor.name} is named in Schema.requires(...) but no fragment registers it: add it to a fragment's components`)
57
+ }
58
+ }
59
+ }
50
60
  return current
51
61
  }
@@ -5,6 +5,8 @@
5
5
  * module functions; the precise public types live on `Schema.Game`.
6
6
  */
7
7
  import * as Command from "../Command.ts"
8
+ import type * as Debug from "../Debug.ts"
9
+ import type { Descriptor } from "../Descriptor.ts"
8
10
  import * as Condition from "../Condition.ts"
9
11
  import * as Entity from "../Entity.ts"
10
12
  import * as EntityScope from "../EntityScope.ts"
@@ -16,21 +18,62 @@ import * as Runtime from "../Runtime.ts"
16
18
  import * as Schedule from "../Schedule.ts"
17
19
  import type { Schema } from "../Schema.ts"
18
20
  import * as System from "../System.ts"
21
+ import { requirementIndex, withRequired } from "./requirements.ts"
19
22
 
20
23
  export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Schema.Game<S, Root> => {
24
+ const requirements = requirementIndex(schema)
21
25
  const definedMachines: Array<Machine.StateMachine.Any> = []
22
26
  const definedMachineNames = new Set<string>()
23
27
 
24
- const defineMachine = (name: string, values: readonly [Machine.StateValue, ...Machine.StateValue[]]) => {
28
+ const defineMachine = (
29
+ name: string,
30
+ values: readonly [Machine.StateValue, ...Machine.StateValue[]],
31
+ options?: { readonly within: readonly [Machine.StateMachine.Any, Machine.StateValue] }
32
+ ) => {
25
33
  if (definedMachineNames.has(name)) {
26
34
  throw new Error(`Duplicate state machine name: ${name}`)
27
35
  }
28
- const machine = Machine.StateMachine(name, values)
36
+ const machine = Machine.StateMachine(
37
+ name,
38
+ values,
39
+ options === undefined ? undefined : { machine: options.within[0], state: options.within[1] }
40
+ )
29
41
  definedMachines.push(machine)
30
42
  definedMachineNames.add(name)
31
43
  return machine
32
44
  }
33
45
 
46
+ /**
47
+ * Every scope defined through this game, in definition order. The runtime
48
+ * reads their triggers at `Runtime.make`, so a scope only ever used with
49
+ * `spawnIn` is still despawned by its state change.
50
+ */
51
+ const definedScopes: Array<EntityScope.EntityScope.Any> = []
52
+ const definedScopeNames = new Set<string>()
53
+
54
+ const defineScope = (
55
+ name: string,
56
+ options?: {
57
+ readonly despawnOnEnter?: readonly [Machine.StateMachine.Any, Machine.StateValue]
58
+ readonly despawnOnExit?: readonly [Machine.StateMachine.Any, Machine.StateValue]
59
+ }
60
+ ) => {
61
+ if (definedScopeNames.has(name)) {
62
+ throw new Error(`Duplicate entity scope name: ${name}`)
63
+ }
64
+ const triggers: Array<EntityScope.Trigger> = []
65
+ if (options?.despawnOnExit !== undefined) {
66
+ triggers.push({ phase: "exit", machine: options.despawnOnExit[0], state: options.despawnOnExit[1] })
67
+ }
68
+ if (options?.despawnOnEnter !== undefined) {
69
+ triggers.push({ phase: "enter", machine: options.despawnOnEnter[0], state: options.despawnOnEnter[1] })
70
+ }
71
+ const scope = EntityScope.make(name, triggers)
72
+ definedScopes.push(scope)
73
+ definedScopeNames.add(name)
74
+ return scope
75
+ }
76
+
34
77
  const transitionSchedule = (
35
78
  transition: Machine.TransitionScheduleDefinition["transition"],
36
79
  plan: ReadonlyArray<Schedule.ScheduleEntry>
@@ -38,7 +81,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
38
81
 
39
82
  const game = {
40
83
  schema,
41
- EntityScope: (name: string) => EntityScope.make(name),
84
+ EntityScope: defineScope,
42
85
  Inspector: (name: string, spec: object, read: (context: any) => unknown) =>
43
86
  Inspector.make(name, { schema, ...spec } as never, read),
44
87
  Entity: {
@@ -50,6 +93,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
50
93
  optional: QueryModule.optional,
51
94
  added: QueryModule.added,
52
95
  changed: QueryModule.changed,
96
+ anyOf: QueryModule.anyOf,
53
97
  readRelation: Relation.read,
54
98
  optionalRelation: Relation.optional,
55
99
  readRelated: Relation.readRelated,
@@ -57,9 +101,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
57
101
  }),
58
102
  Command: {
59
103
  spawn: Command.spawn,
60
- insert: Command.insert,
104
+ extend: Command.extend,
61
105
  entry: Command.entry,
62
- entryResult: Command.entryResult,
63
106
  entryRaw: Command.entryRaw,
64
107
  relate: Command.relate
65
108
  },
@@ -79,8 +122,19 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
79
122
  {
80
123
  readResource: System.readResource,
81
124
  writeResource: System.writeResource,
125
+ changedResource: System.changedResource,
82
126
  readEvent: System.readEvent,
83
127
  writeEvent: System.writeEvent,
128
+ thisTick: System.thisTick,
129
+ commands: Command.commands,
130
+ // `spawns(Player)` also allows what `Player` requires (`Schema.requires`), matching its type.
131
+ spawns: (...components: ReadonlyArray<Descriptor<"component", string, any>>) =>
132
+ Command.spawns(...withRequired(requirements, components)),
133
+ inserts: Command.inserts,
134
+ removes: Command.removes,
135
+ despawns: Command.despawns,
136
+ despawnsScope: Command.despawnsScope,
137
+ relates: Command.relates,
84
138
  service: System.service,
85
139
  machine: System.machine,
86
140
  nextState: System.nextState,
@@ -117,8 +171,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
117
171
  readonly services: Runtime.RuntimeServices<any>
118
172
  readonly resources?: object
119
173
  readonly machines?: Runtime.RuntimeMachines<any>
120
- readonly debug?: true
121
- }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines } as never),
174
+ readonly debug?: Debug.Option
175
+ }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines, scopeDefinitions: definedScopes } as never),
122
176
  service: Runtime.service,
123
177
  services: Runtime.services,
124
178
  machine: Runtime.machine,