@typeonce/ritmo 0.7.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 +181 -40
- package/dist/Command.d.ts.map +1 -1
- package/dist/Command.js +31 -11
- 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 +127 -13
- package/dist/Debug.d.ts.map +1 -1
- package/dist/Descriptor.d.ts +61 -10
- package/dist/Descriptor.d.ts.map +1 -1
- package/dist/Descriptor.js +22 -10
- package/dist/Descriptor.js.map +1 -1
- package/dist/Entity.d.ts +22 -7
- 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/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 +59 -34
- 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 +100 -14
- package/dist/Query.d.ts.map +1 -1
- package/dist/Query.js +34 -5
- 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 +61 -15
- package/dist/Runtime.d.ts.map +1 -1
- package/dist/Runtime.js +594 -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 +233 -48
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +33 -7
- package/dist/Schema.js.map +1 -1
- package/dist/Snapshot.d.ts +90 -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 +132 -31
- package/dist/System.d.ts.map +1 -1
- package/dist/System.js +32 -0
- 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 +10 -0
- package/dist/internal/debug.d.ts.map +1 -1
- package/dist/internal/debug.js +171 -12
- 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 +2 -6
- package/dist/internal/fragments.d.ts.map +1 -1
- package/dist/internal/fragments.js +36 -3
- 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 +48 -7
- 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 +57 -26
- 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/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/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 +281 -67
- package/src/Condition.ts +17 -1
- package/src/Debug.ts +124 -15
- package/src/Descriptor.ts +92 -21
- package/src/Entity.ts +23 -7
- package/src/EntityScope.ts +21 -2
- package/src/FixedLoop.ts +218 -0
- package/src/HostClock.ts +2 -1
- package/src/Machine.ts +72 -35
- package/src/Query.ts +118 -17
- package/src/Relation.ts +5 -67
- package/src/Runtime.ts +659 -85
- package/src/Schedule.ts +31 -19
- package/src/Schema.ts +297 -57
- package/src/Snapshot.ts +94 -2
- package/src/System.ts +151 -29
- package/src/index.ts +4 -0
- package/src/internal/cells.ts +42 -10
- package/src/internal/debug.ts +179 -14
- package/src/internal/equal.ts +31 -0
- package/src/internal/fragments.ts +35 -5
- package/src/internal/game.ts +69 -7
- package/src/internal/order.ts +8 -4
- package/src/internal/queries.ts +62 -12
- package/src/internal/relations.ts +52 -0
- package/src/internal/requirements.ts +71 -0
- package/src/internal/streams.ts +11 -0
- package/src/internal/world.ts +60 -4
package/src/Runtime.ts
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* Game.Runtime.service(Random, { next: Math.random })
|
|
24
24
|
* )
|
|
25
25
|
*
|
|
26
|
-
* // Viewport is a constructed resource, so
|
|
26
|
+
* // make always returns a Result. Viewport is a constructed resource, so
|
|
27
|
+
* // validating it can fail and the Result must be checked.
|
|
27
28
|
* const runtime = Game.Runtime.make({
|
|
28
29
|
* services,
|
|
29
30
|
* resources: {
|
|
@@ -63,8 +64,10 @@ import type { Descriptor } from "./Descriptor.ts"
|
|
|
63
64
|
import type * as Entity from "./Entity.ts"
|
|
64
65
|
import type * as Inspector from "./Inspector.ts"
|
|
65
66
|
import * as Cells from "./internal/cells.ts"
|
|
67
|
+
import { sameValue } from "./internal/equal.ts"
|
|
66
68
|
import * as DebugInternal from "./internal/debug.ts"
|
|
67
69
|
import { makeQueryEngine } from "./internal/queries.ts"
|
|
70
|
+
import { requirementIndex } from "./internal/requirements.ts"
|
|
68
71
|
import * as Streams from "./internal/streams.ts"
|
|
69
72
|
import { ABSENT, makeWorld } from "./internal/world.ts"
|
|
70
73
|
import type { WorldHooks } from "./internal/world.ts"
|
|
@@ -83,9 +86,11 @@ import type { Registry, Schema } from "./Schema.ts"
|
|
|
83
86
|
import type {
|
|
84
87
|
DespawnedReadView,
|
|
85
88
|
EventReadView,
|
|
89
|
+
ResourceChangedView,
|
|
86
90
|
EventWriteView,
|
|
87
91
|
LookupApi,
|
|
88
|
-
|
|
92
|
+
QueryIteration,
|
|
93
|
+
QueryLookups,
|
|
89
94
|
RelationFailureReadView,
|
|
90
95
|
RemovedReadView,
|
|
91
96
|
TransitionEventReadView,
|
|
@@ -115,7 +120,7 @@ import type {
|
|
|
115
120
|
* services: Game.Runtime.services(
|
|
116
121
|
* Game.Runtime.service(Logger, { log: console.log })
|
|
117
122
|
* )
|
|
118
|
-
* })
|
|
123
|
+
* }).value
|
|
119
124
|
*
|
|
120
125
|
* runtime.tick(updateSchedule)
|
|
121
126
|
* ```
|
|
@@ -154,6 +159,17 @@ export type RuntimeResources<S extends Schema.Any> = Partial<{
|
|
|
154
159
|
: Schema.ResourceValue<S, K>
|
|
155
160
|
}>
|
|
156
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Rejects keys of `Provided` that are not resource keys of the schema, naming
|
|
164
|
+
* each one. A misspelled key next to known ones would otherwise compile and
|
|
165
|
+
* be dropped.
|
|
166
|
+
*/
|
|
167
|
+
export type ExactResources<S extends Schema.Any, Provided> = {
|
|
168
|
+
readonly [K in Exclude<keyof Provided, keyof Schema.Resources<S>>]: {
|
|
169
|
+
readonly __fixResources__: `${K & string} is not a resource key of this schema: use a key of the fragment's resources: { ... }`
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
type ConstructedKeys<S extends Schema.Any, Provided> = {
|
|
158
174
|
readonly [K in keyof Provided]: K extends keyof Schema.Resources<S>
|
|
159
175
|
? Schema.Resources<S>[K] extends DescriptorModule.ConstructedDescriptor<"resource", string, any, any, any> ? K : never
|
|
@@ -190,8 +206,9 @@ export type WithDebug<R, Enabled, S extends Schema.Any, Root> =
|
|
|
190
206
|
: R & Partial<Debug.Member<S, Root>>
|
|
191
207
|
|
|
192
208
|
/**
|
|
193
|
-
*
|
|
194
|
-
* provided
|
|
209
|
+
* What `make` returns: always a `Result`. It is a `Success` when nothing
|
|
210
|
+
* provided can fail, so `.value` is the runtime; when a provided resource
|
|
211
|
+
* goes through a constructor, it may be a `Failure` and must be checked.
|
|
195
212
|
*/
|
|
196
213
|
export type MakeRuntimeResult<
|
|
197
214
|
S extends Schema.Any,
|
|
@@ -201,7 +218,7 @@ export type MakeRuntimeResult<
|
|
|
201
218
|
Machines extends Record<string, unknown>,
|
|
202
219
|
Enabled = undefined
|
|
203
220
|
> = [ConstructedKeys<S, Provided>] extends [never]
|
|
204
|
-
? WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root
|
|
221
|
+
? Result.Success<WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>>
|
|
205
222
|
: Result.Result<
|
|
206
223
|
WithDebug<Runtime<S, Services, ProvidedResources<S, Provided>, Root, Machines>, Enabled, S, Root>,
|
|
207
224
|
RuntimeConstructionError<S, Provided>
|
|
@@ -634,12 +651,16 @@ export interface Runtime<
|
|
|
634
651
|
* resources are skipped.
|
|
635
652
|
*
|
|
636
653
|
* Callable only when every component and resource is constructed or
|
|
637
|
-
* transient; see the `Snapshot` module.
|
|
654
|
+
* transient; see the `Snapshot` module. Take it between ticks: calling it
|
|
655
|
+
* while a schedule is running (from a system, a service, or a trace
|
|
656
|
+
* callback) throws.
|
|
638
657
|
*/
|
|
639
658
|
readonly snapshot: Snapshot.Gate<S, () => Snapshot.WorldSnapshot>
|
|
640
659
|
/**
|
|
641
660
|
* Replaces the world with a snapshot after validating it against the
|
|
642
|
-
* schema. On failure nothing changes. See the `Snapshot` module.
|
|
661
|
+
* schema. On failure nothing changes. See the `Snapshot` module. Restore
|
|
662
|
+
* between ticks: calling it while a schedule is running throws. A current
|
|
663
|
+
* resource the save lacks keeps its value.
|
|
643
664
|
*/
|
|
644
665
|
readonly restore: Snapshot.Gate<S, (data: unknown) => Result.Result<void, Snapshot.RestoreError>>
|
|
645
666
|
/**
|
|
@@ -658,7 +679,7 @@ export interface Runtime<
|
|
|
658
679
|
* The event must belong to the schema. Calling it while a schedule is
|
|
659
680
|
* running is a defect and throws: inside a tick, emit from a system.
|
|
660
681
|
*/
|
|
661
|
-
readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: Descriptor.Value<D
|
|
682
|
+
readonly send: <D extends Schema.EventDescriptor<S>>(descriptor: D, value: ReadonlyValue<Descriptor.Value<D>>) => void
|
|
662
683
|
}
|
|
663
684
|
|
|
664
685
|
/**
|
|
@@ -682,7 +703,7 @@ export interface Runtime<
|
|
|
682
703
|
* resources: {
|
|
683
704
|
* score: 0
|
|
684
705
|
* }
|
|
685
|
-
* })
|
|
706
|
+
* }).value
|
|
686
707
|
* ```
|
|
687
708
|
*/
|
|
688
709
|
/**
|
|
@@ -693,6 +714,23 @@ export interface Runtime<
|
|
|
693
714
|
*/
|
|
694
715
|
export const streamCapacity = 65_536
|
|
695
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Brings a parsed save to `migrations.latest`: runs every step after its
|
|
719
|
+
* game version, checking the structure after each.
|
|
720
|
+
*/
|
|
721
|
+
const migrateSave = (save: Snapshot.WorldSnapshot, saves: Snapshot.Migrations | undefined): Result.Result<Snapshot.WorldSnapshot, Snapshot.RestoreError> => {
|
|
722
|
+
const latest = saves?.latest ?? 0
|
|
723
|
+
if (save.gameVersion > latest) return Result.failure({ _tag: "NewerGameVersion", gameVersion: save.gameVersion, latest })
|
|
724
|
+
let current = save
|
|
725
|
+
for (const step of saves?.steps ?? []) {
|
|
726
|
+
if (step.to <= current.gameVersion) continue
|
|
727
|
+
const next = Snapshot.parse({ ...step.migrate(current), gameVersion: step.to })
|
|
728
|
+
if (!next.ok) return next
|
|
729
|
+
current = next.value
|
|
730
|
+
}
|
|
731
|
+
return Result.success(current)
|
|
732
|
+
}
|
|
733
|
+
|
|
696
734
|
const makeValidatedRuntime = <
|
|
697
735
|
S extends Schema.Any,
|
|
698
736
|
Services extends Record<string, unknown>,
|
|
@@ -705,7 +743,9 @@ const makeValidatedRuntime = <
|
|
|
705
743
|
readonly resources: Partial<Record<string, unknown>>
|
|
706
744
|
readonly machines: RuntimeMachines<any> | undefined
|
|
707
745
|
readonly machineDefinitions: ReadonlyArray<Machine.StateMachine.Any>
|
|
746
|
+
readonly scopeDefinitions: ReadonlyArray<EntityScopeModule.EntityScope.Any>
|
|
708
747
|
readonly debug: Debug.Option | undefined
|
|
748
|
+
readonly saves: Snapshot.Migrations | undefined
|
|
709
749
|
}): Runtime<S, Services, Resources, Root, Machines> => {
|
|
710
750
|
const world = makeWorld(options.schema, streamCapacity)
|
|
711
751
|
const queries = makeQueryEngine(world)
|
|
@@ -720,7 +760,10 @@ const makeValidatedRuntime = <
|
|
|
720
760
|
* Machine-keyed committed, pending, and previous values.
|
|
721
761
|
*/
|
|
722
762
|
const currentMachines = new Map<symbol, unknown>()
|
|
723
|
-
|
|
763
|
+
/** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
|
|
764
|
+
const pendingMachines = new Map<symbol, { value: unknown; skipIfSame: boolean; by?: string }>()
|
|
765
|
+
/** Requests dropped because their sub-state stopped existing: skipped by the marker that queued them. */
|
|
766
|
+
const discardedPending = new WeakSet<object>()
|
|
724
767
|
const previousMachines = new Map<symbol, unknown>()
|
|
725
768
|
/**
|
|
726
769
|
* Machine keys changed by transitions applied during the current schedule run.
|
|
@@ -801,8 +844,48 @@ const makeValidatedRuntime = <
|
|
|
801
844
|
options.machineDefinitions.map((machine, index) => [machine.key, index] as const)
|
|
802
845
|
)
|
|
803
846
|
|
|
847
|
+
/** Machines the runtime was given an initial value for. */
|
|
848
|
+
const providedMachineKeys = new Set<symbol>()
|
|
804
849
|
for (const provision of machineEntries) {
|
|
805
850
|
currentMachines.set(provision.machine.key, provision.initial)
|
|
851
|
+
providedMachineKeys.add(provision.machine.key)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Sub-states by parent machine key, in definition order, and the value
|
|
856
|
+
* each enters whenever its parent enters the state it exists in (its
|
|
857
|
+
* runtime-provided value). A sub-state has no current value while its
|
|
858
|
+
* parent is elsewhere, and never one when the runtime does not provide it.
|
|
859
|
+
*/
|
|
860
|
+
const subStates = new Map<symbol, Array<Machine.StateMachine.Any>>()
|
|
861
|
+
const subStateEntry = new Map<symbol, unknown>()
|
|
862
|
+
for (const machine of options.machineDefinitions) {
|
|
863
|
+
if (machine.within === undefined) continue
|
|
864
|
+
const siblings = subStates.get(machine.within.machine.key)
|
|
865
|
+
if (siblings === undefined) subStates.set(machine.within.machine.key, [machine])
|
|
866
|
+
else siblings.push(machine)
|
|
867
|
+
if (!currentMachines.has(machine.key)) continue
|
|
868
|
+
subStateEntry.set(machine.key, currentMachines.get(machine.key))
|
|
869
|
+
}
|
|
870
|
+
// Definition order puts a parent before its sub-states, so a sub-state of
|
|
871
|
+
// an inactive sub-state is cleared too.
|
|
872
|
+
for (const machine of options.machineDefinitions) {
|
|
873
|
+
if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
|
|
874
|
+
currentMachines.delete(machine.key)
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Scope despawn triggers by machine key, in scope definition order
|
|
880
|
+
* (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
|
|
881
|
+
*/
|
|
882
|
+
const scopeTriggers = new Map<symbol, Array<{ readonly trigger: EntityScopeModule.Trigger; readonly scope: EntityScopeModule.EntityScope.Any }>>()
|
|
883
|
+
for (const scope of options.scopeDefinitions) {
|
|
884
|
+
for (const trigger of scope.despawnOn) {
|
|
885
|
+
const triggers = scopeTriggers.get(trigger.machine.key)
|
|
886
|
+
if (triggers === undefined) scopeTriggers.set(trigger.machine.key, [{ trigger, scope }])
|
|
887
|
+
else triggers.push({ trigger, scope })
|
|
888
|
+
}
|
|
806
889
|
}
|
|
807
890
|
|
|
808
891
|
const appendRelationFailure = (
|
|
@@ -842,6 +925,10 @@ const makeValidatedRuntime = <
|
|
|
842
925
|
*/
|
|
843
926
|
const commandUses = new Map<string, Set<string>>()
|
|
844
927
|
const ranSystems = new Set<string>()
|
|
928
|
+
/** With `debug`, the system running now: who queued a next state. */
|
|
929
|
+
let runningSystem = "(host)"
|
|
930
|
+
/** With `debug`, next states one system queued and another replaced before the marker. */
|
|
931
|
+
const nextStateReplacements = new Map<string, Debug.NextStateReplacement>()
|
|
845
932
|
const noteCommand = (kind: string, name: string): void => {
|
|
846
933
|
let uses = commandUses.get(commandOrigin)
|
|
847
934
|
if (uses === undefined) {
|
|
@@ -859,13 +946,96 @@ const makeValidatedRuntime = <
|
|
|
859
946
|
systems.add(commandOrigin)
|
|
860
947
|
}
|
|
861
948
|
|
|
949
|
+
/**
|
|
950
|
+
* `Schema.requires`, checked when commands are applied. Types reject a
|
|
951
|
+
* breaking command when they can see it; this catches the rest: a query
|
|
952
|
+
* proof gone stale within one flush (another system's command applied in
|
|
953
|
+
* between), erased types, and raw ids. A breaking command is skipped
|
|
954
|
+
* whole, traced, and reported by the `requirement-violated` lint.
|
|
955
|
+
*/
|
|
956
|
+
const requirements = requirementIndex(options.schema)
|
|
957
|
+
/** With `debug`, skipped commands by system, command, and requirement, for the lint. */
|
|
958
|
+
const requirementViolations = new Map<string, Debug.RequirementViolation>()
|
|
959
|
+
const violateRequirement = (
|
|
960
|
+
command: Debug.RequirementViolation["command"],
|
|
961
|
+
entity: number,
|
|
962
|
+
[component, missing]: readonly [string, string]
|
|
963
|
+
): void => {
|
|
964
|
+
if (tracing) recordEffect({ kind: "requirementViolated", entity, command, component, missing })
|
|
965
|
+
if (debugEnabled) {
|
|
966
|
+
const key = `${commandOrigin}/${command}/${component}/${missing}`
|
|
967
|
+
const seen = requirementViolations.get(key)
|
|
968
|
+
requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 })
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const streamLosses = new Map<string, Debug.StreamLoss>()
|
|
972
|
+
const watchStreamLoss = <V extends { all(): unknown; lagged(): boolean }>(view: V, system: string, stream: string, paused: boolean): V => {
|
|
973
|
+
const key = `${system}/${stream}`
|
|
974
|
+
const entry = (): { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] } => {
|
|
975
|
+
let found = streamLosses.get(key)
|
|
976
|
+
if (found === undefined) {
|
|
977
|
+
found = { system, stream, lostRuns: 0, checksLagged: false, paused }
|
|
978
|
+
streamLosses.set(key, found)
|
|
979
|
+
}
|
|
980
|
+
return found as { -readonly [K in keyof Debug.StreamLoss]: Debug.StreamLoss[K] }
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
...view,
|
|
984
|
+
all: () => {
|
|
985
|
+
const status = entry()
|
|
986
|
+
if (view.lagged()) status.lostRuns += 1
|
|
987
|
+
return view.all()
|
|
988
|
+
},
|
|
989
|
+
lagged: () => {
|
|
990
|
+
entry().checksLagged = true
|
|
991
|
+
return view.lagged()
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const staleTargets = new Map<string, Debug.StaleTarget>()
|
|
996
|
+
const reportStaleTarget = (command: Debug.StaleTarget["command"], entity: number): void => {
|
|
997
|
+
if (tracing) recordEffect({ kind: "staleTarget", entity, command })
|
|
998
|
+
if (debugEnabled) {
|
|
999
|
+
const key = `${commandOrigin}/${command}`
|
|
1000
|
+
const seen = staleTargets.get(key)
|
|
1001
|
+
staleTargets.set(key, { system: commandOrigin, command, entity, count: (seen?.count ?? 0) + 1 })
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const holds = (id: number) => {
|
|
1005
|
+
const record = world.records.get(id)
|
|
1006
|
+
return (descriptor: Descriptor<"component", string, any>): boolean =>
|
|
1007
|
+
record !== undefined && world.has(record, world.ordinalOf(descriptor))
|
|
1008
|
+
}
|
|
1009
|
+
/** The first requirement `components` break, as `[requirer, missing]`. */
|
|
1010
|
+
const missingIn = (
|
|
1011
|
+
components: ReadonlyArray<Entity.StagedComponent>,
|
|
1012
|
+
alsoHeld: (descriptor: Descriptor<"component", string, any>) => boolean
|
|
1013
|
+
): readonly [string, string] | undefined => {
|
|
1014
|
+
for (const [descriptor] of components) {
|
|
1015
|
+
for (const required of requirements.requires.get(descriptor.name) ?? []) {
|
|
1016
|
+
if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
|
|
1017
|
+
return [descriptor.name, required.name]
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return undefined
|
|
1022
|
+
}
|
|
1023
|
+
|
|
862
1024
|
/**
|
|
863
1025
|
* World adapter used by deferred commands.
|
|
864
1026
|
*/
|
|
865
1027
|
const commandWorld: Command.InternalWorld<S> = {
|
|
866
1028
|
spawnEntity(id, components) {
|
|
1029
|
+
if (!requirements.empty) {
|
|
1030
|
+
const missing = missingIn(components, () => false)
|
|
1031
|
+
if (missing !== undefined) {
|
|
1032
|
+
violateRequirement("spawn", id, missing)
|
|
1033
|
+
return false
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
867
1036
|
if (debugEnabled) for (const [descriptor] of components) noteCommand("spawns", descriptor.name)
|
|
868
1037
|
world.spawnEntity(id, components)
|
|
1038
|
+
return true
|
|
869
1039
|
},
|
|
870
1040
|
destroyEntity(id) {
|
|
871
1041
|
if (debugEnabled) {
|
|
@@ -883,13 +1053,38 @@ const makeValidatedRuntime = <
|
|
|
883
1053
|
if (debugEnabled) noteCommand("despawnsScope", scope.name)
|
|
884
1054
|
world.destroyEntityScope(scope.key)
|
|
885
1055
|
},
|
|
886
|
-
|
|
887
|
-
if (
|
|
888
|
-
|
|
1056
|
+
removeComponents(id, descriptors) {
|
|
1057
|
+
if (!requirements.empty && world.records.has(id)) {
|
|
1058
|
+
const held = holds(id)
|
|
1059
|
+
for (const descriptor of descriptors) {
|
|
1060
|
+
for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
|
|
1061
|
+
if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
|
|
1062
|
+
violateRequirement("remove", id, [requirer.name, descriptor.name])
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
for (const descriptor of descriptors) {
|
|
1069
|
+
if (debugEnabled) noteCommand("removes", descriptor.name)
|
|
1070
|
+
world.removeComponent(id, descriptor)
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
insertComponents(id, entries) {
|
|
1074
|
+
if (!requirements.empty && world.records.has(id)) {
|
|
1075
|
+
const missing = missingIn(entries, holds(id))
|
|
1076
|
+
if (missing !== undefined) {
|
|
1077
|
+
violateRequirement("insert", id, missing)
|
|
1078
|
+
return
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
for (const [descriptor, value] of entries) {
|
|
1082
|
+
if (debugEnabled) noteCommand("inserts", descriptor.name)
|
|
1083
|
+
world.writeComponent(id, descriptor, value)
|
|
1084
|
+
}
|
|
889
1085
|
},
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
world.writeComponent(id, descriptor, value)
|
|
1086
|
+
reportStaleTarget(command, id) {
|
|
1087
|
+
reportStaleTarget(command, id)
|
|
893
1088
|
},
|
|
894
1089
|
tryRelate(id, relation, target) {
|
|
895
1090
|
if (debugEnabled) {
|
|
@@ -1024,7 +1219,7 @@ const makeValidatedRuntime = <
|
|
|
1024
1219
|
return lookup
|
|
1025
1220
|
}
|
|
1026
1221
|
|
|
1027
|
-
const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>):
|
|
1222
|
+
const makeQueryHandle = <Q extends Query.Query.Any<Root>>(query: Q, reader: ReaderState, lookup: LookupApi<S, Root>): QueryIteration<S, Q> & QueryLookups<S, Q> => {
|
|
1028
1223
|
const each = () => queries.each(query, reader.since) as ReadonlyArray<QueryMatch<S, Q>>
|
|
1029
1224
|
const matching = (ids: ReadonlyArray<Entity.EntityId<S, Root>>): Array<QueryMatch<S, Q>> => {
|
|
1030
1225
|
const matches: Array<QueryMatch<S, Q>> = []
|
|
@@ -1082,6 +1277,32 @@ const makeValidatedRuntime = <
|
|
|
1082
1277
|
const machineOriginals = new Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>()
|
|
1083
1278
|
const emittedEvents = new Map<symbol, Array<unknown>>()
|
|
1084
1279
|
|
|
1280
|
+
/**
|
|
1281
|
+
* The open phase, or `undefined`. A transition's exit phase (the exit
|
|
1282
|
+
* schedules of the sub-states it ends and of the state it leaves, then its
|
|
1283
|
+
* `onTransition` schedules) commits or rolls back as one: when one of its
|
|
1284
|
+
* systems fails, the writes, events, queued next states, queued commands,
|
|
1285
|
+
* and stream positions of the systems that ran before it are undone too, so
|
|
1286
|
+
* the retry at a later marker starts from the same state.
|
|
1287
|
+
*/
|
|
1288
|
+
interface Phase {
|
|
1289
|
+
readonly tick: number
|
|
1290
|
+
readonly commands: number
|
|
1291
|
+
readonly origins: number
|
|
1292
|
+
readonly resources: Map<symbol, unknown>
|
|
1293
|
+
readonly resourceChangedAt: Map<symbol, number | undefined>
|
|
1294
|
+
readonly machines: Map<symbol, { value: unknown; skipIfSame: boolean } | typeof absentValue>
|
|
1295
|
+
readonly readers: Map<ReaderState, readonly [since: number, lastRun: number, streamSince: number, streamLastRun: number]>
|
|
1296
|
+
}
|
|
1297
|
+
let phase: Phase | undefined
|
|
1298
|
+
|
|
1299
|
+
/** Keeps a reader's position from before the phase, the first time the phase moves it. */
|
|
1300
|
+
const journalReader = (reader: ReaderState): void => {
|
|
1301
|
+
if (phase !== undefined && !phase.readers.has(reader)) {
|
|
1302
|
+
phase.readers.set(reader, [reader.since, reader.lastRun, reader.streamSince, reader.streamLastRun])
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1085
1306
|
const journalStoreWrite = (store: Map<symbol, unknown>, originals: Map<symbol, unknown>) =>
|
|
1086
1307
|
(key: symbol, value: unknown): void => {
|
|
1087
1308
|
if (!originals.has(key)) {
|
|
@@ -1091,6 +1312,15 @@ const makeValidatedRuntime = <
|
|
|
1091
1312
|
}
|
|
1092
1313
|
const writeResource = journalStoreWrite(resources, resourceOriginals)
|
|
1093
1314
|
|
|
1315
|
+
/**
|
|
1316
|
+
* Resource change ticks for `changedResource` readers. Only resources some
|
|
1317
|
+
* system reads with `changedResource` are compared at commit; a reader's
|
|
1318
|
+
* first run reports a change anyway, so writes before it registers are not
|
|
1319
|
+
* needed.
|
|
1320
|
+
*/
|
|
1321
|
+
const watchedResources = new Set<symbol>()
|
|
1322
|
+
const resourceChangedAt = new Map<symbol, number>()
|
|
1323
|
+
|
|
1094
1324
|
const journalMachine = (key: symbol): void => {
|
|
1095
1325
|
if (!machineOriginals.has(key)) {
|
|
1096
1326
|
machineOriginals.set(key, pendingMachines.get(key) ?? absentValue)
|
|
@@ -1103,6 +1333,23 @@ const makeValidatedRuntime = <
|
|
|
1103
1333
|
|
|
1104
1334
|
const commitSystemTransaction = (): void => {
|
|
1105
1335
|
world.commitTransaction()
|
|
1336
|
+
if (phase !== undefined) {
|
|
1337
|
+
for (const [key, before] of resourceOriginals) {
|
|
1338
|
+
if (!phase.resources.has(key)) phase.resources.set(key, before)
|
|
1339
|
+
}
|
|
1340
|
+
for (const [key, before] of machineOriginals) {
|
|
1341
|
+
if (!phase.machines.has(key)) phase.machines.set(key, before)
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (watchedResources.size > 0) {
|
|
1345
|
+
for (const [key, before] of resourceOriginals) {
|
|
1346
|
+
if (!watchedResources.has(key)) continue
|
|
1347
|
+
if (before === absentValue || !sameValue(before, resources.get(key))) {
|
|
1348
|
+
if (phase !== undefined && !phase.resourceChangedAt.has(key)) phase.resourceChangedAt.set(key, resourceChangedAt.get(key))
|
|
1349
|
+
resourceChangedAt.set(key, world.currentTick())
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1106
1353
|
if (emittedEvents.size > 0) {
|
|
1107
1354
|
const published = world.advanceTick()
|
|
1108
1355
|
for (const [key, values] of emittedEvents) {
|
|
@@ -1139,12 +1386,71 @@ const makeValidatedRuntime = <
|
|
|
1139
1386
|
emittedEvents.clear()
|
|
1140
1387
|
}
|
|
1141
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* Runs `run` as one phase: on failure, or a thrown defect, everything the
|
|
1391
|
+
* systems in it committed is undone.
|
|
1392
|
+
*/
|
|
1393
|
+
const runAsPhase = (run: () => Result.Result<void, SystemFailure>): Result.Result<void, SystemFailure> => {
|
|
1394
|
+
const opened: Phase = {
|
|
1395
|
+
tick: world.currentTick(),
|
|
1396
|
+
commands: pendingCommands.length,
|
|
1397
|
+
origins: pendingOrigins.length,
|
|
1398
|
+
resources: new Map(),
|
|
1399
|
+
resourceChangedAt: new Map(),
|
|
1400
|
+
machines: new Map(),
|
|
1401
|
+
readers: new Map()
|
|
1402
|
+
}
|
|
1403
|
+
phase = opened
|
|
1404
|
+
world.beginPhase()
|
|
1405
|
+
let result: Result.Result<void, SystemFailure> | undefined
|
|
1406
|
+
try {
|
|
1407
|
+
result = run()
|
|
1408
|
+
} finally {
|
|
1409
|
+
phase = undefined
|
|
1410
|
+
if (result?.ok === true) {
|
|
1411
|
+
world.commitPhase()
|
|
1412
|
+
} else {
|
|
1413
|
+
world.rollbackPhase()
|
|
1414
|
+
restoreStore(resources, opened.resources)
|
|
1415
|
+
for (const [key, value] of opened.machines) {
|
|
1416
|
+
if (value === absentValue) pendingMachines.delete(key)
|
|
1417
|
+
else pendingMachines.set(key, value)
|
|
1418
|
+
}
|
|
1419
|
+
for (const [key, tick] of opened.resourceChangedAt) {
|
|
1420
|
+
if (tick === undefined) resourceChangedAt.delete(key)
|
|
1421
|
+
else resourceChangedAt.set(key, tick)
|
|
1422
|
+
}
|
|
1423
|
+
for (const [reader, [since, lastRun, streamSince, streamLastRun]] of opened.readers) {
|
|
1424
|
+
reader.since = since
|
|
1425
|
+
reader.lastRun = lastRun
|
|
1426
|
+
reader.streamSince = streamSince
|
|
1427
|
+
reader.streamLastRun = streamLastRun
|
|
1428
|
+
}
|
|
1429
|
+
events.retract(opened.tick)
|
|
1430
|
+
pendingCommands.length = opened.commands
|
|
1431
|
+
pendingOrigins.length = opened.origins
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
return result
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1142
1437
|
const makeResourceWriteView = (
|
|
1143
1438
|
descriptor: Descriptor<"resource", string, any>,
|
|
1144
1439
|
store: Map<symbol, unknown>,
|
|
1145
1440
|
write: (key: symbol, value: unknown) => void
|
|
1146
1441
|
) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor))
|
|
1147
1442
|
|
|
1443
|
+
const makeResourceChangedView = (descriptor: Descriptor<"resource", string, any>, reader: ReaderState): ResourceChangedView<unknown> => {
|
|
1444
|
+
const key = descriptor.key
|
|
1445
|
+
watchedResources.add(key)
|
|
1446
|
+
const read = Cells.storeRead(resources, key)
|
|
1447
|
+
return {
|
|
1448
|
+
get: () => read.get(),
|
|
1449
|
+
// `since` is the previous completed run; 0 before the first one.
|
|
1450
|
+
changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1148
1454
|
const makeEventReadView = <T>(descriptorKey: symbol, reader: ReaderState): EventReadView<T> => ({
|
|
1149
1455
|
all() {
|
|
1150
1456
|
return events.since(descriptorKey, reader.streamSince) as ReadonlyArray<ReadonlyValue<T>>
|
|
@@ -1211,19 +1517,49 @@ const makeValidatedRuntime = <
|
|
|
1211
1517
|
})
|
|
1212
1518
|
|
|
1213
1519
|
const makeMachineReadView = <M extends Machine.StateMachine.Any>(stateMachine: M): MachineReadView<M> => ({
|
|
1214
|
-
get: () => currentMachines.get(stateMachine.key) as Machine.
|
|
1520
|
+
get: () => currentMachines.get(stateMachine.key) as Machine.CurrentValue<M>,
|
|
1215
1521
|
is: (value) => currentMachines.get(stateMachine.key) === value
|
|
1216
1522
|
})
|
|
1217
1523
|
|
|
1524
|
+
/** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
|
|
1525
|
+
const queueMachine = <M extends Machine.StateMachine.Any>(stateMachine: M, value: unknown, skipIfSame: boolean): Machine.SetResult<M> => {
|
|
1526
|
+
const within = stateMachine.within
|
|
1527
|
+
if (within !== undefined && !currentMachines.has(stateMachine.key)) {
|
|
1528
|
+
return Result.failure({
|
|
1529
|
+
_tag: "SubStateInactive",
|
|
1530
|
+
machine: stateMachine.name,
|
|
1531
|
+
parent: within.machine.name,
|
|
1532
|
+
requires: within.state
|
|
1533
|
+
} satisfies Machine.SubStateInactive) as Machine.SetResult<M>
|
|
1534
|
+
}
|
|
1535
|
+
journalMachine(stateMachine.key)
|
|
1536
|
+
if (debugEnabled) {
|
|
1537
|
+
const previous = pendingMachines.get(stateMachine.key)
|
|
1538
|
+
// A set from a transition schedule waits for the next marker: it replaces nothing queued for this one.
|
|
1539
|
+
if (previous?.by !== undefined && previous.by !== runningSystem && previous.value !== value && activeTransitions.size === 0) {
|
|
1540
|
+
const key = `${stateMachine.name}/${previous.by}/${runningSystem}/${String(previous.value)}/${String(value)}`
|
|
1541
|
+
const seen = nextStateReplacements.get(key)
|
|
1542
|
+
nextStateReplacements.set(key, {
|
|
1543
|
+
machine: stateMachine.name,
|
|
1544
|
+
replaced: { system: previous.by, value: previous.value as Machine.StateValue },
|
|
1545
|
+
by: { system: runningSystem, value: value as Machine.StateValue },
|
|
1546
|
+
count: (seen?.count ?? 0) + 1
|
|
1547
|
+
})
|
|
1548
|
+
}
|
|
1549
|
+
pendingMachines.set(stateMachine.key, { value, skipIfSame, by: runningSystem })
|
|
1550
|
+
} else {
|
|
1551
|
+
pendingMachines.set(stateMachine.key, { value, skipIfSame })
|
|
1552
|
+
}
|
|
1553
|
+
return (within === undefined ? undefined : succeeded) as Machine.SetResult<M>
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1218
1556
|
const makeNextMachineWriteView = <M extends Machine.StateMachine.Any>(stateMachine: M): NextMachineWriteView<M> => ({
|
|
1219
1557
|
getPending: () => pendingMachines.get(stateMachine.key)?.value as Machine.StateMachine.Value<M> | undefined,
|
|
1220
1558
|
set(value) {
|
|
1221
|
-
|
|
1222
|
-
pendingMachines.set(stateMachine.key, { value, skipIfSame: false })
|
|
1559
|
+
return queueMachine(stateMachine, value, false)
|
|
1223
1560
|
},
|
|
1224
1561
|
setIfChanged(value) {
|
|
1225
|
-
|
|
1226
|
-
pendingMachines.set(stateMachine.key, { value, skipIfSame: true })
|
|
1562
|
+
return queueMachine(stateMachine, value, true)
|
|
1227
1563
|
},
|
|
1228
1564
|
reset() {
|
|
1229
1565
|
journalMachine(stateMachine.key)
|
|
@@ -1275,25 +1611,32 @@ const makeValidatedRuntime = <
|
|
|
1275
1611
|
const makeContext = (system: SystemDefinition<any, any, any>, reader: ReaderState, queue: Command.CommandQueue<S, Root>): SystemContext<any> => {
|
|
1276
1612
|
const spec = system.spec
|
|
1277
1613
|
const lookup = makeLookup(reader)
|
|
1614
|
+
const paused = (spec.pauseWhen as ReadonlyArray<unknown>).length > 0
|
|
1615
|
+
/** On a debug runtime, records reads after a loss and calls of `lagged()`, for the `stream-loss-unread` lint. */
|
|
1616
|
+
const watch = <V extends { all(): unknown; lagged(): boolean }>(view: V, stream: string): V =>
|
|
1617
|
+
debugEnabled ? watchStreamLoss(view, system.name, stream, paused) : view
|
|
1278
1618
|
return {
|
|
1279
1619
|
local: (spec.local as (() => unknown) | undefined)?.(),
|
|
1280
|
-
|
|
1620
|
+
// A filtered query hands out the same object; its type hides the lookups.
|
|
1621
|
+
queries: mapRecord(spec.queries as Record<string, Query.Query.Any<Root>>, (query) => makeQueryHandle(query, reader, lookup) as never),
|
|
1281
1622
|
lookup,
|
|
1282
1623
|
resources: mapRecord(spec.resources as Record<string, any>, (access) =>
|
|
1283
1624
|
access.mode === "read"
|
|
1284
1625
|
? Cells.storeRead(resources, access.descriptor.key)
|
|
1285
|
-
:
|
|
1626
|
+
: access.mode === "changed"
|
|
1627
|
+
? makeResourceChangedView(access.descriptor, reader)
|
|
1628
|
+
: makeResourceWriteView(access.descriptor, resources, writeResource)),
|
|
1286
1629
|
events: mapRecord(spec.events as Record<string, any>, (access) =>
|
|
1287
1630
|
access.mode === "read"
|
|
1288
|
-
? makeEventReadView(access.descriptor.key, reader)
|
|
1631
|
+
? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
|
|
1289
1632
|
: makeEventWriteView(access.descriptor.key)),
|
|
1290
1633
|
machines: mapRecord(spec.machines as Record<string, any>, (access) => makeMachineReadView(access.machine)),
|
|
1291
1634
|
nextMachines: mapRecord(spec.nextMachines as Record<string, any>, (access) => makeNextMachineWriteView(access.machine)),
|
|
1292
|
-
transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => makeTransitionEventReadView(access.machine, reader)),
|
|
1635
|
+
transitionEvents: mapRecord(spec.transitionEvents as Record<string, any>, (access) => watch(makeTransitionEventReadView(access.machine, reader), `transition events ${access.machine.name}`)),
|
|
1293
1636
|
transitions: mapRecord(spec.transitions as Record<string, any>, (access) => makeTransitionReadView(access.machine)),
|
|
1294
|
-
removed: mapRecord(spec.removed as Record<string, any>, (access) => makeRemovedReadView(access.descriptor, reader)),
|
|
1295
|
-
despawned: mapRecord(spec.despawned as Record<string, any>, () => makeDespawnedReadView(reader)),
|
|
1296
|
-
relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => makeRelationFailureReadView(access.relation, reader)),
|
|
1637
|
+
removed: mapRecord(spec.removed as Record<string, any>, (access) => watch(makeRemovedReadView(access.descriptor, reader), `removed ${access.descriptor.name}`)),
|
|
1638
|
+
despawned: mapRecord(spec.despawned as Record<string, any>, () => watch(makeDespawnedReadView(reader), "despawned")),
|
|
1639
|
+
relationFailures: mapRecord(spec.relationFailures as Record<string, any>, (access) => watch(makeRelationFailureReadView(access.relation, reader), `relation failures ${access.relation.name}`)),
|
|
1297
1640
|
services: mapRecord(spec.services as Record<string, any>, (access) =>
|
|
1298
1641
|
providedServices[access.descriptor.name as keyof Services]),
|
|
1299
1642
|
// The queue serves every declared permission set; the spec's types narrow it.
|
|
@@ -1486,6 +1829,7 @@ const makeValidatedRuntime = <
|
|
|
1486
1829
|
// neither holds them nor receives a backlog when it runs again.
|
|
1487
1830
|
const skipped = slotOf(system, true)
|
|
1488
1831
|
if (tracing) traceSkipped(system, condition, skipped.reader, false)
|
|
1832
|
+
journalReader(skipped.reader)
|
|
1489
1833
|
skipped.reader.streamLastRun = world.currentTick()
|
|
1490
1834
|
return succeeded
|
|
1491
1835
|
}
|
|
@@ -1501,7 +1845,11 @@ const makeValidatedRuntime = <
|
|
|
1501
1845
|
}
|
|
1502
1846
|
}
|
|
1503
1847
|
const { context, commands, reader } = slotOf(system, true)
|
|
1504
|
-
if (debugEnabled)
|
|
1848
|
+
if (debugEnabled) {
|
|
1849
|
+
ranSystems.add(system.name)
|
|
1850
|
+
runningSystem = system.name
|
|
1851
|
+
}
|
|
1852
|
+
journalReader(reader)
|
|
1505
1853
|
// Changes stamped after the previous completed run are visible to this run.
|
|
1506
1854
|
reader.since = reader.lastRun
|
|
1507
1855
|
reader.streamSince = reader.streamLastRun
|
|
@@ -1626,6 +1974,36 @@ const makeValidatedRuntime = <
|
|
|
1626
1974
|
}
|
|
1627
1975
|
}
|
|
1628
1976
|
|
|
1977
|
+
/**
|
|
1978
|
+
* Despawns the scopes whose trigger matches a committed transition: leaving
|
|
1979
|
+
* `snapshot.from` or entering `snapshot.to`. It runs right after the
|
|
1980
|
+
* commit, so `onExit` systems still saw the members and entities spawned by
|
|
1981
|
+
* `onEnter` survive. Members are the entities in the scope at that moment:
|
|
1982
|
+
* commands queued by `onExit` systems and not yet applied are not.
|
|
1983
|
+
*/
|
|
1984
|
+
const despawnTriggeredScopes = (machineKey: symbol, snapshot: Machine.TransitionSnapshot): void => {
|
|
1985
|
+
const triggers = scopeTriggers.get(machineKey)
|
|
1986
|
+
if (triggers === undefined) return
|
|
1987
|
+
const applied: Array<Debug.AppliedCommand> = []
|
|
1988
|
+
for (const { trigger, scope } of triggers) {
|
|
1989
|
+
const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to
|
|
1990
|
+
if (!matches) continue
|
|
1991
|
+
const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`
|
|
1992
|
+
const effects: Array<Debug.Effect> = []
|
|
1993
|
+
commandEffects = tracing ? effects : undefined
|
|
1994
|
+
commandOrigin = origin
|
|
1995
|
+
try {
|
|
1996
|
+
commandWorld.destroyEntityScope(scope)
|
|
1997
|
+
} finally {
|
|
1998
|
+
commandEffects = undefined
|
|
1999
|
+
}
|
|
2000
|
+
if (tracing) applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects })
|
|
2001
|
+
}
|
|
2002
|
+
if (applied.length > 0) {
|
|
2003
|
+
emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied })
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
|
|
1629
2007
|
/**
|
|
1630
2008
|
* Applies queued commands, then queued machine transitions, running the
|
|
1631
2009
|
* matching exit, transition, and enter schedules from the bundle.
|
|
@@ -1643,7 +2021,17 @@ const makeValidatedRuntime = <
|
|
|
1643
2021
|
|
|
1644
2022
|
const schedules = bundle?.entries ?? []
|
|
1645
2023
|
for (const [machineKey, pending] of scheduledTransitions) {
|
|
1646
|
-
|
|
2024
|
+
if (discardedPending.has(pending)) {
|
|
2025
|
+
// Discarded earlier in this marker: its parent left the state it exists in.
|
|
2026
|
+
continue
|
|
2027
|
+
}
|
|
2028
|
+
// A transition schedule earlier in this marker may have queued a newer
|
|
2029
|
+
// value for this machine: that one waits for the next marker.
|
|
2030
|
+
if (pendingMachines.get(machineKey) === pending) pendingMachines.delete(machineKey)
|
|
2031
|
+
/** Keeps this request queued after a failure, unless a newer one replaced it. */
|
|
2032
|
+
const requeue = () => {
|
|
2033
|
+
if (!pendingMachines.has(machineKey)) pendingMachines.set(machineKey, pending)
|
|
2034
|
+
}
|
|
1647
2035
|
const current = currentMachines.get(machineKey)
|
|
1648
2036
|
if (current === undefined) {
|
|
1649
2037
|
continue
|
|
@@ -1653,45 +2041,52 @@ const makeValidatedRuntime = <
|
|
|
1653
2041
|
continue
|
|
1654
2042
|
}
|
|
1655
2043
|
|
|
1656
|
-
previousMachines.set(machineKey, current)
|
|
1657
2044
|
const snapshot = {
|
|
1658
2045
|
from: current as Machine.StateValue,
|
|
1659
2046
|
to: pending.value as Machine.StateValue
|
|
1660
2047
|
}
|
|
1661
2048
|
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
2049
|
+
// Sub-states that exist in the state being left exit first, deepest
|
|
2050
|
+
// first: their exit schedules run before the parent's, while they
|
|
2051
|
+
// still have their values, and they lose them at the parent's commit.
|
|
2052
|
+
// The exit phase is one transaction: a failure undoes all of it, and
|
|
2053
|
+
// the transition stays queued for a later attempt.
|
|
2054
|
+
const leaving = activeSubStatesIn(machineKey, snapshot.from)
|
|
2055
|
+
const exited = runAsPhase(() => {
|
|
2056
|
+
for (const sub of leaving) {
|
|
2057
|
+
const value = currentMachines.get(sub.key)
|
|
2058
|
+
const result = runPhase(sub.key, "exit", value, { from: value, to: undefined } as unknown as Machine.TransitionSnapshot, schedules)
|
|
2059
|
+
if (!result.ok) return result
|
|
1672
2060
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
const
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if (tracing) traceTransition(machineKey, snapshot, "failed")
|
|
1686
|
-
return result
|
|
2061
|
+
const left = runPhase(machineKey, "exit", snapshot.from, snapshot, schedules)
|
|
2062
|
+
if (!left.ok) return left
|
|
2063
|
+
for (const schedule of schedules) {
|
|
2064
|
+
const transition = schedule.transition
|
|
2065
|
+
if (
|
|
2066
|
+
transition.phase === "transition"
|
|
2067
|
+
&& transition.machine.key === machineKey
|
|
2068
|
+
&& transition.from === snapshot.from
|
|
2069
|
+
&& transition.to === snapshot.to
|
|
2070
|
+
) {
|
|
2071
|
+
const result = runTransitionSchedule(schedule, snapshot)
|
|
2072
|
+
if (!result.ok) return result
|
|
1687
2073
|
}
|
|
1688
2074
|
}
|
|
2075
|
+
return succeeded
|
|
2076
|
+
})
|
|
2077
|
+
if (!exited.ok) {
|
|
2078
|
+
requeue()
|
|
2079
|
+
if (tracing) traceTransition(machineKey, snapshot, "failed")
|
|
2080
|
+
return exited
|
|
1689
2081
|
}
|
|
1690
2082
|
|
|
1691
|
-
currentMachines.
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2083
|
+
for (const sub of leaving) commitMachine(sub.key, currentMachines.get(sub.key), undefined)
|
|
2084
|
+
commitMachine(machineKey, current, pending.value)
|
|
2085
|
+
// Sub-states that exist in the entered state enter their value together
|
|
2086
|
+
// with the parent, parents first, before any onEnter schedule runs: a
|
|
2087
|
+
// failing onEnter cannot leave them without a value, and their scope
|
|
2088
|
+
// triggers fire before what the onEnter schedules spawn.
|
|
2089
|
+
const entered = commitSubStates(machineKey, snapshot.to)
|
|
1695
2090
|
|
|
1696
2091
|
for (const schedule of schedules) {
|
|
1697
2092
|
const transition = schedule.transition
|
|
@@ -1703,6 +2098,89 @@ const makeValidatedRuntime = <
|
|
|
1703
2098
|
}
|
|
1704
2099
|
}
|
|
1705
2100
|
}
|
|
2101
|
+
const enteredSchedules = runSubStateEnters(entered, schedules)
|
|
2102
|
+
if (!enteredSchedules.ok) return enteredSchedules
|
|
2103
|
+
}
|
|
2104
|
+
return succeeded
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Commits one machine's value (`undefined` for a sub-state that stops
|
|
2109
|
+
* existing): records the change and its transition event, traces it, and
|
|
2110
|
+
* despawns the scopes it triggers.
|
|
2111
|
+
*/
|
|
2112
|
+
const commitMachine = (machineKey: symbol, from: unknown, to: unknown): void => {
|
|
2113
|
+
const snapshot = { from, to } as unknown as Machine.TransitionSnapshot
|
|
2114
|
+
if (from !== undefined) previousMachines.set(machineKey, from)
|
|
2115
|
+
if (to === undefined) {
|
|
2116
|
+
currentMachines.delete(machineKey)
|
|
2117
|
+
const discarded = pendingMachines.get(machineKey)
|
|
2118
|
+
if (discarded !== undefined) discardedPending.add(discarded)
|
|
2119
|
+
pendingMachines.delete(machineKey)
|
|
2120
|
+
} else {
|
|
2121
|
+
currentMachines.set(machineKey, to)
|
|
2122
|
+
}
|
|
2123
|
+
changedMachines.add(machineKey)
|
|
2124
|
+
transitionEvents.append(machineKey, world.advanceTick(), [snapshot])
|
|
2125
|
+
if (tracing) traceTransition(machineKey, snapshot, "applied")
|
|
2126
|
+
despawnTriggeredScopes(machineKey, snapshot)
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
type TransitionSchedules = ReadonlyArray<Machine.StateMachine.AnyTransitionSchedule<S, Root>>
|
|
2130
|
+
|
|
2131
|
+
/** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
|
|
2132
|
+
const runPhase = (
|
|
2133
|
+
machineKey: symbol,
|
|
2134
|
+
phase: "enter" | "exit",
|
|
2135
|
+
state: unknown,
|
|
2136
|
+
snapshot: Machine.TransitionSnapshot,
|
|
2137
|
+
schedules: TransitionSchedules
|
|
2138
|
+
): Result.Result<void, SystemFailure> => {
|
|
2139
|
+
for (const schedule of schedules) {
|
|
2140
|
+
const transition = schedule.transition
|
|
2141
|
+
if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
|
|
2142
|
+
const result = runTransitionSchedule(schedule, snapshot)
|
|
2143
|
+
if (!result.ok) return result
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
return succeeded
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
|
|
2150
|
+
const activeSubStatesIn = (parentKey: symbol, state: unknown): Array<Machine.StateMachine.Any> => {
|
|
2151
|
+
const result: Array<Machine.StateMachine.Any> = []
|
|
2152
|
+
for (const sub of subStates.get(parentKey) ?? []) {
|
|
2153
|
+
const value = currentMachines.get(sub.key)
|
|
2154
|
+
if (sub.within!.state !== state || value === undefined) continue
|
|
2155
|
+
result.push(...activeSubStatesIn(sub.key, value), sub)
|
|
2156
|
+
}
|
|
2157
|
+
return result
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Gives the sub-states of `parentKey` that exist in `state` their entry
|
|
2162
|
+
* value, and theirs, parents first. Returns what it entered, in that order.
|
|
2163
|
+
*/
|
|
2164
|
+
const commitSubStates = (parentKey: symbol, state: unknown): Array<readonly [symbol, unknown]> => {
|
|
2165
|
+
const entered: Array<readonly [symbol, unknown]> = []
|
|
2166
|
+
for (const sub of subStates.get(parentKey) ?? []) {
|
|
2167
|
+
if (sub.within!.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key)) continue
|
|
2168
|
+
const value = subStateEntry.get(sub.key)
|
|
2169
|
+
commitMachine(sub.key, undefined, value)
|
|
2170
|
+
entered.push([sub.key, value], ...commitSubStates(sub.key, value))
|
|
2171
|
+
}
|
|
2172
|
+
return entered
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/** Runs the onEnter schedules of sub-states entered by {@link commitSubStates}, in that order. */
|
|
2176
|
+
const runSubStateEnters = (entered: ReadonlyArray<readonly [symbol, unknown]>, schedules: TransitionSchedules): Result.Result<void, SystemFailure> => {
|
|
2177
|
+
for (const [key, value] of entered) {
|
|
2178
|
+
const snapshot = { from: undefined, to: value } as unknown as Machine.TransitionSnapshot
|
|
2179
|
+
const result = runPhase(key, "enter", value, snapshot, schedules)
|
|
2180
|
+
if (!result.ok) {
|
|
2181
|
+
if (tracing) traceTransition(key, snapshot, "enterFailed")
|
|
2182
|
+
return result
|
|
2183
|
+
}
|
|
1706
2184
|
}
|
|
1707
2185
|
return succeeded
|
|
1708
2186
|
}
|
|
@@ -1739,8 +2217,14 @@ const makeValidatedRuntime = <
|
|
|
1739
2217
|
return succeeded
|
|
1740
2218
|
}
|
|
1741
2219
|
|
|
1742
|
-
/** Whether
|
|
2220
|
+
/** Whether `runtime.tick` is on the stack: a nested `tick` is a defect. */
|
|
1743
2221
|
let ticking = false
|
|
2222
|
+
/**
|
|
2223
|
+
* Whether a tick is advancing its frame or running its schedules (in
|
|
2224
|
+
* `tick` and `tryTick`): `send`, `snapshot`, and `restore` are defects
|
|
2225
|
+
* meanwhile. It is cleared before `tick.end` is announced to host listeners.
|
|
2226
|
+
*/
|
|
2227
|
+
let inTick = false
|
|
1744
2228
|
|
|
1745
2229
|
/**
|
|
1746
2230
|
* Runs one schedule. A failing system stops the schedule; systems that
|
|
@@ -1764,7 +2248,7 @@ const makeValidatedRuntime = <
|
|
|
1764
2248
|
let hostEvents = new Set<string>()
|
|
1765
2249
|
|
|
1766
2250
|
const send = (descriptor: Descriptor<"event", string, any>, value: unknown): void => {
|
|
1767
|
-
if (
|
|
2251
|
+
if (inTick) throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`)
|
|
1768
2252
|
if (!eventsByKey.has(descriptor.key)) throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`)
|
|
1769
2253
|
const published = world.advanceTick()
|
|
1770
2254
|
events.append(descriptor.key, published, [value], true)
|
|
@@ -1835,6 +2319,7 @@ const makeValidatedRuntime = <
|
|
|
1835
2319
|
if (ticking) throw new Error("runtime.tick cannot run inside another tick")
|
|
1836
2320
|
if (debugEnabled) reportUnread("next-tick")
|
|
1837
2321
|
ticking = true
|
|
2322
|
+
inTick = true
|
|
1838
2323
|
try {
|
|
1839
2324
|
advanceFrame()
|
|
1840
2325
|
return watchResult(observedTick(schedules, () => {
|
|
@@ -1846,6 +2331,7 @@ const makeValidatedRuntime = <
|
|
|
1846
2331
|
}), schedules)
|
|
1847
2332
|
} finally {
|
|
1848
2333
|
ticking = false
|
|
2334
|
+
inTick = false
|
|
1849
2335
|
}
|
|
1850
2336
|
}
|
|
1851
2337
|
|
|
@@ -1860,9 +2346,11 @@ const makeValidatedRuntime = <
|
|
|
1860
2346
|
try {
|
|
1861
2347
|
result = run()
|
|
1862
2348
|
} catch (defect) {
|
|
2349
|
+
inTick = false
|
|
1863
2350
|
emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect })
|
|
1864
2351
|
throw defect
|
|
1865
2352
|
}
|
|
2353
|
+
inTick = false
|
|
1866
2354
|
emitHost(result.ok
|
|
1867
2355
|
? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
|
|
1868
2356
|
: { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error })
|
|
@@ -1876,7 +2364,8 @@ const makeValidatedRuntime = <
|
|
|
1876
2364
|
case "resource":
|
|
1877
2365
|
return resources.has(requirement.key)
|
|
1878
2366
|
case "stateMachine":
|
|
1879
|
-
|
|
2367
|
+
// An inactive sub-state has no value but is provided.
|
|
2368
|
+
return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key)
|
|
1880
2369
|
}
|
|
1881
2370
|
}
|
|
1882
2371
|
|
|
@@ -1900,8 +2389,14 @@ const makeValidatedRuntime = <
|
|
|
1900
2389
|
return watchResult(Result.failure({ kind: "TickOrderViolation" as const, issues }), [schedule])
|
|
1901
2390
|
}
|
|
1902
2391
|
|
|
1903
|
-
|
|
1904
|
-
|
|
2392
|
+
if (inTick) throw new Error("runtime.tryTick cannot run inside another tick")
|
|
2393
|
+
inTick = true
|
|
2394
|
+
try {
|
|
2395
|
+
advanceFrame()
|
|
2396
|
+
return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]) as Result.Result<void, Schedule.FailureOf<Selected>>
|
|
2397
|
+
} finally {
|
|
2398
|
+
inTick = false
|
|
2399
|
+
}
|
|
1905
2400
|
}
|
|
1906
2401
|
|
|
1907
2402
|
/**
|
|
@@ -1938,6 +2433,7 @@ const makeValidatedRuntime = <
|
|
|
1938
2433
|
for (const provision of machineEntries) machinesByName.set(provision.machine.name, provision.machine)
|
|
1939
2434
|
|
|
1940
2435
|
const snapshot = (): Snapshot.WorldSnapshot => {
|
|
2436
|
+
if (inTick) throw new Error("runtime.snapshot was called while a schedule is running: it would capture writes a failing system rolls back; take it between ticks")
|
|
1941
2437
|
const savedResources: Record<string, unknown> = {}
|
|
1942
2438
|
for (const [name, descriptor] of resourcesByName) {
|
|
1943
2439
|
if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
|
|
@@ -1956,6 +2452,7 @@ const makeValidatedRuntime = <
|
|
|
1956
2452
|
}
|
|
1957
2453
|
return {
|
|
1958
2454
|
version: Snapshot.version,
|
|
2455
|
+
gameVersion: options.saves?.latest ?? 0,
|
|
1959
2456
|
nextEntity: world.nextEntityValue(),
|
|
1960
2457
|
entities: world.exportEntities(DescriptorModule.isTransient),
|
|
1961
2458
|
relations: world.exportRelations(),
|
|
@@ -1973,7 +2470,9 @@ const makeValidatedRuntime = <
|
|
|
1973
2470
|
const restore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
|
|
1974
2471
|
const parsed = Snapshot.parse(data)
|
|
1975
2472
|
if (!parsed.ok) return parsed
|
|
1976
|
-
const
|
|
2473
|
+
const migrated = migrateSave(parsed.value, options.saves)
|
|
2474
|
+
if (!migrated.ok) return migrated
|
|
2475
|
+
const saved = migrated.value
|
|
1977
2476
|
|
|
1978
2477
|
// Validate everything before touching the world.
|
|
1979
2478
|
const ids = new Set<number>()
|
|
@@ -1992,6 +2491,11 @@ const makeValidatedRuntime = <
|
|
|
1992
2491
|
if (!value.ok) return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error })
|
|
1993
2492
|
components.push([descriptor, value.value])
|
|
1994
2493
|
}
|
|
2494
|
+
// Transient components are never saved, so a requirement on one is not checked here.
|
|
2495
|
+
const missing = missingIn(components, DescriptorModule.isTransient)
|
|
2496
|
+
if (missing !== undefined) {
|
|
2497
|
+
return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] })
|
|
2498
|
+
}
|
|
1995
2499
|
entities.push({ id: entity.id, components })
|
|
1996
2500
|
}
|
|
1997
2501
|
const validatedResources: Array<readonly [symbol, unknown]> = []
|
|
@@ -2028,17 +2532,35 @@ const makeValidatedRuntime = <
|
|
|
2028
2532
|
}
|
|
2029
2533
|
}
|
|
2030
2534
|
}
|
|
2031
|
-
const
|
|
2535
|
+
const restoredMachines = new Map(currentMachines)
|
|
2032
2536
|
for (const [name, value] of Object.entries(saved.machines)) {
|
|
2033
2537
|
const machine = machinesByName.get(name)
|
|
2034
2538
|
if (!machine) return Result.failure({ _tag: "UnknownMachine", name })
|
|
2035
2539
|
if (!machine.values.includes(value)) return Result.failure({ _tag: "InvalidMachineState", name, value })
|
|
2036
|
-
|
|
2540
|
+
restoredMachines.set(machine.key, value)
|
|
2541
|
+
}
|
|
2542
|
+
// A save has no value for a sub-state whose parent is elsewhere; one that
|
|
2543
|
+
// has a value must have its parent in the state it exists in. A sub-state
|
|
2544
|
+
// missing while its parent is there enters its entry value.
|
|
2545
|
+
for (const machine of options.machineDefinitions) {
|
|
2546
|
+
const within = machine.within
|
|
2547
|
+
if (within === undefined) continue
|
|
2548
|
+
const active = restoredMachines.get(within.machine.key) === within.state
|
|
2549
|
+
if (Object.hasOwn(saved.machines, machine.name)) {
|
|
2550
|
+
if (!active) {
|
|
2551
|
+
return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state })
|
|
2552
|
+
}
|
|
2553
|
+
} else if (active && subStateEntry.has(machine.key)) {
|
|
2554
|
+
restoredMachines.set(machine.key, subStateEntry.get(machine.key))
|
|
2555
|
+
} else {
|
|
2556
|
+
restoredMachines.delete(machine.key)
|
|
2557
|
+
}
|
|
2037
2558
|
}
|
|
2038
2559
|
|
|
2039
2560
|
const scoped = new Set<number>()
|
|
2040
2561
|
const memberships: Array<readonly [number, symbol]> = []
|
|
2041
2562
|
for (const [name, members] of Object.entries(saved.scopes)) {
|
|
2563
|
+
if (!options.scopeDefinitions.some((scope) => scope.name === name)) return Result.failure({ _tag: "UnknownScope", name })
|
|
2042
2564
|
for (const id of members) {
|
|
2043
2565
|
if (!ids.has(id) || scoped.has(id)) return Result.failure({ _tag: "InvalidScope", name, entityId: id })
|
|
2044
2566
|
scoped.add(id)
|
|
@@ -2066,14 +2588,19 @@ const makeValidatedRuntime = <
|
|
|
2066
2588
|
world.tryRelate(source, relation, target)
|
|
2067
2589
|
}
|
|
2068
2590
|
for (const [id, key] of memberships) world.assignEntityScope(id, key)
|
|
2069
|
-
for (const [key, value] of validatedResources)
|
|
2070
|
-
|
|
2591
|
+
for (const [key, value] of validatedResources) {
|
|
2592
|
+
resources.set(key, value)
|
|
2593
|
+
resourceChangedAt.set(key, world.currentTick())
|
|
2594
|
+
}
|
|
2595
|
+
currentMachines.clear()
|
|
2596
|
+
for (const [key, value] of restoredMachines) currentMachines.set(key, value)
|
|
2071
2597
|
// Locals are not saved: each system starts over from its factory.
|
|
2072
2598
|
localSlots.forEach(({ slot }, index) => { (slot.context as { local: unknown }).local = rebuiltLocals[index] })
|
|
2073
2599
|
return succeeded
|
|
2074
2600
|
}
|
|
2075
2601
|
|
|
2076
2602
|
const tracedRestore = (data: unknown): Result.Result<void, Snapshot.RestoreError> => {
|
|
2603
|
+
if (inTick) throw new Error("runtime.restore was called while a schedule is running: the running systems' queued commands would land on the restored world; restore between ticks")
|
|
2077
2604
|
const result = restore(data)
|
|
2078
2605
|
if (tracing) emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok })
|
|
2079
2606
|
return result
|
|
@@ -2217,11 +2744,17 @@ const makeValidatedRuntime = <
|
|
|
2217
2744
|
schema: options.schema,
|
|
2218
2745
|
machines: [...new Set(machinesByName.values())],
|
|
2219
2746
|
currentMachine: (machine) => currentMachines.get(machine.key) as Machine.StateValue | undefined,
|
|
2747
|
+
providesMachine: (machine) => providedMachineKeys.has(machine.key),
|
|
2748
|
+
scopes: options.scopeDefinitions,
|
|
2220
2749
|
providedServices: Object.keys(providedServices),
|
|
2221
2750
|
externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
|
|
2222
2751
|
relationMutations,
|
|
2223
2752
|
commandUses,
|
|
2224
2753
|
ranSystems,
|
|
2754
|
+
requirementViolations: [...requirementViolations.values()],
|
|
2755
|
+
nextStateReplacements: [...nextStateReplacements.values()],
|
|
2756
|
+
staleTargets: [...staleTargets.values()],
|
|
2757
|
+
streamLosses: [...streamLosses.values()],
|
|
2225
2758
|
hasResource: (descriptor) => resources.has(descriptor.key),
|
|
2226
2759
|
schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule as ExecutableScheduleDefinition<S>] as const)
|
|
2227
2760
|
}),
|
|
@@ -2262,8 +2795,9 @@ const makeValidatedRuntime = <
|
|
|
2262
2795
|
* Creates a runtime for a fully built schema and a set of external services.
|
|
2263
2796
|
*
|
|
2264
2797
|
* Resources owned by constructed descriptors take raw input and are validated
|
|
2265
|
-
* here
|
|
2266
|
-
*
|
|
2798
|
+
* here. The runtime always comes back as a `Result`: typed `Success` when
|
|
2799
|
+
* nothing provided can fail (read `.value`), and a `Result` to check when a
|
|
2800
|
+
* constructed resource is provided.
|
|
2267
2801
|
*
|
|
2268
2802
|
* The runtime does not own the outer frame loop. It only owns ECS state plus
|
|
2269
2803
|
* the host-provided services that systems are allowed to depend on.
|
|
@@ -2276,7 +2810,7 @@ const makeValidatedRuntime = <
|
|
|
2276
2810
|
* ),
|
|
2277
2811
|
* resources: { Score: 0 },
|
|
2278
2812
|
* machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
|
|
2279
|
-
* })
|
|
2813
|
+
* }).value
|
|
2280
2814
|
* ```
|
|
2281
2815
|
*/
|
|
2282
2816
|
export const make = <
|
|
@@ -2289,17 +2823,16 @@ export const make = <
|
|
|
2289
2823
|
>(options: {
|
|
2290
2824
|
readonly schema: S
|
|
2291
2825
|
readonly services: ProvidedServices
|
|
2292
|
-
readonly resources?: Resources
|
|
2826
|
+
readonly resources?: Resources & ExactResources<S, Resources>
|
|
2293
2827
|
readonly machines?: ProvidedMachines
|
|
2294
2828
|
readonly machineDefinitions?: ReadonlyArray<Machine.StateMachine.Any>
|
|
2829
|
+
/** Scopes whose members a committed state change despawns; `Game.Runtime.make` passes every `Game.EntityScope`. */
|
|
2830
|
+
readonly scopeDefinitions?: ReadonlyArray<EntityScopeModule.EntityScope.Any>
|
|
2295
2831
|
/** Attaches a `debug` handle to the runtime, timed by `hostClock`; see the `Debug` module. */
|
|
2296
2832
|
readonly debug?: Enabled
|
|
2833
|
+
/** The game's save migrations (`Snapshot.migrations`), applied by `restore`. */
|
|
2834
|
+
readonly saves?: Snapshot.Migrations
|
|
2297
2835
|
}): MakeRuntimeResult<S, Simplify<RuntimeServicesOf<ProvidedServices>>, Resources, Root, RuntimeMachinesOf<ProvidedMachines>, Enabled> => {
|
|
2298
|
-
const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) =>
|
|
2299
|
-
options.resources !== undefined
|
|
2300
|
-
&& (options.resources as Record<string, unknown>)[key] !== undefined
|
|
2301
|
-
&& DescriptorModule.hasConstructor(descriptor)
|
|
2302
|
-
)
|
|
2303
2836
|
const validated = collectConstructedRegistryValues(options.schema.resources, options.resources)
|
|
2304
2837
|
if (!validated.ok) {
|
|
2305
2838
|
return Result.failure({ resources: validated.error }) as never
|
|
@@ -2310,9 +2843,44 @@ export const make = <
|
|
|
2310
2843
|
resources: validated.value,
|
|
2311
2844
|
machines: options.machines,
|
|
2312
2845
|
machineDefinitions: options.machineDefinitions ?? [],
|
|
2313
|
-
|
|
2846
|
+
scopeDefinitions: options.scopeDefinitions ?? [],
|
|
2847
|
+
debug: options.debug,
|
|
2848
|
+
saves: options.saves
|
|
2314
2849
|
})
|
|
2315
|
-
return
|
|
2850
|
+
return Result.success(runtime) as never
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/** Names listed more than once in `Names` (a tuple), in order of their second listing. */
|
|
2854
|
+
type RepeatedNames<Names extends ReadonlyArray<string>, Seen extends string = never> =
|
|
2855
|
+
Names extends readonly [infer Head extends string, ...infer Tail extends ReadonlyArray<string>]
|
|
2856
|
+
? (string extends Head ? never : Head extends Seen ? Head : never) | RepeatedNames<Tail, Seen | Head>
|
|
2857
|
+
: never
|
|
2858
|
+
|
|
2859
|
+
type ProvidedServiceNames<Entries extends ReadonlyArray<ServiceProvision>> = {
|
|
2860
|
+
readonly [K in keyof Entries]: Entries[K] extends ServiceProvision<infer D> ? D["name"] : string
|
|
2861
|
+
}
|
|
2862
|
+
type ProvidedMachineNames<Entries extends ReadonlyArray<MachineProvision>> = {
|
|
2863
|
+
readonly [K in keyof Entries]: Entries[K] extends MachineProvision<infer M> ? M["name"] : string
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
/** Rejects a service provided twice: only the last would be used. */
|
|
2867
|
+
type UniqueServices<Entries extends ReadonlyArray<ServiceProvision>> =
|
|
2868
|
+
[RepeatedNames<ProvidedServiceNames<Entries>>] extends [never] ? unknown : {
|
|
2869
|
+
readonly __fixServices__: `${RepeatedNames<ProvidedServiceNames<Entries>>} is provided twice: keep one Game.Runtime.service(...) for it`
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
/** Rejects a machine started twice: only the last initial value would be used. */
|
|
2873
|
+
type UniqueMachines<Entries extends ReadonlyArray<MachineProvision>> =
|
|
2874
|
+
[RepeatedNames<ProvidedMachineNames<Entries>>] extends [never] ? unknown : {
|
|
2875
|
+
readonly __fixMachines__: `${RepeatedNames<ProvidedMachineNames<Entries>>} is provided twice: keep one Game.Runtime.machine(...) for it`
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
const rejectRepeated = (names: ReadonlyArray<string>, kind: "service" | "machine"): void => {
|
|
2879
|
+
const seen = new Set<string>()
|
|
2880
|
+
for (const name of names) {
|
|
2881
|
+
if (seen.has(name)) throw new Error(`${name} is provided twice: keep one Game.Runtime.${kind}(...) for it`)
|
|
2882
|
+
seen.add(name)
|
|
2883
|
+
}
|
|
2316
2884
|
}
|
|
2317
2885
|
|
|
2318
2886
|
/**
|
|
@@ -2324,6 +2892,8 @@ export const make = <
|
|
|
2324
2892
|
*
|
|
2325
2893
|
* The helper keeps the runtime map keyed by service descriptors instead of
|
|
2326
2894
|
* ad hoc strings, so the provision site cannot drift from the declaration site.
|
|
2895
|
+
* Each service is provided once: a second provision of the same one does not
|
|
2896
|
+
* compile.
|
|
2327
2897
|
*
|
|
2328
2898
|
* @example
|
|
2329
2899
|
* ```ts
|
|
@@ -2336,7 +2906,8 @@ export const make = <
|
|
|
2336
2906
|
*/
|
|
2337
2907
|
export const services = <
|
|
2338
2908
|
const Entries extends ReadonlyArray<ServiceProvision>
|
|
2339
|
-
>(...entries: Entries): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
|
|
2909
|
+
>(...entries: Entries & UniqueServices<Entries>): RuntimeServices<ServiceEntriesToRecord<Entries>> => {
|
|
2910
|
+
rejectRepeated(entries.map((entry) => entry.descriptor.name), "service")
|
|
2340
2911
|
const provided: Record<string, unknown> = {}
|
|
2341
2912
|
for (const { descriptor, implementation } of entries) {
|
|
2342
2913
|
provided[descriptor.name] = implementation
|
|
@@ -2366,11 +2937,14 @@ export const service = <
|
|
|
2366
2937
|
* Builds the machine initialization environment from machine definitions.
|
|
2367
2938
|
*
|
|
2368
2939
|
* Use this when gameplay phases need a known committed starting state before
|
|
2369
|
-
* any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`.
|
|
2940
|
+
* any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`. Each
|
|
2941
|
+
* machine is provided once: a second provision of the same one does not
|
|
2942
|
+
* compile.
|
|
2370
2943
|
*/
|
|
2371
2944
|
export const machines = <
|
|
2372
2945
|
const Entries extends ReadonlyArray<MachineProvision>
|
|
2373
|
-
>(...entries: Entries): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
|
|
2946
|
+
>(...entries: Entries & UniqueMachines<Entries>): RuntimeMachines<MachineEntriesToRecord<Entries>> => {
|
|
2947
|
+
rejectRepeated(entries.map((entry) => entry.machine.name), "machine")
|
|
2374
2948
|
const provided: Record<string, unknown> = {}
|
|
2375
2949
|
for (const { machine, initial } of entries) {
|
|
2376
2950
|
provided[machine.name] = initial
|