@typeonce/ritmo 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/Clock.d.ts +19 -4
- package/dist/Clock.d.ts.map +1 -1
- package/dist/Clock.js +18 -2
- package/dist/Clock.js.map +1 -1
- package/dist/Command.d.ts +101 -45
- package/dist/Command.d.ts.map +1 -1
- package/dist/Command.js +22 -4
- package/dist/Command.js.map +1 -1
- package/dist/Debug.d.ts +66 -8
- package/dist/Debug.d.ts.map +1 -1
- package/dist/Descriptor.d.ts +27 -9
- package/dist/Descriptor.d.ts.map +1 -1
- package/dist/Descriptor.js +13 -9
- package/dist/Descriptor.js.map +1 -1
- package/dist/Entity.d.ts +15 -8
- package/dist/Entity.d.ts.map +1 -1
- package/dist/Entity.js.map +1 -1
- package/dist/FixedLoop.d.ts +106 -0
- package/dist/FixedLoop.d.ts.map +1 -0
- package/dist/FixedLoop.js +97 -0
- package/dist/FixedLoop.js.map +1 -0
- package/dist/HostClock.d.ts +1 -1
- package/dist/HostClock.d.ts.map +1 -1
- package/dist/HostClock.js +2 -1
- package/dist/HostClock.js.map +1 -1
- package/dist/Machine.d.ts +4 -1
- package/dist/Machine.d.ts.map +1 -1
- package/dist/Machine.js +4 -1
- package/dist/Machine.js.map +1 -1
- package/dist/Query.d.ts +52 -10
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +2 -1
- package/dist/Query.js.map +1 -1
- package/dist/Relation.d.ts +5 -14
- package/dist/Relation.d.ts.map +1 -1
- package/dist/Relation.js +0 -32
- package/dist/Relation.js.map +1 -1
- package/dist/Runtime.d.ts +58 -15
- package/dist/Runtime.d.ts.map +1 -1
- package/dist/Runtime.js +288 -70
- package/dist/Runtime.js.map +1 -1
- package/dist/Schedule.d.ts +24 -15
- package/dist/Schedule.d.ts.map +1 -1
- package/dist/Schedule.js +2 -1
- package/dist/Schedule.js.map +1 -1
- package/dist/Schema.d.ts +98 -29
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +4 -4
- package/dist/Schema.js.map +1 -1
- package/dist/Snapshot.d.ts +76 -1
- package/dist/Snapshot.d.ts.map +1 -1
- package/dist/Snapshot.js +42 -1
- package/dist/Snapshot.js.map +1 -1
- package/dist/System.d.ts +68 -24
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/cells.d.ts +2 -0
- package/dist/internal/cells.d.ts.map +1 -1
- package/dist/internal/cells.js +34 -8
- package/dist/internal/cells.js.map +1 -1
- package/dist/internal/debug.d.ts +3 -0
- package/dist/internal/debug.d.ts.map +1 -1
- package/dist/internal/debug.js +83 -15
- package/dist/internal/debug.js.map +1 -1
- package/dist/internal/fragments.d.ts +2 -6
- package/dist/internal/fragments.d.ts.map +1 -1
- package/dist/internal/fragments.js +25 -2
- package/dist/internal/fragments.js.map +1 -1
- package/dist/internal/game.d.ts +2 -0
- package/dist/internal/game.d.ts.map +1 -1
- package/dist/internal/game.js +16 -3
- package/dist/internal/game.js.map +1 -1
- package/dist/internal/queries.d.ts.map +1 -1
- package/dist/internal/queries.js +27 -18
- package/dist/internal/queries.js.map +1 -1
- package/dist/internal/relations.d.ts +10 -0
- package/dist/internal/relations.d.ts.map +1 -0
- package/dist/internal/relations.js +32 -0
- package/dist/internal/relations.js.map +1 -0
- package/dist/internal/streams.d.ts +2 -0
- package/dist/internal/streams.d.ts.map +1 -1
- package/dist/internal/streams.js +10 -0
- package/dist/internal/streams.js.map +1 -1
- package/dist/internal/world.d.ts +7 -1
- package/dist/internal/world.d.ts.map +1 -1
- package/dist/internal/world.js +53 -3
- package/dist/internal/world.js.map +1 -1
- package/package.json +1 -1
- package/src/Clock.ts +29 -4
- package/src/Command.ts +161 -67
- package/src/Debug.ts +61 -9
- package/src/Descriptor.ts +43 -17
- package/src/Entity.ts +16 -9
- package/src/FixedLoop.ts +218 -0
- package/src/HostClock.ts +2 -1
- package/src/Machine.ts +4 -1
- package/src/Query.ts +47 -10
- package/src/Relation.ts +5 -67
- package/src/Runtime.ts +345 -85
- package/src/Schedule.ts +31 -19
- package/src/Schema.ts +118 -35
- package/src/Snapshot.ts +90 -2
- package/src/System.ts +76 -23
- package/src/index.ts +4 -0
- package/src/internal/cells.ts +42 -10
- package/src/internal/debug.ts +84 -15
- package/src/internal/fragments.ts +23 -3
- package/src/internal/game.ts +19 -3
- package/src/internal/queries.ts +28 -2
- package/src/internal/relations.ts +52 -0
- package/src/internal/streams.ts +11 -0
- package/src/internal/world.ts +60 -4
package/src/System.ts
CHANGED
|
@@ -461,7 +461,8 @@ export interface EventReadView<T> {
|
|
|
461
461
|
* A writable event stream view.
|
|
462
462
|
*/
|
|
463
463
|
export interface EventWriteView<T> {
|
|
464
|
-
|
|
464
|
+
/** Publishes one event. Takes the readonly shape reads return, so a read event can be emitted again. */
|
|
465
|
+
emit(value: import("./Query.ts").ReadonlyValue<T>): void
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
/**
|
|
@@ -482,6 +483,13 @@ export interface NextMachineWriteView<M extends Machine.StateMachine.Any = Machi
|
|
|
482
483
|
* Queues `value`, committed at the next `applyStateTransitions(...)`. For a
|
|
483
484
|
* sub-state it returns a `Result` that fails with `SubStateInactive` (and
|
|
484
485
|
* queues nothing) while the parent is in another state.
|
|
486
|
+
*
|
|
487
|
+
* One value is queued per machine: a later `set` before the marker
|
|
488
|
+
* replaces an earlier one. A `set` from a transition schedule (`onEnter`,
|
|
489
|
+
* `onExit`, `onTransition`) is committed at the following marker, while a
|
|
490
|
+
* value queued before the current marker still commits at it. A sub-state
|
|
491
|
+
* value set while its parent is still in its state but leaves it at the
|
|
492
|
+
* same marker returns ok and is dropped with the sub-state.
|
|
485
493
|
*/
|
|
486
494
|
set(value: Machine.StateMachine.Value<M>): Machine.SetResult<M>
|
|
487
495
|
/** Like `set`, but committing the current value again is skipped (no exit or enter schedules). */
|
|
@@ -661,10 +669,47 @@ export interface RelationFailureReadView<
|
|
|
661
669
|
* A runtime query handle exposed to system implementations.
|
|
662
670
|
*
|
|
663
671
|
* The handle is already typed from the query spec, so iterating it returns
|
|
664
|
-
* strongly typed entity proofs and cells.
|
|
672
|
+
* strongly typed entity proofs and cells. A query with `added` / `changed`
|
|
673
|
+
* filters only iterates (`each`, `singleOptional`): its lookups would fail
|
|
674
|
+
* for every entity that did not change since this system's previous run, so
|
|
675
|
+
* declare a second query without filters to resolve entities.
|
|
665
676
|
*/
|
|
666
|
-
export
|
|
677
|
+
export type QueryHandle<S extends Schema.Any, Q extends Query.Any> =
|
|
678
|
+
& QueryIteration<S, Q>
|
|
679
|
+
& (Q["filters"] extends readonly [] ? QueryLookups<S, Q> : FilteredQueryLookups)
|
|
680
|
+
|
|
681
|
+
/** What every query handle has: iteration over its current matches. */
|
|
682
|
+
export interface QueryIteration<S extends Schema.Any, Q extends Query.Any> {
|
|
667
683
|
each(): ReadonlyArray<QueryMatch<S, Q>>
|
|
684
|
+
/**
|
|
685
|
+
* Returns a single match when zero or one entity satisfies the query.
|
|
686
|
+
*
|
|
687
|
+
* Use this when absence is acceptable but multiplicity is still a bug. Zero
|
|
688
|
+
* matches return `ok: true` with `value: undefined`; multiple matches remain
|
|
689
|
+
* an explicit `MultipleEntities` failure.
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* ```ts
|
|
693
|
+
* const player = queries.player.singleOptional()
|
|
694
|
+
* if (!player.ok || !player.value) {
|
|
695
|
+
* return
|
|
696
|
+
* }
|
|
697
|
+
*
|
|
698
|
+
* player.value.data.position.set({ x: 0, y: 0 })
|
|
699
|
+
* ```
|
|
700
|
+
*/
|
|
701
|
+
singleOptional(): Result.Result<QueryMatch<S, Q> | undefined, Query.MultipleEntitiesError>
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
type FilteredLookupMessage = "lookups need a query without added/changed filters: declare a second query for resolving entities"
|
|
705
|
+
|
|
706
|
+
/** The lookups of a query with filters, each replaced by the message naming the fix. */
|
|
707
|
+
export type FilteredQueryLookups = {
|
|
708
|
+
readonly [K in keyof QueryLookups<Schema.Any, Query.Any>]: (this: FilteredLookupMessage, ...args: ReadonlyArray<unknown>) => never
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Lookups of one entity or one hierarchy through a query without `added` / `changed` filters. */
|
|
712
|
+
export interface QueryLookups<S extends Schema.Any, Q extends Query.Any> {
|
|
668
713
|
/**
|
|
669
714
|
* Retrieves the match for one specific entity id when it satisfies the query.
|
|
670
715
|
*
|
|
@@ -711,24 +756,6 @@ export interface QueryHandle<S extends Schema.Any, Q extends Query.Any> {
|
|
|
711
756
|
* Returns a single match when exactly one entity satisfies the query.
|
|
712
757
|
*/
|
|
713
758
|
single(): Result.Result<QueryMatch<S, Q>, Query.SingleError>
|
|
714
|
-
/**
|
|
715
|
-
* Returns a single match when zero or one entity satisfies the query.
|
|
716
|
-
*
|
|
717
|
-
* Use this when absence is acceptable but multiplicity is still a bug. Zero
|
|
718
|
-
* matches return `ok: true` with `value: undefined`; multiple matches remain
|
|
719
|
-
* an explicit `MultipleEntities` failure.
|
|
720
|
-
*
|
|
721
|
-
* @example
|
|
722
|
-
* ```ts
|
|
723
|
-
* const player = queries.player.singleOptional()
|
|
724
|
-
* if (!player.ok || !player.value) {
|
|
725
|
-
* return
|
|
726
|
-
* }
|
|
727
|
-
*
|
|
728
|
-
* player.value.data.position.set({ x: 0, y: 0 })
|
|
729
|
-
* ```
|
|
730
|
-
*/
|
|
731
|
-
singleOptional(): Result.Result<QueryMatch<S, Q> | undefined, Query.MultipleEntitiesError>
|
|
732
759
|
}
|
|
733
760
|
|
|
734
761
|
/**
|
|
@@ -859,10 +886,36 @@ export interface SystemAccessInput {
|
|
|
859
886
|
*/
|
|
860
887
|
export type SystemAccessSpec = SystemAccessInput
|
|
861
888
|
|
|
862
|
-
/** Rejects unknown system access categories at a bound constructor. */
|
|
889
|
+
/** Rejects unknown system access categories, and redundant resource and event slots, at a bound constructor. */
|
|
863
890
|
export type ExactAccess<Access extends SystemAccessInput> = Access & {
|
|
864
891
|
readonly [Key in Exclude<keyof Access, keyof SystemAccessInput>]: never
|
|
865
|
-
}
|
|
892
|
+
} & RedundantSlotsFix<Access, "resources", "read"> & RedundantSlotsFix<Access, "events", never>
|
|
893
|
+
|
|
894
|
+
type SlotOf<A> = A extends { readonly mode: infer Mode extends string; readonly descriptor: { readonly name: infer Name extends string } } ? readonly [Mode, Name] : never
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Slots of `Record` that repeat another slot: the same descriptor in the same
|
|
898
|
+
* mode, or in `ReadMode` next to any other slot of it (a write or changed
|
|
899
|
+
* view already reads).
|
|
900
|
+
*/
|
|
901
|
+
type RedundantSlots<Record, ReadMode extends string> = {
|
|
902
|
+
readonly [K in keyof Record]: SlotOf<Record[K]> extends readonly [infer Mode, infer Name]
|
|
903
|
+
? [Extract<SlotOf<Omit<Record, K>[keyof Omit<Record, K>]>, readonly [Mode extends ReadMode ? string : Mode, Name]>] extends [never] ? never : K
|
|
904
|
+
: never
|
|
905
|
+
}[keyof Record]
|
|
906
|
+
|
|
907
|
+
type RedundantSlotsFix<Access, Category extends "resources" | "events", ReadMode extends string> =
|
|
908
|
+
Access extends { readonly [C in Category]?: infer Record }
|
|
909
|
+
? [RedundantSlots<Record, ReadMode>] extends [never]
|
|
910
|
+
? unknown
|
|
911
|
+
: {
|
|
912
|
+
readonly [C in Category]: {
|
|
913
|
+
readonly [K in RedundantSlots<Record, ReadMode>]: {
|
|
914
|
+
readonly __fixSystem__: `${K & string} repeats ${Category extends "resources" ? "a resource" : "an event"} another slot of this system declares: keep one slot (a write or changed view reads too)`
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
: unknown
|
|
866
919
|
|
|
867
920
|
type AccessField<
|
|
868
921
|
Access,
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,10 @@ export * as Entity from "./Entity.ts"
|
|
|
33
33
|
* Entity lifetime scopes used by scene and level ownership patterns.
|
|
34
34
|
*/
|
|
35
35
|
export * as EntityScope from "./EntityScope.ts"
|
|
36
|
+
/**
|
|
37
|
+
* Fixed-step timing for any host: clamping, catch-up limits, interpolation.
|
|
38
|
+
*/
|
|
39
|
+
export * as FixedLoop from "./FixedLoop.ts"
|
|
36
40
|
/**
|
|
37
41
|
* The host's wall clock as an injected contract (`standard`, `test()`).
|
|
38
42
|
*/
|
package/src/internal/cells.ts
CHANGED
|
@@ -63,38 +63,64 @@ const withTransition = (cell: WriteCellBase<unknown>, component: string) => Obje
|
|
|
63
63
|
}
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A required slot is empty only when its match outlived the run that produced
|
|
68
|
+
* it: the entity was despawned, or lost the component. Reading would hand out
|
|
69
|
+
* the internal marker as a value, and writing would put the component back
|
|
70
|
+
* without the insert path, so both fail loudly instead.
|
|
71
|
+
*/
|
|
72
|
+
const staleMatch = (record: EntityRecord, ordinal: number): Error =>
|
|
73
|
+
new Error(
|
|
74
|
+
`${record.nameAt(ordinal)} was used through a query match after ${record.alive ? "its entity lost it" : "its entity was despawned"}: `
|
|
75
|
+
+ "a match is valid during the system run that produced it; keep the entity id or a handle and resolve it again with queries.x.get(id) or queries.x.getHandle(handle)"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/** Kept out of `get` so the hot read stays small enough to inline. */
|
|
80
|
+
const staleRead = (record: EntityRecord, ordinal: number): never => {
|
|
81
|
+
throw staleMatch(record, ordinal)
|
|
82
|
+
}
|
|
83
|
+
|
|
66
84
|
class ComponentReadCell {
|
|
67
|
-
|
|
85
|
+
// The record's values array, held directly: one load per read instead of
|
|
86
|
+
// two pays for the stale check. Records never replace their array.
|
|
87
|
+
readonly values: Array<unknown>
|
|
68
88
|
readonly ordinal: number
|
|
89
|
+
readonly record: EntityRecord
|
|
69
90
|
|
|
70
91
|
constructor(record: EntityRecord, ordinal: number) {
|
|
71
|
-
this.
|
|
92
|
+
this.values = record.values
|
|
72
93
|
this.ordinal = ordinal
|
|
94
|
+
this.record = record
|
|
73
95
|
}
|
|
74
96
|
|
|
75
97
|
get(): unknown {
|
|
76
|
-
|
|
98
|
+
const value = this.values[this.ordinal]
|
|
99
|
+
return value === ABSENT ? staleRead(this.record, this.ordinal) : value
|
|
77
100
|
}
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
class ComponentWriteCell extends WriteCellBase<unknown> {
|
|
81
|
-
readonly
|
|
104
|
+
readonly values: Array<unknown>
|
|
82
105
|
readonly ordinal: number
|
|
106
|
+
readonly record: EntityRecord
|
|
83
107
|
readonly world: World
|
|
84
108
|
|
|
85
109
|
constructor(record: EntityRecord, ordinal: number, world: World) {
|
|
86
110
|
super()
|
|
87
|
-
this.
|
|
111
|
+
this.values = record.values
|
|
88
112
|
this.ordinal = ordinal
|
|
113
|
+
this.record = record
|
|
89
114
|
this.world = world
|
|
90
115
|
}
|
|
91
116
|
|
|
92
117
|
get(): unknown {
|
|
93
|
-
|
|
118
|
+
const value = this.values[this.ordinal]
|
|
119
|
+
return value === ABSENT ? staleRead(this.record, this.ordinal) : value
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
set(value: unknown): void {
|
|
97
|
-
this.world.setComponentValue(this.record, this.ordinal, value)
|
|
123
|
+
if (!this.world.setComponentValue(this.record, this.ordinal, value)) staleRead(this.record, this.ordinal)
|
|
98
124
|
}
|
|
99
125
|
}
|
|
100
126
|
|
|
@@ -115,6 +141,11 @@ class OptionalComponentCell {
|
|
|
115
141
|
get(): unknown {
|
|
116
142
|
return this.record.values[this.ordinal]
|
|
117
143
|
}
|
|
144
|
+
|
|
145
|
+
value(): unknown {
|
|
146
|
+
const value = this.record.values[this.ordinal]
|
|
147
|
+
return value === ABSENT ? undefined : value
|
|
148
|
+
}
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
class StoreReadCell {
|
|
@@ -167,7 +198,7 @@ export const componentWrite = (
|
|
|
167
198
|
return entityState === undefined ? constructed : withTransition(constructed, entityState)
|
|
168
199
|
}
|
|
169
200
|
|
|
170
|
-
export const componentOptional = (record: EntityRecord, ordinal: number): { readonly present: boolean; get(): unknown } =>
|
|
201
|
+
export const componentOptional = (record: EntityRecord, ordinal: number): { readonly present: boolean; get(): unknown; value(): unknown } =>
|
|
171
202
|
new OptionalComponentCell(record, ordinal)
|
|
172
203
|
|
|
173
204
|
export const storeRead = (store: Map<symbol, unknown>, key: symbol): { get(): unknown } =>
|
|
@@ -198,9 +229,10 @@ export const derivedRead = <T>(read: () => T): { get(): T } => ({ get: read })
|
|
|
198
229
|
export const derivedOptional = <T>(
|
|
199
230
|
isPresent: () => boolean,
|
|
200
231
|
read: () => T
|
|
201
|
-
): { readonly present: boolean; get(): T } => ({
|
|
232
|
+
): { readonly present: boolean; get(): T; value(): T | undefined } => ({
|
|
202
233
|
get present() {
|
|
203
234
|
return isPresent()
|
|
204
235
|
},
|
|
205
|
-
get: read
|
|
236
|
+
get: read,
|
|
237
|
+
value: () => (isPresent() ? read() : undefined)
|
|
206
238
|
})
|
package/src/internal/debug.ts
CHANGED
|
@@ -282,6 +282,9 @@ export interface DescribeInput {
|
|
|
282
282
|
readonly ranSystems: ReadonlySet<string>
|
|
283
283
|
/** Commands skipped because they would have broken a `Schema.requires` requirement. */
|
|
284
284
|
readonly requirementViolations: ReadonlyArray<Debug.RequirementViolation>
|
|
285
|
+
readonly nextStateReplacements: ReadonlyArray<Debug.NextStateReplacement>
|
|
286
|
+
readonly staleTargets: ReadonlyArray<Debug.StaleTarget>
|
|
287
|
+
readonly streamLosses: ReadonlyArray<Debug.StreamLoss>
|
|
285
288
|
readonly hasResource: (descriptor: Descriptor.Any) => boolean
|
|
286
289
|
readonly schedules: ReadonlyArray<readonly [string, AnySchedule]>
|
|
287
290
|
}
|
|
@@ -293,19 +296,38 @@ export const describe = (input: DescribeInput): Debug.Description => {
|
|
|
293
296
|
const relations = Object.values(input.schema.relations) as ReadonlyArray<Relation.Relation.Any>
|
|
294
297
|
|
|
295
298
|
const schedules = collectSchedules(input.schedules)
|
|
299
|
+
// A system gated by Schedule.when / pauseWhen is a copy pointing at its
|
|
300
|
+
// base: both are one system, described once with every placement.
|
|
301
|
+
const baseOf = (system: AnySystem): AnySystem => (system as { readonly base?: AnySystem }).base ?? system
|
|
296
302
|
const placements = new Map<AnySystem, Array<string>>()
|
|
303
|
+
const steps = new Set<AnySystem>()
|
|
297
304
|
for (const [name, schedule] of schedules) {
|
|
298
305
|
schedule.steps.forEach((step, index) => {
|
|
299
306
|
if (!Schedule.isSystemStep(step)) return
|
|
300
|
-
const system = step as AnySystem
|
|
307
|
+
const system = baseOf(step as AnySystem)
|
|
308
|
+
steps.add(step as AnySystem)
|
|
301
309
|
const list = placements.get(system)
|
|
302
310
|
const placement = `${name}#${index}`
|
|
303
311
|
if (list) list.push(placement)
|
|
304
312
|
else placements.set(system, [placement])
|
|
305
313
|
})
|
|
306
314
|
}
|
|
307
|
-
|
|
308
|
-
const
|
|
315
|
+
// Gates differ per placement: the description lists the conditions of all of them.
|
|
316
|
+
const gatesOf = new Map<AnySystem, { when: Set<unknown>; pauseWhen: Set<unknown> }>()
|
|
317
|
+
for (const step of steps) {
|
|
318
|
+
const base = baseOf(step)
|
|
319
|
+
const gates = gatesOf.get(base) ?? { when: new Set(), pauseWhen: new Set() }
|
|
320
|
+
for (const condition of step.spec.when as ReadonlyArray<unknown>) gates.when.add(condition)
|
|
321
|
+
for (const condition of step.spec.pauseWhen as ReadonlyArray<unknown>) gates.pauseWhen.add(condition)
|
|
322
|
+
gatesOf.set(base, gates)
|
|
323
|
+
}
|
|
324
|
+
const byBase = new Map([...placements].map(([system, where]) => {
|
|
325
|
+
const gates = gatesOf.get(system)!
|
|
326
|
+
const merged = { ...system, spec: { ...system.spec, when: [...gates.when], pauseWhen: [...gates.pauseWhen] } } as AnySystem
|
|
327
|
+
return [system, describeSystem(merged, where)] as const
|
|
328
|
+
}))
|
|
329
|
+
const described = new Map<AnySystem, Debug.SystemDescription>([...steps].map((step) => [step, byBase.get(baseOf(step))!] as const))
|
|
330
|
+
const systems = [...byBase.values()]
|
|
309
331
|
|
|
310
332
|
const components = new Index(componentDescriptors.map((descriptor) => descriptor.name))
|
|
311
333
|
const resources = new Index(resourceDescriptors.map((descriptor) => descriptor.name))
|
|
@@ -314,7 +336,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
|
|
|
314
336
|
for (const name of input.externallyWrittenEvents) events.write(name, "runtime.send")
|
|
315
337
|
for (const system of systems) {
|
|
316
338
|
for (const query of system.queries) {
|
|
317
|
-
for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed]) {
|
|
339
|
+
for (const name of [...query.reads, ...query.optional, ...query.with, ...query.added, ...query.changed, ...anyOfNames(query)]) {
|
|
318
340
|
components.read(name, system.name)
|
|
319
341
|
}
|
|
320
342
|
for (const name of query.writes) {
|
|
@@ -401,7 +423,7 @@ export const describe = (input: DescribeInput): Debug.Description => {
|
|
|
401
423
|
}
|
|
402
424
|
}
|
|
403
425
|
const failureReaders = new Set(systems.flatMap((system) => system.relationFailures))
|
|
404
|
-
// Declared `relates` permissions, and relations that commands were seen to relate (
|
|
426
|
+
// Declared `relates` permissions, and relations that commands were seen to relate (erased types can relate without one).
|
|
405
427
|
const relaters = new Map<string, Set<string>>()
|
|
406
428
|
for (const system of systems) {
|
|
407
429
|
for (const name of system.commands.relates) relaters.set(name, new Set([...(relaters.get(name) ?? []), system.name]))
|
|
@@ -452,15 +474,6 @@ export const describe = (input: DescribeInput): Debug.Description => {
|
|
|
452
474
|
})
|
|
453
475
|
}
|
|
454
476
|
}
|
|
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
|
-
}
|
|
464
477
|
for (const entry of components.list()) {
|
|
465
478
|
if (entry.writers.length > 0 && entry.readers.every((reader) => entry.writers.includes(reader))) {
|
|
466
479
|
lints.push({
|
|
@@ -472,6 +485,52 @@ export const describe = (input: DescribeInput): Debug.Description => {
|
|
|
472
485
|
}
|
|
473
486
|
}
|
|
474
487
|
}
|
|
488
|
+
// Observed with debug, whether or not schedules are named.
|
|
489
|
+
for (const violation of input.requirementViolations) {
|
|
490
|
+
const times = violation.count === 1 ? "once" : `${violation.count} times`
|
|
491
|
+
lints.push({
|
|
492
|
+
severity: "warning",
|
|
493
|
+
code: "requirement-violated",
|
|
494
|
+
subject: violation.system,
|
|
495
|
+
message: `${violation.system} queued ${violation.command === "insert" ? "an" : "a"} ${violation.command} that would have left ${violation.component} without ${violation.missing} (last on entity ${violation.entity}), so it was skipped ${times}`
|
|
496
|
+
})
|
|
497
|
+
}
|
|
498
|
+
for (const replacement of input.nextStateReplacements) {
|
|
499
|
+
const times = replacement.count === 1 ? "" : `, ${replacement.count} times`
|
|
500
|
+
lints.push({
|
|
501
|
+
severity: "warning",
|
|
502
|
+
code: "next-state-replaced",
|
|
503
|
+
subject: replacement.machine,
|
|
504
|
+
message: `${replacement.by.system} queued ${replacement.machine}=${String(replacement.by.value)}, replacing ${String(replacement.replaced.value)} queued by ${replacement.replaced.system} before the same applyStateTransitions()${times}: the last value wins`
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
for (const stale of input.staleTargets) {
|
|
508
|
+
const times = stale.count === 1 ? "once" : `${stale.count} times`
|
|
509
|
+
lints.push({
|
|
510
|
+
severity: "warning",
|
|
511
|
+
code: "stale-target",
|
|
512
|
+
subject: stale.system,
|
|
513
|
+
message: `${stale.system} passed ${stale.command} an id spawned in an earlier run (last entity ${stale.entity}), so it was skipped ${times}`
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
for (const loss of input.streamLosses) {
|
|
517
|
+
if (loss.checksLagged) continue
|
|
518
|
+
if (loss.lostRuns > 0) {
|
|
519
|
+
lints.push({
|
|
520
|
+
severity: "warning",
|
|
521
|
+
code: "stream-loss-unread",
|
|
522
|
+
subject: loss.system,
|
|
523
|
+
message: `${loss.system} read ${loss.stream} after entries were dropped before it read them (${loss.lostRuns === 1 ? "once" : `${loss.lostRuns} runs`}), and never checks lagged()`
|
|
524
|
+
})
|
|
525
|
+
} else if (loss.paused) {
|
|
526
|
+
lints.push({
|
|
527
|
+
severity: "info",
|
|
528
|
+
code: "stream-loss-unread",
|
|
529
|
+
subject: loss.system,
|
|
530
|
+
message: `${loss.system} reads ${loss.stream} under pauseWhen and never checks lagged(): a long pause drops entries past the stream's capacity`
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
}
|
|
475
534
|
|
|
476
535
|
return {
|
|
477
536
|
version: 1,
|
|
@@ -548,6 +607,12 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
|
|
|
548
607
|
return `fine if ${subject} is read outside the described schedules (rendering, inspectors); otherwise stop writing it`
|
|
549
608
|
case "requirement-violated":
|
|
550
609
|
return `keep what ${subject} spawns or inserts complete (every component its components require), and remove a required component only together with its requirers; a query proof can go stale when another system's command is applied first in the same applyDeferred()`
|
|
610
|
+
case "stream-loss-unread":
|
|
611
|
+
return `check lagged() in ${subject} where a missed entry matters (a node never destroyed, a count off), and rebuild from queries when it is true; fine if a missed entry is harmless`
|
|
612
|
+
case "stale-target":
|
|
613
|
+
return `an id spawn returns is valid only in the run that spawned it; in ${subject}, keep the entity id or a handle and resolve it again with a query (queries.x.get(id) / lookup.getHandle(handle, query)) and pass match.entity`
|
|
614
|
+
case "next-state-replaced":
|
|
615
|
+
return "decide between the values in one system, or check whether a next state is already queued (nextMachines.x.getPending()) before setting another"
|
|
551
616
|
case "command-permission-unused":
|
|
552
617
|
return `fine if the session did not reach the case where ${subject} uses it; otherwise drop the permission from its Game.System.commands(...)`
|
|
553
618
|
case "read-before-write":
|
|
@@ -558,9 +623,13 @@ const fixFor = (code: Debug.Lint["code"], subject: string): string => {
|
|
|
558
623
|
type Accesses = { readonly reads: ReadonlyArray<string>; readonly writes: ReadonlyArray<string> }
|
|
559
624
|
|
|
560
625
|
/** What a system reads and writes, per kind. `with`/`without` filters test presence and are not reads. */
|
|
626
|
+
/** The components named by a query's `anyOf` filters. */
|
|
627
|
+
const anyOfNames = (query: Debug.QueryDescription): ReadonlyArray<string> =>
|
|
628
|
+
query.anyOf.flatMap((filter) => [...filter.added, ...filter.changed])
|
|
629
|
+
|
|
561
630
|
const accessesOf = (system: Debug.SystemDescription): Record<"component" | "resource" | "event", Accesses> => ({
|
|
562
631
|
component: {
|
|
563
|
-
reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed]),
|
|
632
|
+
reads: system.queries.flatMap((query) => [...query.reads, ...query.optional, ...query.added, ...query.changed, ...anyOfNames(query)]),
|
|
564
633
|
writes: system.queries.flatMap((query) => query.writes)
|
|
565
634
|
},
|
|
566
635
|
resource: { reads: system.resources.reads, writes: system.resources.writes },
|
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
* The compile-time counterpart is `ValidateFragments` in `Schema.ts`; these
|
|
5
5
|
* guards cover schemas assembled from erased types.
|
|
6
6
|
*/
|
|
7
|
+
import { isTransient } from "../Descriptor.ts"
|
|
7
8
|
import type { Schema, SchemaDefinition } from "../Schema.ts"
|
|
8
9
|
|
|
9
|
-
type NamedRegistry = Record<string, { readonly name: string }>
|
|
10
|
+
type NamedRegistry = Record<string, { readonly name: string; readonly kind?: string }>
|
|
11
|
+
|
|
12
|
+
/** The descriptor kind each registry holds (relations carry their own shape). */
|
|
13
|
+
const expectedKind: Partial<Record<(typeof kinds)[number], string>> = { components: "component", resources: "resource", events: "event" }
|
|
14
|
+
const article = (kind: string) => kind === "event" ? "an" : "a"
|
|
10
15
|
|
|
11
16
|
const kinds = ["components", "resources", "events", "relations"] as const
|
|
12
17
|
|
|
@@ -21,9 +26,15 @@ export const emptySchema = (): SchemaDefinition => ({
|
|
|
21
26
|
/**
|
|
22
27
|
* Merges two registries after checking for duplicate keys and descriptor names.
|
|
23
28
|
*/
|
|
24
|
-
const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry => {
|
|
29
|
+
const mergeRegistry = (left: NamedRegistry, right: NamedRegistry, expected: string | undefined): NamedRegistry => {
|
|
25
30
|
const names = new Set(Object.values(left).map((entry) => entry.name))
|
|
26
31
|
for (const [key, entry] of Object.entries(right)) {
|
|
32
|
+
if (expected !== undefined && entry.kind !== expected) {
|
|
33
|
+
const actual = String(entry.kind)
|
|
34
|
+
throw new Error(actual === "service"
|
|
35
|
+
? `${key} is a service, not ${article(expected)} ${expected}: provide services to Game.Runtime.make({ services }) instead of registering them`
|
|
36
|
+
: `${key} is ${article(actual)} ${actual}, not ${article(expected)} ${expected}: move it to ${actual}s: { ... }`)
|
|
37
|
+
}
|
|
27
38
|
if (key in left) {
|
|
28
39
|
throw new Error(`Duplicate schema key: ${key}`)
|
|
29
40
|
}
|
|
@@ -38,7 +49,7 @@ const mergeRegistry = (left: NamedRegistry, right: NamedRegistry): NamedRegistry
|
|
|
38
49
|
export const mergeSchemas = (left: Schema.Any, right: Schema.Any): Schema.Any => {
|
|
39
50
|
const merged: Record<string, unknown> = {}
|
|
40
51
|
for (const kind of kinds) {
|
|
41
|
-
merged[kind] = mergeRegistry(left[kind], right[kind])
|
|
52
|
+
merged[kind] = mergeRegistry(left[kind], right[kind], expectedKind[kind])
|
|
42
53
|
}
|
|
43
54
|
merged["requires"] = [...left.requires, ...right.requires]
|
|
44
55
|
return merged as unknown as Schema.Any
|
|
@@ -56,6 +67,15 @@ export const buildSchema = (fragments: ReadonlyArray<Schema.Any>): Schema.Any =>
|
|
|
56
67
|
throw new Error(`${descriptor.name} is named in Schema.requires(...) but no fragment registers it: add it to a fragment's components`)
|
|
57
68
|
}
|
|
58
69
|
}
|
|
70
|
+
for (const required of requirement.required) {
|
|
71
|
+
if (isTransient(required) && !isTransient(requirement.component)) {
|
|
72
|
+
throw new Error(transientRequirementMessage(requirement.component.name, required.name))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
59
75
|
}
|
|
60
76
|
return current
|
|
61
77
|
}
|
|
78
|
+
|
|
79
|
+
/** Why a saved component cannot require a transient one: a restore would bring it back without it. */
|
|
80
|
+
export const transientRequirementMessage = (requirer: string, required: string): string =>
|
|
81
|
+
`${requirer} requires ${required}, which is transient and not saved: make ${requirer} transient too, or give ${required} a constructor`
|
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 * as Snapshot from "../Snapshot.ts"
|
|
9
10
|
import type { Descriptor } from "../Descriptor.ts"
|
|
10
11
|
import * as Condition from "../Condition.ts"
|
|
11
12
|
import * as Entity from "../Entity.ts"
|
|
@@ -15,11 +16,16 @@ import * as Machine from "../Machine.ts"
|
|
|
15
16
|
import * as QueryModule from "../Query.ts"
|
|
16
17
|
import * as Relation from "../Relation.ts"
|
|
17
18
|
import * as Runtime from "../Runtime.ts"
|
|
19
|
+
import * as Requirement from "../Requirement.ts"
|
|
18
20
|
import * as Schedule from "../Schedule.ts"
|
|
19
21
|
import type { Schema } from "../Schema.ts"
|
|
20
22
|
import * as System from "../System.ts"
|
|
21
23
|
import { requirementIndex, withRequired } from "./requirements.ts"
|
|
22
24
|
|
|
25
|
+
/** Why an exit phase (`onExit`, `onTransition`) cannot contain `applyDeferred()`. */
|
|
26
|
+
export const exitApplyDeferredMessage =
|
|
27
|
+
"onExit and onTransition schedules cannot contain applyDeferred(): the exit phase rolls back as one when a system in it fails, so its commands are applied at the next applyDeferred() after the marker" as const
|
|
28
|
+
|
|
23
29
|
export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Schema.Game<S, Root> => {
|
|
24
30
|
const requirements = requirementIndex(schema)
|
|
25
31
|
const definedMachines: Array<Machine.StateMachine.Any> = []
|
|
@@ -77,7 +83,14 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
|
|
|
77
83
|
const transitionSchedule = (
|
|
78
84
|
transition: Machine.TransitionScheduleDefinition["transition"],
|
|
79
85
|
plan: ReadonlyArray<Schedule.ScheduleEntry>
|
|
80
|
-
) =>
|
|
86
|
+
) => {
|
|
87
|
+
const schedule = Schedule.make(schema, plan)
|
|
88
|
+
if (transition.phase !== "enter" && schedule.steps.some((step) => !Schedule.isSystemStep(step) && step.kind === "applyDeferred")) {
|
|
89
|
+
throw new Error(exitApplyDeferredMessage)
|
|
90
|
+
}
|
|
91
|
+
// The machine is a requirement: a runtime without it could never run the schedule.
|
|
92
|
+
return { ...schedule, requirements: Requirement.collect([...schedule.requirements, transition.machine]), transition }
|
|
93
|
+
}
|
|
81
94
|
|
|
82
95
|
const game = {
|
|
83
96
|
schema,
|
|
@@ -85,7 +98,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
|
|
|
85
98
|
Inspector: (name: string, spec: object, read: (context: any) => unknown) =>
|
|
86
99
|
Inspector.make(name, { schema, ...spec } as never, read),
|
|
87
100
|
Entity: {
|
|
88
|
-
handle: Entity.handle
|
|
101
|
+
handle: Entity.handle,
|
|
102
|
+
id: (handle: number) => Entity.makeEntityId(handle)
|
|
89
103
|
},
|
|
90
104
|
Query: Object.assign((spec: never) => QueryModule.Query(spec), {
|
|
91
105
|
read: QueryModule.read,
|
|
@@ -129,7 +143,8 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
|
|
|
129
143
|
commands: Command.commands,
|
|
130
144
|
// `spawns(Player)` also allows what `Player` requires (`Schema.requires`), matching its type.
|
|
131
145
|
spawns: (...components: ReadonlyArray<Descriptor<"component", string, any>>) =>
|
|
132
|
-
|
|
146
|
+
// Internally erased: the bound signature already requires at least one component.
|
|
147
|
+
Command.spawns(...(withRequired(requirements, components) as unknown as readonly [Descriptor<"component", string, any>])),
|
|
133
148
|
inserts: Command.inserts,
|
|
134
149
|
removes: Command.removes,
|
|
135
150
|
despawns: Command.despawns,
|
|
@@ -172,6 +187,7 @@ export const makeGame = <S extends Schema.Any, Root>(schema: S, _root: Root): Sc
|
|
|
172
187
|
readonly resources?: object
|
|
173
188
|
readonly machines?: Runtime.RuntimeMachines<any>
|
|
174
189
|
readonly debug?: Debug.Option
|
|
190
|
+
readonly saves?: Snapshot.Migrations
|
|
175
191
|
}) => Runtime.make({ schema, ...options, machineDefinitions: definedMachines, scopeDefinitions: definedScopes } as never),
|
|
176
192
|
service: Runtime.service,
|
|
177
193
|
services: Runtime.services,
|
package/src/internal/queries.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Matches are always returned in ascending entity id order, which is spawn
|
|
15
15
|
* order, so iteration is deterministic.
|
|
16
16
|
*/
|
|
17
|
+
import type { Descriptor } from "../Descriptor.ts"
|
|
17
18
|
import * as DescriptorModule from "../Descriptor.ts"
|
|
18
19
|
import type * as Entity from "../Entity.ts"
|
|
19
20
|
import * as Query from "../Query.ts"
|
|
@@ -124,6 +125,31 @@ export const makeQueryEngine = (world: World) => {
|
|
|
124
125
|
const states = new WeakMap<AnyQuery, QueryState>()
|
|
125
126
|
let nextOrdinal = 0
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The ordinal of a selected component, checking that the descriptor the
|
|
130
|
+
* query names behaves like the one the schema registers under that name:
|
|
131
|
+
* identity is (kind, name), so a look-alike built elsewhere must not add
|
|
132
|
+
* or drop behaviour (entity-state transitions, a constructor, immutability,
|
|
133
|
+
* transience) the schema does not declare.
|
|
134
|
+
*/
|
|
135
|
+
const registeredOrdinal = (descriptor: Descriptor.Any): number => {
|
|
136
|
+
const ordinal = world.ordinalOf(descriptor as never)
|
|
137
|
+
const registered = world.descriptorAt(ordinal) as Descriptor.Any
|
|
138
|
+
if (registered === descriptor) return ordinal
|
|
139
|
+
const traits = (candidate: Descriptor.Any) => [
|
|
140
|
+
DescriptorModule.isEntityState(candidate as never) ? "an entity state" : undefined,
|
|
141
|
+
DescriptorModule.hasConstructor(candidate) ? "constructed" : undefined,
|
|
142
|
+
DescriptorModule.isImmutable(candidate) ? "immutable" : undefined,
|
|
143
|
+
DescriptorModule.isTransient(candidate) ? "transient" : undefined
|
|
144
|
+
].filter((trait) => trait !== undefined).join(", ") || "plain"
|
|
145
|
+
const expected = traits(registered)
|
|
146
|
+
const actual = traits(descriptor)
|
|
147
|
+
if (expected !== actual) {
|
|
148
|
+
throw new Error(`${descriptor.name} is ${expected} in this schema, but this query names a descriptor that is ${actual}: use the descriptor the schema registers`)
|
|
149
|
+
}
|
|
150
|
+
return ordinal
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
const compile = (query: AnyQuery): QueryState => {
|
|
128
154
|
const required: Array<number> = query.with.map((descriptor) => world.ordinalOf(descriptor))
|
|
129
155
|
const withRelations: Array<Relation.Relation.Any> = [...query.withRelations]
|
|
@@ -136,7 +162,7 @@ export const makeQueryEngine = (world: World) => {
|
|
|
136
162
|
switch (access.mode) {
|
|
137
163
|
case "read":
|
|
138
164
|
case "write": {
|
|
139
|
-
const ordinal =
|
|
165
|
+
const ordinal = registeredOrdinal(access.descriptor)
|
|
140
166
|
required.push(ordinal)
|
|
141
167
|
proofSlots.push([slot, ordinal])
|
|
142
168
|
if (access.mode === "write") {
|
|
@@ -154,7 +180,7 @@ export const makeQueryEngine = (world: World) => {
|
|
|
154
180
|
break
|
|
155
181
|
}
|
|
156
182
|
case "optional":
|
|
157
|
-
slots.push({ slot, mode: "optional", ordinal:
|
|
183
|
+
slots.push({ slot, mode: "optional", ordinal: registeredOrdinal(access.descriptor) })
|
|
158
184
|
break
|
|
159
185
|
case "readRelation":
|
|
160
186
|
withRelations.push(access.descriptor)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds relation pairs for `Descriptor.Hierarchy` and `Descriptor.Relation`,
|
|
3
|
+
* the one public way to define a relation.
|
|
4
|
+
*/
|
|
5
|
+
import type { PairBrand, RelatedDefinition, RelationDefinition, RelationKind } from "../Relation.ts"
|
|
6
|
+
|
|
7
|
+
const relationTypeId = "~ritmo/Relation"
|
|
8
|
+
|
|
9
|
+
export const makePair = <
|
|
10
|
+
const Name extends string,
|
|
11
|
+
const RelatedName extends string,
|
|
12
|
+
const Kind extends RelationKind
|
|
13
|
+
>(
|
|
14
|
+
kind: Kind,
|
|
15
|
+
name: Name,
|
|
16
|
+
relatedName: RelatedName
|
|
17
|
+
): {
|
|
18
|
+
readonly relation: RelationDefinition<Name, RelatedName, Kind, PairBrand<Name, RelatedName, Kind>>
|
|
19
|
+
readonly related: RelatedDefinition<RelatedName, Name, Kind, PairBrand<Name, RelatedName, Kind>>
|
|
20
|
+
} => {
|
|
21
|
+
type Pair = PairBrand<Name, RelatedName, Kind>
|
|
22
|
+
const related = {
|
|
23
|
+
kind: "related",
|
|
24
|
+
relatedName,
|
|
25
|
+
relationName: name,
|
|
26
|
+
key: Symbol.for(`ritmo/related/${name}`),
|
|
27
|
+
relationKind: kind,
|
|
28
|
+
[relationTypeId]: {
|
|
29
|
+
_Pair: (_: never) => undefined as unknown as Pair
|
|
30
|
+
}
|
|
31
|
+
} as RelatedDefinition<RelatedName, Name, Kind, Pair>
|
|
32
|
+
|
|
33
|
+
const relation = {
|
|
34
|
+
kind: "relation",
|
|
35
|
+
name,
|
|
36
|
+
relatedName,
|
|
37
|
+
key: Symbol.for(`ritmo/relation/${name}`),
|
|
38
|
+
relationKind: kind,
|
|
39
|
+
linkedDespawn: kind === "hierarchy",
|
|
40
|
+
allowSelf: false,
|
|
41
|
+
ordered: kind === "hierarchy",
|
|
42
|
+
related,
|
|
43
|
+
[relationTypeId]: {
|
|
44
|
+
_Pair: (_: never) => undefined as unknown as Pair
|
|
45
|
+
}
|
|
46
|
+
} as RelationDefinition<Name, RelatedName, Kind, Pair>
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
relation,
|
|
50
|
+
related
|
|
51
|
+
}
|
|
52
|
+
}
|