@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.
- package/dist/Command.d.ts +102 -17
- package/dist/Command.d.ts.map +1 -1
- package/dist/Command.js +10 -8
- package/dist/Command.js.map +1 -1
- package/dist/Condition.d.ts +12 -1
- package/dist/Condition.d.ts.map +1 -1
- package/dist/Condition.js.map +1 -1
- package/dist/Debug.d.ts +63 -7
- package/dist/Debug.d.ts.map +1 -1
- package/dist/Descriptor.d.ts +36 -3
- package/dist/Descriptor.d.ts.map +1 -1
- package/dist/Descriptor.js +11 -3
- package/dist/Descriptor.js.map +1 -1
- package/dist/Entity.d.ts +12 -4
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js.map +1 -1
- package/dist/EntityScope.d.ts +20 -1
- package/dist/EntityScope.d.ts.map +1 -1
- package/dist/EntityScope.js +8 -2
- package/dist/EntityScope.js.map +1 -1
- package/dist/Machine.d.ts +55 -33
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +6 -2
- package/dist/Machine.js.map +1 -1
- package/dist/Query.d.ts +48 -4
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +32 -4
- package/dist/Query.js.map +1 -1
- package/dist/Runtime.d.ts +3 -0
- package/dist/Runtime.d.ts.map +1 -1
- package/dist/Runtime.js +330 -24
- package/dist/Runtime.js.map +1 -1
- package/dist/Schema.d.ts +141 -25
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +29 -3
- package/dist/Schema.js.map +1 -1
- package/dist/Snapshot.d.ts +14 -0
- package/dist/Snapshot.d.ts.map +1 -1
- package/dist/Snapshot.js.map +1 -1
- package/dist/System.d.ts +64 -7
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js +32 -0
- package/dist/System.js.map +1 -1
- package/dist/internal/debug.d.ts +7 -0
- package/dist/internal/debug.d.ts.map +1 -1
- package/dist/internal/debug.js +97 -6
- package/dist/internal/debug.js.map +1 -1
- package/dist/internal/equal.d.ts +8 -0
- package/dist/internal/equal.d.ts.map +1 -0
- package/dist/internal/equal.js +38 -0
- package/dist/internal/equal.js.map +1 -0
- package/dist/internal/fragments.d.ts.map +1 -1
- package/dist/internal/fragments.js +11 -1
- package/dist/internal/fragments.js.map +1 -1
- package/dist/internal/game.d.ts.map +1 -1
- package/dist/internal/game.js +33 -5
- package/dist/internal/game.js.map +1 -1
- package/dist/internal/order.d.ts.map +1 -1
- package/dist/internal/order.js +4 -2
- package/dist/internal/order.js.map +1 -1
- package/dist/internal/queries.d.ts.map +1 -1
- package/dist/internal/queries.js +30 -8
- package/dist/internal/queries.js.map +1 -1
- package/dist/internal/requirements.d.ts +26 -0
- package/dist/internal/requirements.d.ts.map +1 -0
- package/dist/internal/requirements.js +46 -0
- package/dist/internal/requirements.js.map +1 -0
- package/package.json +1 -1
- package/src/Command.ts +149 -29
- package/src/Condition.ts +17 -1
- package/src/Debug.ts +64 -7
- package/src/Descriptor.ts +55 -10
- package/src/Entity.ts +13 -4
- package/src/EntityScope.ts +21 -2
- package/src/Machine.ts +68 -34
- package/src/Query.ts +71 -7
- package/src/Runtime.ts +336 -22
- package/src/Schema.ts +185 -28
- package/src/Snapshot.ts +4 -0
- package/src/System.ts +75 -6
- package/src/internal/debug.ts +104 -8
- package/src/internal/equal.ts +31 -0
- package/src/internal/fragments.ts +12 -2
- package/src/internal/game.ts +51 -5
- package/src/internal/order.ts +8 -4
- package/src/internal/queries.ts +34 -10
- package/src/internal/requirements.ts +71 -0
package/src/internal/debug.ts
CHANGED
|
@@ -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
|
|
106
|
-
changed: query.filters
|
|
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: "
|
|
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) => ({
|
|
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,
|
|
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
|
}
|
package/src/internal/game.ts
CHANGED
|
@@ -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 = (
|
|
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(
|
|
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:
|
|
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
|
|
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,
|
package/src/internal/order.ts
CHANGED
|
@@ -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<
|
|
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
|
|
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
|
package/src/internal/queries.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 =
|
|
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
|
+
}
|