@typeonce/ritmo 0.7.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 (87) hide show
  1. package/dist/Command.d.ts +102 -17
  2. package/dist/Command.d.ts.map +1 -1
  3. package/dist/Command.js +10 -8
  4. package/dist/Command.js.map +1 -1
  5. package/dist/Condition.d.ts +12 -1
  6. package/dist/Condition.d.ts.map +1 -1
  7. package/dist/Condition.js.map +1 -1
  8. package/dist/Debug.d.ts +63 -7
  9. package/dist/Debug.d.ts.map +1 -1
  10. package/dist/Descriptor.d.ts +36 -3
  11. package/dist/Descriptor.d.ts.map +1 -1
  12. package/dist/Descriptor.js +11 -3
  13. package/dist/Descriptor.js.map +1 -1
  14. package/dist/Entity.d.ts +12 -4
  15. package/dist/Entity.d.ts.map +1 -1
  16. package/dist/Entity.js.map +1 -1
  17. package/dist/EntityScope.d.ts +20 -1
  18. package/dist/EntityScope.d.ts.map +1 -1
  19. package/dist/EntityScope.js +8 -2
  20. package/dist/EntityScope.js.map +1 -1
  21. package/dist/Machine.d.ts +55 -33
  22. package/dist/Machine.d.ts.map +1 -1
  23. package/dist/Machine.js +6 -2
  24. package/dist/Machine.js.map +1 -1
  25. package/dist/Query.d.ts +48 -4
  26. package/dist/Query.d.ts.map +1 -1
  27. package/dist/Query.js +32 -4
  28. package/dist/Query.js.map +1 -1
  29. package/dist/Runtime.d.ts +3 -0
  30. package/dist/Runtime.d.ts.map +1 -1
  31. package/dist/Runtime.js +330 -24
  32. package/dist/Runtime.js.map +1 -1
  33. package/dist/Schema.d.ts +141 -25
  34. package/dist/Schema.d.ts.map +1 -1
  35. package/dist/Schema.js +29 -3
  36. package/dist/Schema.js.map +1 -1
  37. package/dist/Snapshot.d.ts +14 -0
  38. package/dist/Snapshot.d.ts.map +1 -1
  39. package/dist/Snapshot.js.map +1 -1
  40. package/dist/System.d.ts +64 -7
  41. package/dist/System.d.ts.map +1 -1
  42. package/dist/System.js +32 -0
  43. package/dist/System.js.map +1 -1
  44. package/dist/internal/debug.d.ts +7 -0
  45. package/dist/internal/debug.d.ts.map +1 -1
  46. package/dist/internal/debug.js +97 -6
  47. package/dist/internal/debug.js.map +1 -1
  48. package/dist/internal/equal.d.ts +8 -0
  49. package/dist/internal/equal.d.ts.map +1 -0
  50. package/dist/internal/equal.js +38 -0
  51. package/dist/internal/equal.js.map +1 -0
  52. package/dist/internal/fragments.d.ts.map +1 -1
  53. package/dist/internal/fragments.js +11 -1
  54. package/dist/internal/fragments.js.map +1 -1
  55. package/dist/internal/game.d.ts.map +1 -1
  56. package/dist/internal/game.js +33 -5
  57. package/dist/internal/game.js.map +1 -1
  58. package/dist/internal/order.d.ts.map +1 -1
  59. package/dist/internal/order.js +4 -2
  60. package/dist/internal/order.js.map +1 -1
  61. package/dist/internal/queries.d.ts.map +1 -1
  62. package/dist/internal/queries.js +30 -8
  63. package/dist/internal/queries.js.map +1 -1
  64. package/dist/internal/requirements.d.ts +26 -0
  65. package/dist/internal/requirements.d.ts.map +1 -0
  66. package/dist/internal/requirements.js +46 -0
  67. package/dist/internal/requirements.js.map +1 -0
  68. package/package.json +1 -1
  69. package/src/Command.ts +149 -29
  70. package/src/Condition.ts +17 -1
  71. package/src/Debug.ts +64 -7
  72. package/src/Descriptor.ts +55 -10
  73. package/src/Entity.ts +13 -4
  74. package/src/EntityScope.ts +21 -2
  75. package/src/Machine.ts +68 -34
  76. package/src/Query.ts +71 -7
  77. package/src/Runtime.ts +336 -22
  78. package/src/Schema.ts +185 -28
  79. package/src/Snapshot.ts +4 -0
  80. package/src/System.ts +75 -6
  81. package/src/internal/debug.ts +104 -8
  82. package/src/internal/equal.ts +31 -0
  83. package/src/internal/fragments.ts +12 -2
  84. package/src/internal/game.ts +51 -5
  85. package/src/internal/order.ts +8 -4
  86. package/src/internal/queries.ts +34 -10
  87. package/src/internal/requirements.ts +71 -0
@@ -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,8 @@ 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>
271
285
  readonly hasResource: (descriptor: Descriptor.Any) => boolean
272
286
  readonly schedules: ReadonlyArray<readonly [string, AnySchedule]>
273
287
  }
@@ -325,6 +339,20 @@ export const describe = (input: DescribeInput): Debug.Description => {
325
339
  schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyStateTransitions"))
326
340
 
327
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
+ }
328
356
  if (schedules.length > 0) {
329
357
  for (const entry of events.list()) {
330
358
  if (entry.writers.length > 0 && entry.readers.length === 0) {
@@ -354,6 +382,24 @@ export const describe = (input: DescribeInput): Debug.Description => {
354
382
  })
355
383
  }
356
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
+ }
357
403
  const failureReaders = new Set(systems.flatMap((system) => system.relationFailures))
358
404
  // Declared `relates` permissions, and relations that commands were seen to relate (drafts relate without one).
359
405
  const relaters = new Map<string, Set<string>>()
@@ -370,6 +416,20 @@ export const describe = (input: DescribeInput): Debug.Description => {
370
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`
371
417
  })
372
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
+ }
373
433
  lints.push(...readBeforeWriteLints(schedules, described, input.externallyWrittenEvents))
374
434
  // Each named schedule is checked as one tick; `tick` checks this at compile time.
375
435
  for (const [name, schedule] of input.schedules) {
@@ -392,6 +452,15 @@ export const describe = (input: DescribeInput): Debug.Description => {
392
452
  })
393
453
  }
394
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
+ }
395
464
  for (const entry of components.list()) {
396
465
  if (entry.writers.length > 0 && entry.readers.every((reader) => entry.writers.includes(reader))) {
397
466
  lints.push({
@@ -406,7 +475,14 @@ export const describe = (input: DescribeInput): Debug.Description => {
406
475
 
407
476
  return {
408
477
  version: 1,
409
- 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
+ })),
410
486
  resources: resourceDescriptors.map((descriptor) => ({
411
487
  name: descriptor.name,
412
488
  storage: storageOf(descriptor),
@@ -423,8 +499,11 @@ export const describe = (input: DescribeInput): Debug.Description => {
423
499
  machines: input.machines.map((machine) => ({
424
500
  name: machine.name,
425
501
  states: machine.values,
426
- 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)}`
427
505
  })),
506
+ scopes: input.scopes.map((scope) => ({ name: scope.name, despawnOn: scope.despawnOn.map(triggerName) })),
428
507
  services: describeServices(input.providedServices, systems),
429
508
  schedules: schedules.map(([name, schedule]) => ({ name, steps: schedule.steps.map(describeStep) })),
430
509
  systems,
@@ -437,6 +516,17 @@ export const describe = (input: DescribeInput): Debug.Description => {
437
516
  }
438
517
  }
439
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
+
440
530
  /** The fix each lint names, by code. */
441
531
  const fixFor = (code: Debug.Lint["code"], subject: string): string => {
442
532
  switch (code) {
@@ -446,12 +536,18 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
446
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)`
447
537
  case "next-state-never-applied":
448
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`
449
543
  case "relation-failure-never-read":
450
544
  return `read the failures with Game.System.readRelationFailures(${subject}) in a system and handle them`
451
545
  case "this-tick-order":
452
546
  return "move the writer before the reader in the schedule (the message names both), or read without Game.System.thisTick"
453
547
  case "component-never-read":
454
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()`
455
551
  case "command-permission-unused":
456
552
  return `fine if the session did not reach the case where ${subject} uses it; otherwise drop the permission from its Game.System.commands(...)`
457
553
  case "read-before-write":
@@ -467,7 +563,7 @@ const accessesOf = (system: Debug.SystemDescription): Record<"component" | "reso
467
563
  reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed]),
468
564
  writes: system.queries.flatMap((query) => query.writes)
469
565
  },
470
- resource: system.resources,
566
+ resource: { reads: system.resources.reads, writes: system.resources.writes },
471
567
  event: system.events
472
568
  })
473
569
 
@@ -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
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import * as Command from "../Command.ts"
8
8
  import type * as Debug from "../Debug.ts"
9
+ import type { Descriptor } from "../Descriptor.ts"
9
10
  import * as Condition from "../Condition.ts"
10
11
  import * as Entity from "../Entity.ts"
11
12
  import * as EntityScope from "../EntityScope.ts"
@@ -17,21 +18,62 @@ import * as Runtime from "../Runtime.ts"
17
18
  import * as Schedule from "../Schedule.ts"
18
19
  import type { Schema } from "../Schema.ts"
19
20
  import * as System from "../System.ts"
21
+ import { requirementIndex, withRequired } from "./requirements.ts"
20
22
 
21
23
  export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Schema.Game<S, Root> => {
24
+ const requirements = requirementIndex(schema)
22
25
  const definedMachines: Array<Machine.StateMachine.Any> = []
23
26
  const definedMachineNames = new Set<string>()
24
27
 
25
- 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
+ ) => {
26
33
  if (definedMachineNames.has(name)) {
27
34
  throw new Error(`Duplicate state machine name: ${name}`)
28
35
  }
29
- 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
+ )
30
41
  definedMachines.push(machine)
31
42
  definedMachineNames.add(name)
32
43
  return machine
33
44
  }
34
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
+
35
77
  const transitionSchedule = (
36
78
  transition: Machine.TransitionScheduleDefinition["transition"],
37
79
  plan: ReadonlyArray<Schedule.ScheduleEntry>
@@ -39,7 +81,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
39
81
 
40
82
  const game = {
41
83
  schema,
42
- EntityScope: (name: string) => EntityScope.make(name),
84
+ EntityScope: defineScope,
43
85
  Inspector: (name: string, spec: object, read: (context: any) => unknown) =>
44
86
  Inspector.make(name, { schema, ...spec } as never, read),
45
87
  Entity: {
@@ -51,6 +93,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
51
93
  optional: QueryModule.optional,
52
94
  added: QueryModule.added,
53
95
  changed: QueryModule.changed,
96
+ anyOf: QueryModule.anyOf,
54
97
  readRelation: Relation.read,
55
98
  optionalRelation: Relation.optional,
56
99
  readRelated: Relation.readRelated,
@@ -79,11 +122,14 @@ 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,
84
128
  thisTick: System.thisTick,
85
129
  commands: Command.commands,
86
- spawns: Command.spawns,
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)),
87
133
  inserts: Command.inserts,
88
134
  removes: Command.removes,
89
135
  despawns: Command.despawns,
@@ -126,7 +172,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
126
172
  readonly resources?: object
127
173
  readonly machines?: Runtime.RuntimeMachines<any>
128
174
  readonly debug?: Debug.Option
129
- }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines } as never),
175
+ }) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines, scopeDefinitions: definedScopes } as never),
130
176
  service: Runtime.service,
131
177
  services: Runtime.services,
132
178
  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
@@ -46,6 +46,9 @@ interface LifecycleFilterPlan {
46
46
  readonly ordinal: number
47
47
  }
48
48
 
49
+ /** A query filter: one lifecycle filter, or `anyOf` several (passes when one does). */
50
+ type FilterPlan = ReadonlyArray<LifecycleFilterPlan>
51
+
49
52
  interface QueryState {
50
53
  readonly ordinal: number
51
54
  readonly required: ReadonlyArray<number>
@@ -54,7 +57,7 @@ interface QueryState {
54
57
  readonly withoutRelations: ReadonlyArray<Relation.Relation.Any>
55
58
  readonly withRelated: ReadonlyArray<Relation.Relation.Any>
56
59
  readonly withoutRelated: ReadonlyArray<Relation.Relation.Any>
57
- readonly filters: ReadonlyArray<LifecycleFilterPlan>
60
+ readonly filters: ReadonlyArray<FilterPlan>
58
61
  readonly slots: ReadonlyArray<SlotPlan>
59
62
  readonly proofSlots: ReadonlyArray<readonly [string, number]>
60
63
  readonly writeSlots: ReadonlyArray<readonly [string, number]>
@@ -185,11 +188,13 @@ export const makeQueryEngine = (world: World) => {
185
188
  withoutRelations: query.withoutRelations,
186
189
  withRelated: unique(withRelated),
187
190
  withoutRelated: query.withoutRelated,
188
- filters: query.filters.map((filter) => {
189
- const ordinal = world.ordinalOf(filter.descriptor)
190
- world.trackChanges(ordinal)
191
- return { kind: filter.kind, ordinal }
192
- }),
191
+ filters: query.filters.map((filter) =>
192
+ (filter.kind === "anyOf" ? filter.filters : [filter]).map((member) => {
193
+ const ordinal = world.ordinalOf(member.descriptor)
194
+ world.trackChanges(ordinal)
195
+ return { kind: member.kind, ordinal }
196
+ })
197
+ ),
193
198
  slots,
194
199
  proofSlots,
195
200
  writeSlots,
@@ -239,14 +244,33 @@ export const makeQueryEngine = (world: World) => {
239
244
 
240
245
  const matchesFilters = (state: QueryState, record: EntityRecord, since: number): boolean => {
241
246
  for (const filter of state.filters) {
242
- const matched = filter.kind === "added"
243
- ? world.addedSince(record, filter.ordinal, since)
244
- : world.changedSince(record, filter.ordinal, since)
247
+ let matched = false
248
+ for (const member of filter) {
249
+ matched = member.kind === "added"
250
+ ? world.addedSince(record, member.ordinal, since)
251
+ : world.changedSince(record, member.ordinal, since)
252
+ if (matched) break
253
+ }
245
254
  if (!matched) return false
246
255
  }
247
256
  return true
248
257
  }
249
258
 
259
+ /**
260
+ * Ids that may pass one filter (may repeat), or `undefined` when a change
261
+ * log no longer covers `since`. An `anyOf` is the union of its members' logs.
262
+ */
263
+ const filterCandidates = (filter: FilterPlan, since: number): ReadonlyArray<number> | undefined => {
264
+ if (filter.length === 1) return world.changedCandidates(filter[0]!.ordinal, since)
265
+ const union: Array<number> = []
266
+ for (const member of filter) {
267
+ const logged = world.changedCandidates(member.ordinal, since)
268
+ if (logged === undefined) return undefined
269
+ for (const id of logged) union.push(id)
270
+ }
271
+ return union
272
+ }
273
+
250
274
  const relatedIds = (relation: Relation.Relation.Any, id: number): ReadonlyArray<Entity.EntityId<any, any>> =>
251
275
  world.relatedSourceIds(relation, id).map(world.entityIdOf)
252
276
 
@@ -426,7 +450,7 @@ export const makeQueryEngine = (world: World) => {
426
450
  // bounds the candidates for either filter.
427
451
  let candidates: ReadonlyArray<number> | undefined
428
452
  for (const filter of state.filters) {
429
- const logged = world.changedCandidates(filter.ordinal, since)
453
+ const logged = filterCandidates(filter, since)
430
454
  if (logged === undefined) continue
431
455
  if (logged.length === 0) return []
432
456
  if (candidates === undefined || logged.length < candidates.length) candidates = logged
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Runtime side of `Schema.requires`: the requirement index built once per
3
+ * bound schema, used to expand `Game.System.spawns(...)`, to check commands
4
+ * when they are applied, and to check restored entities.
5
+ *
6
+ * The compile-time counterpart lives in `Command.ts` (spawn, insert, remove)
7
+ * and `Schema.ts` (`ValidateRequires`).
8
+ */
9
+ import type { Descriptor } from "../Descriptor.ts"
10
+ import type { Schema } from "../Schema.ts"
11
+
12
+ type ComponentDescriptor = Descriptor<"component", string, any>
13
+
14
+ export interface RequirementIndex {
15
+ /** Whether the schema declares any requirement. */
16
+ readonly empty: boolean
17
+ /** Component name to the components it directly requires. */
18
+ readonly requires: ReadonlyMap<string, ReadonlyArray<ComponentDescriptor>>
19
+ /** Component name to the components that directly require it. */
20
+ readonly requiredBy: ReadonlyMap<string, ReadonlyArray<ComponentDescriptor>>
21
+ }
22
+
23
+ const push = (map: Map<string, Array<ComponentDescriptor>>, key: string, value: ComponentDescriptor): void => {
24
+ const list = map.get(key)
25
+ if (list === undefined) map.set(key, [value])
26
+ else if (!list.some((entry) => entry.name === value.name)) list.push(value)
27
+ }
28
+
29
+ export const requirementIndex = (schema: Schema.Any): RequirementIndex => {
30
+ const requires = new Map<string, Array<ComponentDescriptor>>()
31
+ const requiredBy = new Map<string, Array<ComponentDescriptor>>()
32
+ for (const requirement of schema.requires) {
33
+ for (const required of requirement.required) {
34
+ push(requires, requirement.component.name, required)
35
+ push(requiredBy, required.name, requirement.component)
36
+ }
37
+ }
38
+ return { empty: requires.size === 0, requires, requiredBy }
39
+ }
40
+
41
+ /** `components` and everything they require, transitively, without duplicates. */
42
+ export const withRequired = (
43
+ index: RequirementIndex,
44
+ components: ReadonlyArray<ComponentDescriptor>
45
+ ): ReadonlyArray<ComponentDescriptor> => {
46
+ if (index.empty) return components
47
+ const result: Array<ComponentDescriptor> = []
48
+ const seen = new Set<string>()
49
+ const visit = (descriptor: ComponentDescriptor): void => {
50
+ if (seen.has(descriptor.name)) return
51
+ seen.add(descriptor.name)
52
+ result.push(descriptor)
53
+ for (const required of index.requires.get(descriptor.name) ?? []) visit(required)
54
+ }
55
+ components.forEach(visit)
56
+ return result
57
+ }
58
+
59
+ /** The first requirement `held` breaks, as `[requirer, missing]`, or `undefined`. */
60
+ export const firstMissing = (
61
+ index: RequirementIndex,
62
+ held: (name: string) => boolean,
63
+ names: Iterable<string>
64
+ ): readonly [string, string] | undefined => {
65
+ for (const name of names) {
66
+ for (const required of index.requires.get(name) ?? []) {
67
+ if (!held(required.name)) return [name, required.name]
68
+ }
69
+ }
70
+ return undefined
71
+ }