@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.
- package/README.md +2 -0
- package/dist/Brand.d.ts +9 -4
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Brand.js +9 -4
- package/dist/Brand.js.map +1 -1
- package/dist/Clock.d.ts +186 -0
- package/dist/Clock.d.ts.map +1 -0
- package/dist/Clock.js +111 -0
- package/dist/Clock.js.map +1 -0
- package/dist/Command.d.ts +250 -41
- package/dist/Command.d.ts.map +1 -1
- package/dist/Command.js +59 -23
- 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 +163 -15
- package/dist/Debug.d.ts.map +1 -1
- package/dist/Debug.js.map +1 -1
- package/dist/Decode.d.ts +1 -1
- package/dist/Decode.js +1 -1
- package/dist/Descriptor.d.ts +67 -24
- package/dist/Descriptor.d.ts.map +1 -1
- package/dist/Descriptor.js +32 -15
- package/dist/Descriptor.js.map +1 -1
- package/dist/Entity.d.ts +22 -5
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js +2 -2
- package/dist/Entity.js.map +1 -1
- package/dist/EntityScope.d.ts +26 -1
- package/dist/EntityScope.d.ts.map +1 -1
- package/dist/EntityScope.js +14 -2
- package/dist/EntityScope.js.map +1 -1
- package/dist/HostClock.d.ts +49 -0
- package/dist/HostClock.d.ts.map +1 -0
- package/dist/HostClock.js +46 -0
- package/dist/HostClock.js.map +1 -0
- package/dist/Inspector.d.ts +7 -1
- package/dist/Inspector.d.ts.map +1 -1
- package/dist/Inspector.js.map +1 -1
- package/dist/Machine.d.ts +67 -36
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +10 -3
- package/dist/Machine.js.map +1 -1
- package/dist/Query.d.ts +74 -26
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +32 -28
- package/dist/Query.js.map +1 -1
- package/dist/Runtime.d.ts +39 -16
- package/dist/Runtime.d.ts.map +1 -1
- package/dist/Runtime.js +556 -87
- package/dist/Runtime.js.map +1 -1
- package/dist/Schedule.d.ts +217 -10
- package/dist/Schedule.d.ts.map +1 -1
- package/dist/Schedule.js +5 -0
- package/dist/Schedule.js.map +1 -1
- package/dist/Schema.d.ts +196 -44
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +29 -11
- 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 +212 -58
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js +84 -6
- package/dist/System.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -7
- package/dist/index.js.map +1 -1
- package/dist/internal/cells.d.ts +3 -3
- package/dist/internal/cells.d.ts.map +1 -1
- package/dist/internal/cells.js +9 -11
- package/dist/internal/cells.js.map +1 -1
- package/dist/internal/debug.d.ts +13 -0
- package/dist/internal/debug.d.ts.map +1 -1
- package/dist/internal/debug.js +192 -7
- 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 +41 -6
- package/dist/internal/game.js.map +1 -1
- package/dist/internal/order.d.ts +10 -0
- package/dist/internal/order.d.ts.map +1 -0
- package/dist/internal/order.js +122 -0
- package/dist/internal/order.js.map +1 -0
- package/dist/internal/queries.d.ts.map +1 -1
- package/dist/internal/queries.js +32 -10
- 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/Brand.ts +9 -4
- package/src/Clock.ts +311 -0
- package/src/Command.ts +407 -89
- package/src/Condition.ts +17 -1
- package/src/Debug.ts +172 -14
- package/src/Decode.ts +1 -1
- package/src/Descriptor.ts +92 -37
- package/src/Entity.ts +24 -5
- package/src/EntityScope.ts +27 -2
- package/src/HostClock.ts +64 -0
- package/src/Inspector.ts +7 -2
- package/src/Machine.ts +81 -37
- package/src/Query.ts +110 -38
- package/src/Runtime.ts +607 -111
- package/src/Schedule.ts +380 -13
- package/src/Schema.ts +249 -63
- package/src/Snapshot.ts +4 -0
- package/src/System.ts +248 -93
- package/src/index.ts +8 -7
- package/src/internal/cells.ts +10 -12
- package/src/internal/debug.ts +208 -12
- package/src/internal/equal.ts +31 -0
- package/src/internal/fragments.ts +12 -2
- package/src/internal/game.ts +61 -7
- package/src/internal/order.ts +144 -0
- package/src/internal/queries.ts +37 -13
- package/src/internal/requirements.ts +71 -0
- package/dist/Definition.d.ts +0 -99
- package/dist/Definition.d.ts.map +0 -1
- package/dist/Definition.js +0 -68
- package/dist/Definition.js.map +0 -1
- package/dist/Feature.d.ts +0 -106
- package/dist/Feature.d.ts.map +0 -1
- package/dist/Feature.js +0 -87
- package/dist/Feature.js.map +0 -1
- package/dist/Fx.d.ts +0 -128
- package/dist/Fx.d.ts.map +0 -1
- package/dist/Fx.js +0 -112
- package/dist/Fx.js.map +0 -1
- package/dist/internal/definition.d.ts +0 -17
- package/dist/internal/definition.d.ts.map +0 -1
- package/dist/internal/definition.js +0 -25
- package/dist/internal/definition.js.map +0 -1
- package/src/Definition.ts +0 -106
- package/src/Feature.ts +0 -241
- package/src/Fx.ts +0 -176
- package/src/internal/definition.ts +0 -56
package/src/internal/cells.ts
CHANGED
|
@@ -18,7 +18,8 @@ abstract class WriteCellBase<T> {
|
|
|
18
18
|
abstract get(): T
|
|
19
19
|
abstract set(value: T): void
|
|
20
20
|
|
|
21
|
-
|
|
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.
|
|
44
|
+
return cell.writeResult(constructor.result(raw))
|
|
47
45
|
},
|
|
48
46
|
updateRaw(f: (current: T) => Raw): Result.Result<void, Error> {
|
|
49
|
-
return cell.
|
|
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
|
|
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>,
|
|
58
|
-
transition(from: unknown, to: unknown): Result.Result<void, { readonly _tag: "
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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 } =>
|
package/src/internal/debug.ts
CHANGED
|
@@ -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
|
|
105
|
-
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
|
+
: []),
|
|
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: "
|
|
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) => ({
|
|
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,
|
|
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
|
@@ -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 = (
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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?:
|
|
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,
|