@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/dist/Runtime.js
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: {
|
|
@@ -59,8 +60,10 @@ import * as HostClockModule from "./HostClock.js";
|
|
|
59
60
|
import * as Command from "./Command.js";
|
|
60
61
|
import * as DescriptorModule from "./Descriptor.js";
|
|
61
62
|
import * as Cells from "./internal/cells.js";
|
|
63
|
+
import { sameValue } from "./internal/equal.js";
|
|
62
64
|
import * as DebugInternal from "./internal/debug.js";
|
|
63
65
|
import { makeQueryEngine } from "./internal/queries.js";
|
|
66
|
+
import { requirementIndex } from "./internal/requirements.js";
|
|
64
67
|
import * as Streams from "./internal/streams.js";
|
|
65
68
|
import { ABSENT, makeWorld } from "./internal/world.js";
|
|
66
69
|
import * as Query from "./Query.js";
|
|
@@ -147,7 +150,7 @@ const collectConstructedRegistryValues = (registry, provided) => {
|
|
|
147
150
|
* resources: {
|
|
148
151
|
* score: 0
|
|
149
152
|
* }
|
|
150
|
-
* })
|
|
153
|
+
* }).value
|
|
151
154
|
* ```
|
|
152
155
|
*/
|
|
153
156
|
/**
|
|
@@ -157,6 +160,25 @@ const collectConstructedRegistryValues = (registry, provided) => {
|
|
|
157
160
|
* `lagged() === true` (or a `missed` read in the debug trace).
|
|
158
161
|
*/
|
|
159
162
|
export const streamCapacity = 65_536;
|
|
163
|
+
/**
|
|
164
|
+
* Brings a parsed save to `migrations.latest`: runs every step after its
|
|
165
|
+
* game version, checking the structure after each.
|
|
166
|
+
*/
|
|
167
|
+
const migrateSave = (save, saves) => {
|
|
168
|
+
const latest = saves?.latest ?? 0;
|
|
169
|
+
if (save.gameVersion > latest)
|
|
170
|
+
return Result.failure({ _tag: "NewerGameVersion", gameVersion: save.gameVersion, latest });
|
|
171
|
+
let current = save;
|
|
172
|
+
for (const step of saves?.steps ?? []) {
|
|
173
|
+
if (step.to <= current.gameVersion)
|
|
174
|
+
continue;
|
|
175
|
+
const next = Snapshot.parse({ ...step.migrate(current), gameVersion: step.to });
|
|
176
|
+
if (!next.ok)
|
|
177
|
+
return next;
|
|
178
|
+
current = next.value;
|
|
179
|
+
}
|
|
180
|
+
return Result.success(current);
|
|
181
|
+
};
|
|
160
182
|
const makeValidatedRuntime = (options) => {
|
|
161
183
|
const world = makeWorld(options.schema, streamCapacity);
|
|
162
184
|
const queries = makeQueryEngine(world);
|
|
@@ -169,7 +191,10 @@ const makeValidatedRuntime = (options) => {
|
|
|
169
191
|
* Machine-keyed committed, pending, and previous values.
|
|
170
192
|
*/
|
|
171
193
|
const currentMachines = new Map();
|
|
194
|
+
/** Queued next values; with `debug`, `by` is the system that queued it, for the `next-state-replaced` lint. */
|
|
172
195
|
const pendingMachines = new Map();
|
|
196
|
+
/** Requests dropped because their sub-state stopped existing: skipped by the marker that queued them. */
|
|
197
|
+
const discardedPending = new WeakSet();
|
|
173
198
|
const previousMachines = new Map();
|
|
174
199
|
/**
|
|
175
200
|
* Machine keys changed by transitions applied during the current schedule run.
|
|
@@ -240,8 +265,52 @@ const makeValidatedRuntime = (options) => {
|
|
|
240
265
|
const providedMachines = (options.machines ?? machines());
|
|
241
266
|
const machineEntries = (options.machines ?? machines())[runtimeMachinesEntries];
|
|
242
267
|
const machineDefinitionOrder = new Map(options.machineDefinitions.map((machine, index) => [machine.key, index]));
|
|
268
|
+
/** Machines the runtime was given an initial value for. */
|
|
269
|
+
const providedMachineKeys = new Set();
|
|
243
270
|
for (const provision of machineEntries) {
|
|
244
271
|
currentMachines.set(provision.machine.key, provision.initial);
|
|
272
|
+
providedMachineKeys.add(provision.machine.key);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Sub-states by parent machine key, in definition order, and the value
|
|
276
|
+
* each enters whenever its parent enters the state it exists in (its
|
|
277
|
+
* runtime-provided value). A sub-state has no current value while its
|
|
278
|
+
* parent is elsewhere, and never one when the runtime does not provide it.
|
|
279
|
+
*/
|
|
280
|
+
const subStates = new Map();
|
|
281
|
+
const subStateEntry = new Map();
|
|
282
|
+
for (const machine of options.machineDefinitions) {
|
|
283
|
+
if (machine.within === undefined)
|
|
284
|
+
continue;
|
|
285
|
+
const siblings = subStates.get(machine.within.machine.key);
|
|
286
|
+
if (siblings === undefined)
|
|
287
|
+
subStates.set(machine.within.machine.key, [machine]);
|
|
288
|
+
else
|
|
289
|
+
siblings.push(machine);
|
|
290
|
+
if (!currentMachines.has(machine.key))
|
|
291
|
+
continue;
|
|
292
|
+
subStateEntry.set(machine.key, currentMachines.get(machine.key));
|
|
293
|
+
}
|
|
294
|
+
// Definition order puts a parent before its sub-states, so a sub-state of
|
|
295
|
+
// an inactive sub-state is cleared too.
|
|
296
|
+
for (const machine of options.machineDefinitions) {
|
|
297
|
+
if (machine.within !== undefined && currentMachines.get(machine.within.machine.key) !== machine.within.state) {
|
|
298
|
+
currentMachines.delete(machine.key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Scope despawn triggers by machine key, in scope definition order
|
|
303
|
+
* (`Game.EntityScope(name, { despawnOnExit, despawnOnEnter })`).
|
|
304
|
+
*/
|
|
305
|
+
const scopeTriggers = new Map();
|
|
306
|
+
for (const scope of options.scopeDefinitions) {
|
|
307
|
+
for (const trigger of scope.despawnOn) {
|
|
308
|
+
const triggers = scopeTriggers.get(trigger.machine.key);
|
|
309
|
+
if (triggers === undefined)
|
|
310
|
+
scopeTriggers.set(trigger.machine.key, [{ trigger, scope }]);
|
|
311
|
+
else
|
|
312
|
+
triggers.push({ trigger, scope });
|
|
313
|
+
}
|
|
245
314
|
}
|
|
246
315
|
const appendRelationFailure = (relation, operation, sourceId, targetId, error) => {
|
|
247
316
|
if (tracing) {
|
|
@@ -267,6 +336,10 @@ const makeValidatedRuntime = (options) => {
|
|
|
267
336
|
*/
|
|
268
337
|
const commandUses = new Map();
|
|
269
338
|
const ranSystems = new Set();
|
|
339
|
+
/** With `debug`, the system running now: who queued a next state. */
|
|
340
|
+
let runningSystem = "(host)";
|
|
341
|
+
/** With `debug`, next states one system queued and another replaced before the marker. */
|
|
342
|
+
const nextStateReplacements = new Map();
|
|
270
343
|
const noteCommand = (kind, name) => {
|
|
271
344
|
let uses = commandUses.get(commandOrigin);
|
|
272
345
|
if (uses === undefined) {
|
|
@@ -283,15 +356,92 @@ const makeValidatedRuntime = (options) => {
|
|
|
283
356
|
}
|
|
284
357
|
systems.add(commandOrigin);
|
|
285
358
|
};
|
|
359
|
+
/**
|
|
360
|
+
* `Schema.requires`, checked when commands are applied. Types reject a
|
|
361
|
+
* breaking command when they can see it; this catches the rest: a query
|
|
362
|
+
* proof gone stale within one flush (another system's command applied in
|
|
363
|
+
* between), erased types, and raw ids. A breaking command is skipped
|
|
364
|
+
* whole, traced, and reported by the `requirement-violated` lint.
|
|
365
|
+
*/
|
|
366
|
+
const requirements = requirementIndex(options.schema);
|
|
367
|
+
/** With `debug`, skipped commands by system, command, and requirement, for the lint. */
|
|
368
|
+
const requirementViolations = new Map();
|
|
369
|
+
const violateRequirement = (command, entity, [component, missing]) => {
|
|
370
|
+
if (tracing)
|
|
371
|
+
recordEffect({ kind: "requirementViolated", entity, command, component, missing });
|
|
372
|
+
if (debugEnabled) {
|
|
373
|
+
const key = `${commandOrigin}/${command}/${component}/${missing}`;
|
|
374
|
+
const seen = requirementViolations.get(key);
|
|
375
|
+
requirementViolations.set(key, { system: commandOrigin, command, component, missing, entity, count: (seen?.count ?? 0) + 1 });
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const streamLosses = new Map();
|
|
379
|
+
const watchStreamLoss = (view, system, stream, paused) => {
|
|
380
|
+
const key = `${system}/${stream}`;
|
|
381
|
+
const entry = () => {
|
|
382
|
+
let found = streamLosses.get(key);
|
|
383
|
+
if (found === undefined) {
|
|
384
|
+
found = { system, stream, lostRuns: 0, checksLagged: false, paused };
|
|
385
|
+
streamLosses.set(key, found);
|
|
386
|
+
}
|
|
387
|
+
return found;
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
...view,
|
|
391
|
+
all: () => {
|
|
392
|
+
const status = entry();
|
|
393
|
+
if (view.lagged())
|
|
394
|
+
status.lostRuns += 1;
|
|
395
|
+
return view.all();
|
|
396
|
+
},
|
|
397
|
+
lagged: () => {
|
|
398
|
+
entry().checksLagged = true;
|
|
399
|
+
return view.lagged();
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
};
|
|
403
|
+
const staleTargets = new Map();
|
|
404
|
+
const reportStaleTarget = (command, entity) => {
|
|
405
|
+
if (tracing)
|
|
406
|
+
recordEffect({ kind: "staleTarget", entity, command });
|
|
407
|
+
if (debugEnabled) {
|
|
408
|
+
const key = `${commandOrigin}/${command}`;
|
|
409
|
+
const seen = staleTargets.get(key);
|
|
410
|
+
staleTargets.set(key, { system: commandOrigin, command, entity, count: (seen?.count ?? 0) + 1 });
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const holds = (id) => {
|
|
414
|
+
const record = world.records.get(id);
|
|
415
|
+
return (descriptor) => record !== undefined && world.has(record, world.ordinalOf(descriptor));
|
|
416
|
+
};
|
|
417
|
+
/** The first requirement `components` break, as `[requirer, missing]`. */
|
|
418
|
+
const missingIn = (components, alsoHeld) => {
|
|
419
|
+
for (const [descriptor] of components) {
|
|
420
|
+
for (const required of requirements.requires.get(descriptor.name) ?? []) {
|
|
421
|
+
if (!alsoHeld(required) && !components.some(([staged]) => staged.name === required.name)) {
|
|
422
|
+
return [descriptor.name, required.name];
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
};
|
|
286
428
|
/**
|
|
287
429
|
* World adapter used by deferred commands.
|
|
288
430
|
*/
|
|
289
431
|
const commandWorld = {
|
|
290
432
|
spawnEntity(id, components) {
|
|
433
|
+
if (!requirements.empty) {
|
|
434
|
+
const missing = missingIn(components, () => false);
|
|
435
|
+
if (missing !== undefined) {
|
|
436
|
+
violateRequirement("spawn", id, missing);
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
291
440
|
if (debugEnabled)
|
|
292
441
|
for (const [descriptor] of components)
|
|
293
442
|
noteCommand("spawns", descriptor.name);
|
|
294
443
|
world.spawnEntity(id, components);
|
|
444
|
+
return true;
|
|
295
445
|
},
|
|
296
446
|
destroyEntity(id) {
|
|
297
447
|
if (debugEnabled) {
|
|
@@ -311,15 +461,40 @@ const makeValidatedRuntime = (options) => {
|
|
|
311
461
|
noteCommand("despawnsScope", scope.name);
|
|
312
462
|
world.destroyEntityScope(scope.key);
|
|
313
463
|
},
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
|
|
464
|
+
removeComponents(id, descriptors) {
|
|
465
|
+
if (!requirements.empty && world.records.has(id)) {
|
|
466
|
+
const held = holds(id);
|
|
467
|
+
for (const descriptor of descriptors) {
|
|
468
|
+
for (const requirer of requirements.requiredBy.get(descriptor.name) ?? []) {
|
|
469
|
+
if (held(requirer) && !descriptors.some((removed) => removed.name === requirer.name)) {
|
|
470
|
+
violateRequirement("remove", id, [requirer.name, descriptor.name]);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const descriptor of descriptors) {
|
|
477
|
+
if (debugEnabled)
|
|
478
|
+
noteCommand("removes", descriptor.name);
|
|
479
|
+
world.removeComponent(id, descriptor);
|
|
480
|
+
}
|
|
318
481
|
},
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
482
|
+
insertComponents(id, entries) {
|
|
483
|
+
if (!requirements.empty && world.records.has(id)) {
|
|
484
|
+
const missing = missingIn(entries, holds(id));
|
|
485
|
+
if (missing !== undefined) {
|
|
486
|
+
violateRequirement("insert", id, missing);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const [descriptor, value] of entries) {
|
|
491
|
+
if (debugEnabled)
|
|
492
|
+
noteCommand("inserts", descriptor.name);
|
|
493
|
+
world.writeComponent(id, descriptor, value);
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
reportStaleTarget(command, id) {
|
|
497
|
+
reportStaleTarget(command, id);
|
|
323
498
|
},
|
|
324
499
|
tryRelate(id, relation, target) {
|
|
325
500
|
if (debugEnabled) {
|
|
@@ -487,6 +662,13 @@ const makeValidatedRuntime = (options) => {
|
|
|
487
662
|
const resourceOriginals = new Map();
|
|
488
663
|
const machineOriginals = new Map();
|
|
489
664
|
const emittedEvents = new Map();
|
|
665
|
+
let phase;
|
|
666
|
+
/** Keeps a reader's position from before the phase, the first time the phase moves it. */
|
|
667
|
+
const journalReader = (reader) => {
|
|
668
|
+
if (phase !== undefined && !phase.readers.has(reader)) {
|
|
669
|
+
phase.readers.set(reader, [reader.since, reader.lastRun, reader.streamSince, reader.streamLastRun]);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
490
672
|
const journalStoreWrite = (store, originals) => (key, value) => {
|
|
491
673
|
if (!originals.has(key)) {
|
|
492
674
|
originals.set(key, store.has(key) ? store.get(key) : absentValue);
|
|
@@ -494,6 +676,14 @@ const makeValidatedRuntime = (options) => {
|
|
|
494
676
|
store.set(key, value);
|
|
495
677
|
};
|
|
496
678
|
const writeResource = journalStoreWrite(resources, resourceOriginals);
|
|
679
|
+
/**
|
|
680
|
+
* Resource change ticks for `changedResource` readers. Only resources some
|
|
681
|
+
* system reads with `changedResource` are compared at commit; a reader's
|
|
682
|
+
* first run reports a change anyway, so writes before it registers are not
|
|
683
|
+
* needed.
|
|
684
|
+
*/
|
|
685
|
+
const watchedResources = new Set();
|
|
686
|
+
const resourceChangedAt = new Map();
|
|
497
687
|
const journalMachine = (key) => {
|
|
498
688
|
if (!machineOriginals.has(key)) {
|
|
499
689
|
machineOriginals.set(key, pendingMachines.get(key) ?? absentValue);
|
|
@@ -504,6 +694,27 @@ const makeValidatedRuntime = (options) => {
|
|
|
504
694
|
};
|
|
505
695
|
const commitSystemTransaction = () => {
|
|
506
696
|
world.commitTransaction();
|
|
697
|
+
if (phase !== undefined) {
|
|
698
|
+
for (const [key, before] of resourceOriginals) {
|
|
699
|
+
if (!phase.resources.has(key))
|
|
700
|
+
phase.resources.set(key, before);
|
|
701
|
+
}
|
|
702
|
+
for (const [key, before] of machineOriginals) {
|
|
703
|
+
if (!phase.machines.has(key))
|
|
704
|
+
phase.machines.set(key, before);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (watchedResources.size > 0) {
|
|
708
|
+
for (const [key, before] of resourceOriginals) {
|
|
709
|
+
if (!watchedResources.has(key))
|
|
710
|
+
continue;
|
|
711
|
+
if (before === absentValue || !sameValue(before, resources.get(key))) {
|
|
712
|
+
if (phase !== undefined && !phase.resourceChangedAt.has(key))
|
|
713
|
+
phase.resourceChangedAt.set(key, resourceChangedAt.get(key));
|
|
714
|
+
resourceChangedAt.set(key, world.currentTick());
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
507
718
|
if (emittedEvents.size > 0) {
|
|
508
719
|
const published = world.advanceTick();
|
|
509
720
|
for (const [key, values] of emittedEvents) {
|
|
@@ -539,7 +750,70 @@ const makeValidatedRuntime = (options) => {
|
|
|
539
750
|
machineOriginals.clear();
|
|
540
751
|
emittedEvents.clear();
|
|
541
752
|
};
|
|
753
|
+
/**
|
|
754
|
+
* Runs `run` as one phase: on failure, or a thrown defect, everything the
|
|
755
|
+
* systems in it committed is undone.
|
|
756
|
+
*/
|
|
757
|
+
const runAsPhase = (run) => {
|
|
758
|
+
const opened = {
|
|
759
|
+
tick: world.currentTick(),
|
|
760
|
+
commands: pendingCommands.length,
|
|
761
|
+
origins: pendingOrigins.length,
|
|
762
|
+
resources: new Map(),
|
|
763
|
+
resourceChangedAt: new Map(),
|
|
764
|
+
machines: new Map(),
|
|
765
|
+
readers: new Map()
|
|
766
|
+
};
|
|
767
|
+
phase = opened;
|
|
768
|
+
world.beginPhase();
|
|
769
|
+
let result;
|
|
770
|
+
try {
|
|
771
|
+
result = run();
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
phase = undefined;
|
|
775
|
+
if (result?.ok === true) {
|
|
776
|
+
world.commitPhase();
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
world.rollbackPhase();
|
|
780
|
+
restoreStore(resources, opened.resources);
|
|
781
|
+
for (const [key, value] of opened.machines) {
|
|
782
|
+
if (value === absentValue)
|
|
783
|
+
pendingMachines.delete(key);
|
|
784
|
+
else
|
|
785
|
+
pendingMachines.set(key, value);
|
|
786
|
+
}
|
|
787
|
+
for (const [key, tick] of opened.resourceChangedAt) {
|
|
788
|
+
if (tick === undefined)
|
|
789
|
+
resourceChangedAt.delete(key);
|
|
790
|
+
else
|
|
791
|
+
resourceChangedAt.set(key, tick);
|
|
792
|
+
}
|
|
793
|
+
for (const [reader, [since, lastRun, streamSince, streamLastRun]] of opened.readers) {
|
|
794
|
+
reader.since = since;
|
|
795
|
+
reader.lastRun = lastRun;
|
|
796
|
+
reader.streamSince = streamSince;
|
|
797
|
+
reader.streamLastRun = streamLastRun;
|
|
798
|
+
}
|
|
799
|
+
events.retract(opened.tick);
|
|
800
|
+
pendingCommands.length = opened.commands;
|
|
801
|
+
pendingOrigins.length = opened.origins;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return result;
|
|
805
|
+
};
|
|
542
806
|
const makeResourceWriteView = (descriptor, store, write) => Cells.storeWrite(store, descriptor.key, write, DescriptorModule.constructorOf(descriptor));
|
|
807
|
+
const makeResourceChangedView = (descriptor, reader) => {
|
|
808
|
+
const key = descriptor.key;
|
|
809
|
+
watchedResources.add(key);
|
|
810
|
+
const read = Cells.storeRead(resources, key);
|
|
811
|
+
return {
|
|
812
|
+
get: () => read.get(),
|
|
813
|
+
// `since` is the previous completed run; 0 before the first one.
|
|
814
|
+
changed: () => reader.since === 0 || (resourceChangedAt.get(key) ?? 0) > reader.since
|
|
815
|
+
};
|
|
816
|
+
};
|
|
543
817
|
const makeEventReadView = (descriptorKey, reader) => ({
|
|
544
818
|
all() {
|
|
545
819
|
return events.since(descriptorKey, reader.streamSince);
|
|
@@ -598,15 +872,45 @@ const makeValidatedRuntime = (options) => {
|
|
|
598
872
|
get: () => currentMachines.get(stateMachine.key),
|
|
599
873
|
is: (value) => currentMachines.get(stateMachine.key) === value
|
|
600
874
|
});
|
|
875
|
+
/** Queues a next value; a sub-state whose parent is in another state queues nothing and fails. */
|
|
876
|
+
const queueMachine = (stateMachine, value, skipIfSame) => {
|
|
877
|
+
const within = stateMachine.within;
|
|
878
|
+
if (within !== undefined && !currentMachines.has(stateMachine.key)) {
|
|
879
|
+
return Result.failure({
|
|
880
|
+
_tag: "SubStateInactive",
|
|
881
|
+
machine: stateMachine.name,
|
|
882
|
+
parent: within.machine.name,
|
|
883
|
+
requires: within.state
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
journalMachine(stateMachine.key);
|
|
887
|
+
if (debugEnabled) {
|
|
888
|
+
const previous = pendingMachines.get(stateMachine.key);
|
|
889
|
+
// A set from a transition schedule waits for the next marker: it replaces nothing queued for this one.
|
|
890
|
+
if (previous?.by !== undefined && previous.by !== runningSystem && previous.value !== value && activeTransitions.size === 0) {
|
|
891
|
+
const key = `${stateMachine.name}/${previous.by}/${runningSystem}/${String(previous.value)}/${String(value)}`;
|
|
892
|
+
const seen = nextStateReplacements.get(key);
|
|
893
|
+
nextStateReplacements.set(key, {
|
|
894
|
+
machine: stateMachine.name,
|
|
895
|
+
replaced: { system: previous.by, value: previous.value },
|
|
896
|
+
by: { system: runningSystem, value: value },
|
|
897
|
+
count: (seen?.count ?? 0) + 1
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
pendingMachines.set(stateMachine.key, { value, skipIfSame, by: runningSystem });
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
pendingMachines.set(stateMachine.key, { value, skipIfSame });
|
|
904
|
+
}
|
|
905
|
+
return (within === undefined ? undefined : succeeded);
|
|
906
|
+
};
|
|
601
907
|
const makeNextMachineWriteView = (stateMachine) => ({
|
|
602
908
|
getPending: () => pendingMachines.get(stateMachine.key)?.value,
|
|
603
909
|
set(value) {
|
|
604
|
-
|
|
605
|
-
pendingMachines.set(stateMachine.key, { value, skipIfSame: false });
|
|
910
|
+
return queueMachine(stateMachine, value, false);
|
|
606
911
|
},
|
|
607
912
|
setIfChanged(value) {
|
|
608
|
-
|
|
609
|
-
pendingMachines.set(stateMachine.key, { value, skipIfSame: true });
|
|
913
|
+
return queueMachine(stateMachine, value, true);
|
|
610
914
|
},
|
|
611
915
|
reset() {
|
|
612
916
|
journalMachine(stateMachine.key);
|
|
@@ -654,23 +958,29 @@ const makeValidatedRuntime = (options) => {
|
|
|
654
958
|
const makeContext = (system, reader, queue) => {
|
|
655
959
|
const spec = system.spec;
|
|
656
960
|
const lookup = makeLookup(reader);
|
|
961
|
+
const paused = spec.pauseWhen.length > 0;
|
|
962
|
+
/** On a debug runtime, records reads after a loss and calls of `lagged()`, for the `stream-loss-unread` lint. */
|
|
963
|
+
const watch = (view, stream) => debugEnabled ? watchStreamLoss(view, system.name, stream, paused) : view;
|
|
657
964
|
return {
|
|
658
965
|
local: spec.local?.(),
|
|
966
|
+
// A filtered query hands out the same object; its type hides the lookups.
|
|
659
967
|
queries: mapRecord(spec.queries, (query) => makeQueryHandle(query, reader, lookup)),
|
|
660
968
|
lookup,
|
|
661
969
|
resources: mapRecord(spec.resources, (access) => access.mode === "read"
|
|
662
970
|
? Cells.storeRead(resources, access.descriptor.key)
|
|
663
|
-
:
|
|
971
|
+
: access.mode === "changed"
|
|
972
|
+
? makeResourceChangedView(access.descriptor, reader)
|
|
973
|
+
: makeResourceWriteView(access.descriptor, resources, writeResource)),
|
|
664
974
|
events: mapRecord(spec.events, (access) => access.mode === "read"
|
|
665
|
-
? makeEventReadView(access.descriptor.key, reader)
|
|
975
|
+
? watch(makeEventReadView(access.descriptor.key, reader), `events ${access.descriptor.name}`)
|
|
666
976
|
: makeEventWriteView(access.descriptor.key)),
|
|
667
977
|
machines: mapRecord(spec.machines, (access) => makeMachineReadView(access.machine)),
|
|
668
978
|
nextMachines: mapRecord(spec.nextMachines, (access) => makeNextMachineWriteView(access.machine)),
|
|
669
|
-
transitionEvents: mapRecord(spec.transitionEvents, (access) => makeTransitionEventReadView(access.machine, reader)),
|
|
979
|
+
transitionEvents: mapRecord(spec.transitionEvents, (access) => watch(makeTransitionEventReadView(access.machine, reader), `transition events ${access.machine.name}`)),
|
|
670
980
|
transitions: mapRecord(spec.transitions, (access) => makeTransitionReadView(access.machine)),
|
|
671
|
-
removed: mapRecord(spec.removed, (access) => makeRemovedReadView(access.descriptor, reader)),
|
|
672
|
-
despawned: mapRecord(spec.despawned, () => makeDespawnedReadView(reader)),
|
|
673
|
-
relationFailures: mapRecord(spec.relationFailures, (access) => makeRelationFailureReadView(access.relation, reader)),
|
|
981
|
+
removed: mapRecord(spec.removed, (access) => watch(makeRemovedReadView(access.descriptor, reader), `removed ${access.descriptor.name}`)),
|
|
982
|
+
despawned: mapRecord(spec.despawned, () => watch(makeDespawnedReadView(reader), "despawned")),
|
|
983
|
+
relationFailures: mapRecord(spec.relationFailures, (access) => watch(makeRelationFailureReadView(access.relation, reader), `relation failures ${access.relation.name}`)),
|
|
674
984
|
services: mapRecord(spec.services, (access) => providedServices[access.descriptor.name]),
|
|
675
985
|
// The queue serves every declared permission set; the spec's types narrow it.
|
|
676
986
|
commands: queue
|
|
@@ -833,6 +1143,7 @@ const makeValidatedRuntime = (options) => {
|
|
|
833
1143
|
const skipped = slotOf(system, true);
|
|
834
1144
|
if (tracing)
|
|
835
1145
|
traceSkipped(system, condition, skipped.reader, false);
|
|
1146
|
+
journalReader(skipped.reader);
|
|
836
1147
|
skipped.reader.streamLastRun = world.currentTick();
|
|
837
1148
|
return succeeded;
|
|
838
1149
|
}
|
|
@@ -849,8 +1160,11 @@ const makeValidatedRuntime = (options) => {
|
|
|
849
1160
|
}
|
|
850
1161
|
}
|
|
851
1162
|
const { context, commands, reader } = slotOf(system, true);
|
|
852
|
-
if (debugEnabled)
|
|
1163
|
+
if (debugEnabled) {
|
|
853
1164
|
ranSystems.add(system.name);
|
|
1165
|
+
runningSystem = system.name;
|
|
1166
|
+
}
|
|
1167
|
+
journalReader(reader);
|
|
854
1168
|
// Changes stamped after the previous completed run are visible to this run.
|
|
855
1169
|
reader.since = reader.lastRun;
|
|
856
1170
|
reader.streamSince = reader.streamLastRun;
|
|
@@ -971,6 +1285,39 @@ const makeValidatedRuntime = (options) => {
|
|
|
971
1285
|
activeTransitions.delete(schedule.transition.machine.key);
|
|
972
1286
|
}
|
|
973
1287
|
};
|
|
1288
|
+
/**
|
|
1289
|
+
* Despawns the scopes whose trigger matches a committed transition: leaving
|
|
1290
|
+
* `snapshot.from` or entering `snapshot.to`. It runs right after the
|
|
1291
|
+
* commit, so `onExit` systems still saw the members and entities spawned by
|
|
1292
|
+
* `onEnter` survive. Members are the entities in the scope at that moment:
|
|
1293
|
+
* commands queued by `onExit` systems and not yet applied are not.
|
|
1294
|
+
*/
|
|
1295
|
+
const despawnTriggeredScopes = (machineKey, snapshot) => {
|
|
1296
|
+
const triggers = scopeTriggers.get(machineKey);
|
|
1297
|
+
if (triggers === undefined)
|
|
1298
|
+
return;
|
|
1299
|
+
const applied = [];
|
|
1300
|
+
for (const { trigger, scope } of triggers) {
|
|
1301
|
+
const matches = trigger.phase === "exit" ? trigger.state === snapshot.from : trigger.state === snapshot.to;
|
|
1302
|
+
if (!matches)
|
|
1303
|
+
continue;
|
|
1304
|
+
const origin = `${trigger.phase === "exit" ? "onExit" : "onEnter"}(${trigger.machine.name}=${String(trigger.state)})`;
|
|
1305
|
+
const effects = [];
|
|
1306
|
+
commandEffects = tracing ? effects : undefined;
|
|
1307
|
+
commandOrigin = origin;
|
|
1308
|
+
try {
|
|
1309
|
+
commandWorld.destroyEntityScope(scope);
|
|
1310
|
+
}
|
|
1311
|
+
finally {
|
|
1312
|
+
commandEffects = undefined;
|
|
1313
|
+
}
|
|
1314
|
+
if (tracing)
|
|
1315
|
+
applied.push({ tag: `despawnScope ${scope.name}`, system: origin, effects });
|
|
1316
|
+
}
|
|
1317
|
+
if (applied.length > 0) {
|
|
1318
|
+
emit({ type: "deferred", frame: frameCount, tick: world.currentTick(), schedule: currentSchedule(), marker: "applyStateTransitions", commands: applied });
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
974
1321
|
/**
|
|
975
1322
|
* Applies queued commands, then queued machine transitions, running the
|
|
976
1323
|
* matching exit, transition, and enter schedules from the bundle.
|
|
@@ -983,7 +1330,19 @@ const makeValidatedRuntime = (options) => {
|
|
|
983
1330
|
- (machineDefinitionOrder.get(rightKey) ?? Number.MAX_SAFE_INTEGER));
|
|
984
1331
|
const schedules = bundle?.entries ?? [];
|
|
985
1332
|
for (const [machineKey, pending] of scheduledTransitions) {
|
|
986
|
-
|
|
1333
|
+
if (discardedPending.has(pending)) {
|
|
1334
|
+
// Discarded earlier in this marker: its parent left the state it exists in.
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
// A transition schedule earlier in this marker may have queued a newer
|
|
1338
|
+
// value for this machine: that one waits for the next marker.
|
|
1339
|
+
if (pendingMachines.get(machineKey) === pending)
|
|
1340
|
+
pendingMachines.delete(machineKey);
|
|
1341
|
+
/** Keeps this request queued after a failure, unless a newer one replaced it. */
|
|
1342
|
+
const requeue = () => {
|
|
1343
|
+
if (!pendingMachines.has(machineKey))
|
|
1344
|
+
pendingMachines.set(machineKey, pending);
|
|
1345
|
+
};
|
|
987
1346
|
const current = currentMachines.get(machineKey);
|
|
988
1347
|
if (current === undefined) {
|
|
989
1348
|
continue;
|
|
@@ -993,44 +1352,53 @@ const makeValidatedRuntime = (options) => {
|
|
|
993
1352
|
traceTransition(machineKey, { from: current, to: current }, "unchanged");
|
|
994
1353
|
continue;
|
|
995
1354
|
}
|
|
996
|
-
previousMachines.set(machineKey, current);
|
|
997
1355
|
const snapshot = {
|
|
998
1356
|
from: current,
|
|
999
1357
|
to: pending.value
|
|
1000
1358
|
};
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1359
|
+
// Sub-states that exist in the state being left exit first, deepest
|
|
1360
|
+
// first: their exit schedules run before the parent's, while they
|
|
1361
|
+
// still have their values, and they lose them at the parent's commit.
|
|
1362
|
+
// The exit phase is one transaction: a failure undoes all of it, and
|
|
1363
|
+
// the transition stays queued for a later attempt.
|
|
1364
|
+
const leaving = activeSubStatesIn(machineKey, snapshot.from);
|
|
1365
|
+
const exited = runAsPhase(() => {
|
|
1366
|
+
for (const sub of leaving) {
|
|
1367
|
+
const value = currentMachines.get(sub.key);
|
|
1368
|
+
const result = runPhase(sub.key, "exit", value, { from: value, to: undefined }, schedules);
|
|
1369
|
+
if (!result.ok)
|
|
1010
1370
|
return result;
|
|
1011
|
-
}
|
|
1012
1371
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
if (
|
|
1024
|
-
|
|
1025
|
-
return result;
|
|
1372
|
+
const left = runPhase(machineKey, "exit", snapshot.from, snapshot, schedules);
|
|
1373
|
+
if (!left.ok)
|
|
1374
|
+
return left;
|
|
1375
|
+
for (const schedule of schedules) {
|
|
1376
|
+
const transition = schedule.transition;
|
|
1377
|
+
if (transition.phase === "transition"
|
|
1378
|
+
&& transition.machine.key === machineKey
|
|
1379
|
+
&& transition.from === snapshot.from
|
|
1380
|
+
&& transition.to === snapshot.to) {
|
|
1381
|
+
const result = runTransitionSchedule(schedule, snapshot);
|
|
1382
|
+
if (!result.ok)
|
|
1383
|
+
return result;
|
|
1026
1384
|
}
|
|
1027
1385
|
}
|
|
1386
|
+
return succeeded;
|
|
1387
|
+
});
|
|
1388
|
+
if (!exited.ok) {
|
|
1389
|
+
requeue();
|
|
1390
|
+
if (tracing)
|
|
1391
|
+
traceTransition(machineKey, snapshot, "failed");
|
|
1392
|
+
return exited;
|
|
1028
1393
|
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1394
|
+
for (const sub of leaving)
|
|
1395
|
+
commitMachine(sub.key, currentMachines.get(sub.key), undefined);
|
|
1396
|
+
commitMachine(machineKey, current, pending.value);
|
|
1397
|
+
// Sub-states that exist in the entered state enter their value together
|
|
1398
|
+
// with the parent, parents first, before any onEnter schedule runs: a
|
|
1399
|
+
// failing onEnter cannot leave them without a value, and their scope
|
|
1400
|
+
// triggers fire before what the onEnter schedules spawn.
|
|
1401
|
+
const entered = commitSubStates(machineKey, snapshot.to);
|
|
1034
1402
|
for (const schedule of schedules) {
|
|
1035
1403
|
const transition = schedule.transition;
|
|
1036
1404
|
if (transition.phase === "enter" && transition.machine.key === machineKey && transition.state === snapshot.to) {
|
|
@@ -1042,6 +1410,85 @@ const makeValidatedRuntime = (options) => {
|
|
|
1042
1410
|
}
|
|
1043
1411
|
}
|
|
1044
1412
|
}
|
|
1413
|
+
const enteredSchedules = runSubStateEnters(entered, schedules);
|
|
1414
|
+
if (!enteredSchedules.ok)
|
|
1415
|
+
return enteredSchedules;
|
|
1416
|
+
}
|
|
1417
|
+
return succeeded;
|
|
1418
|
+
};
|
|
1419
|
+
/**
|
|
1420
|
+
* Commits one machine's value (`undefined` for a sub-state that stops
|
|
1421
|
+
* existing): records the change and its transition event, traces it, and
|
|
1422
|
+
* despawns the scopes it triggers.
|
|
1423
|
+
*/
|
|
1424
|
+
const commitMachine = (machineKey, from, to) => {
|
|
1425
|
+
const snapshot = { from, to };
|
|
1426
|
+
if (from !== undefined)
|
|
1427
|
+
previousMachines.set(machineKey, from);
|
|
1428
|
+
if (to === undefined) {
|
|
1429
|
+
currentMachines.delete(machineKey);
|
|
1430
|
+
const discarded = pendingMachines.get(machineKey);
|
|
1431
|
+
if (discarded !== undefined)
|
|
1432
|
+
discardedPending.add(discarded);
|
|
1433
|
+
pendingMachines.delete(machineKey);
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
currentMachines.set(machineKey, to);
|
|
1437
|
+
}
|
|
1438
|
+
changedMachines.add(machineKey);
|
|
1439
|
+
transitionEvents.append(machineKey, world.advanceTick(), [snapshot]);
|
|
1440
|
+
if (tracing)
|
|
1441
|
+
traceTransition(machineKey, snapshot, "applied");
|
|
1442
|
+
despawnTriggeredScopes(machineKey, snapshot);
|
|
1443
|
+
};
|
|
1444
|
+
/** Runs every schedule of `phase` for `machineKey` in `state`, in bundle order. */
|
|
1445
|
+
const runPhase = (machineKey, phase, state, snapshot, schedules) => {
|
|
1446
|
+
for (const schedule of schedules) {
|
|
1447
|
+
const transition = schedule.transition;
|
|
1448
|
+
if (transition.phase === phase && transition.machine.key === machineKey && transition.state === state) {
|
|
1449
|
+
const result = runTransitionSchedule(schedule, snapshot);
|
|
1450
|
+
if (!result.ok)
|
|
1451
|
+
return result;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return succeeded;
|
|
1455
|
+
};
|
|
1456
|
+
/** The sub-states with a value that exist in `state` of `parentKey`, and theirs, deepest first. */
|
|
1457
|
+
const activeSubStatesIn = (parentKey, state) => {
|
|
1458
|
+
const result = [];
|
|
1459
|
+
for (const sub of subStates.get(parentKey) ?? []) {
|
|
1460
|
+
const value = currentMachines.get(sub.key);
|
|
1461
|
+
if (sub.within.state !== state || value === undefined)
|
|
1462
|
+
continue;
|
|
1463
|
+
result.push(...activeSubStatesIn(sub.key, value), sub);
|
|
1464
|
+
}
|
|
1465
|
+
return result;
|
|
1466
|
+
};
|
|
1467
|
+
/**
|
|
1468
|
+
* Gives the sub-states of `parentKey` that exist in `state` their entry
|
|
1469
|
+
* value, and theirs, parents first. Returns what it entered, in that order.
|
|
1470
|
+
*/
|
|
1471
|
+
const commitSubStates = (parentKey, state) => {
|
|
1472
|
+
const entered = [];
|
|
1473
|
+
for (const sub of subStates.get(parentKey) ?? []) {
|
|
1474
|
+
if (sub.within.state !== state || currentMachines.has(sub.key) || !subStateEntry.has(sub.key))
|
|
1475
|
+
continue;
|
|
1476
|
+
const value = subStateEntry.get(sub.key);
|
|
1477
|
+
commitMachine(sub.key, undefined, value);
|
|
1478
|
+
entered.push([sub.key, value], ...commitSubStates(sub.key, value));
|
|
1479
|
+
}
|
|
1480
|
+
return entered;
|
|
1481
|
+
};
|
|
1482
|
+
/** Runs the onEnter schedules of sub-states entered by {@link commitSubStates}, in that order. */
|
|
1483
|
+
const runSubStateEnters = (entered, schedules) => {
|
|
1484
|
+
for (const [key, value] of entered) {
|
|
1485
|
+
const snapshot = { from: undefined, to: value };
|
|
1486
|
+
const result = runPhase(key, "enter", value, snapshot, schedules);
|
|
1487
|
+
if (!result.ok) {
|
|
1488
|
+
if (tracing)
|
|
1489
|
+
traceTransition(key, snapshot, "enterFailed");
|
|
1490
|
+
return result;
|
|
1491
|
+
}
|
|
1045
1492
|
}
|
|
1046
1493
|
return succeeded;
|
|
1047
1494
|
};
|
|
@@ -1076,8 +1523,14 @@ const makeValidatedRuntime = (options) => {
|
|
|
1076
1523
|
}
|
|
1077
1524
|
return succeeded;
|
|
1078
1525
|
}
|
|
1079
|
-
/** Whether
|
|
1526
|
+
/** Whether `runtime.tick` is on the stack: a nested `tick` is a defect. */
|
|
1080
1527
|
let ticking = false;
|
|
1528
|
+
/**
|
|
1529
|
+
* Whether a tick is advancing its frame or running its schedules (in
|
|
1530
|
+
* `tick` and `tryTick`): `send`, `snapshot`, and `restore` are defects
|
|
1531
|
+
* meanwhile. It is cleared before `tick.end` is announced to host listeners.
|
|
1532
|
+
*/
|
|
1533
|
+
let inTick = false;
|
|
1081
1534
|
/**
|
|
1082
1535
|
* Runs one schedule. A failing system stops the schedule; systems that
|
|
1083
1536
|
* already succeeded stay committed, and their pending work stays pending.
|
|
@@ -1099,7 +1552,7 @@ const makeValidatedRuntime = (options) => {
|
|
|
1099
1552
|
/** Events declared as sent by the host (`debug.declareHostEvents`). */
|
|
1100
1553
|
let hostEvents = new Set();
|
|
1101
1554
|
const send = (descriptor, value) => {
|
|
1102
|
-
if (
|
|
1555
|
+
if (inTick)
|
|
1103
1556
|
throw new Error(`runtime.send(${descriptor.name}) was called while a schedule is running: emit from a system instead`);
|
|
1104
1557
|
if (!eventsByKey.has(descriptor.key))
|
|
1105
1558
|
throw new Error(`runtime.send: ${descriptor.name} is not an event of this runtime's schema`);
|
|
@@ -1173,6 +1626,7 @@ const makeValidatedRuntime = (options) => {
|
|
|
1173
1626
|
if (debugEnabled)
|
|
1174
1627
|
reportUnread("next-tick");
|
|
1175
1628
|
ticking = true;
|
|
1629
|
+
inTick = true;
|
|
1176
1630
|
try {
|
|
1177
1631
|
advanceFrame();
|
|
1178
1632
|
return watchResult(observedTick(schedules, () => {
|
|
@@ -1186,6 +1640,7 @@ const makeValidatedRuntime = (options) => {
|
|
|
1186
1640
|
}
|
|
1187
1641
|
finally {
|
|
1188
1642
|
ticking = false;
|
|
1643
|
+
inTick = false;
|
|
1189
1644
|
}
|
|
1190
1645
|
};
|
|
1191
1646
|
/** Runs one tick's schedules, telling `observeHost` listeners when it starts and how it ends. */
|
|
@@ -1198,9 +1653,11 @@ const makeValidatedRuntime = (options) => {
|
|
|
1198
1653
|
result = run();
|
|
1199
1654
|
}
|
|
1200
1655
|
catch (defect) {
|
|
1656
|
+
inTick = false;
|
|
1201
1657
|
emitHost({ type: "tick.end", schedules, frame: frameCount, outcome: "defect", error: defect });
|
|
1202
1658
|
throw defect;
|
|
1203
1659
|
}
|
|
1660
|
+
inTick = false;
|
|
1204
1661
|
emitHost(result.ok
|
|
1205
1662
|
? { type: "tick.end", schedules, frame: frameCount, outcome: "ok" }
|
|
1206
1663
|
: { type: "tick.end", schedules, frame: frameCount, outcome: "failed", error: result.error });
|
|
@@ -1213,7 +1670,8 @@ const makeValidatedRuntime = (options) => {
|
|
|
1213
1670
|
case "resource":
|
|
1214
1671
|
return resources.has(requirement.key);
|
|
1215
1672
|
case "stateMachine":
|
|
1216
|
-
|
|
1673
|
+
// An inactive sub-state has no value but is provided.
|
|
1674
|
+
return providedMachineKeys.has(requirement.key) || currentMachines.has(requirement.key);
|
|
1217
1675
|
}
|
|
1218
1676
|
};
|
|
1219
1677
|
const tryRunSchedule = (schedule) => {
|
|
@@ -1232,8 +1690,16 @@ const makeValidatedRuntime = (options) => {
|
|
|
1232
1690
|
if (issues.length > 0) {
|
|
1233
1691
|
return watchResult(Result.failure({ kind: "TickOrderViolation", issues }), [schedule]);
|
|
1234
1692
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1693
|
+
if (inTick)
|
|
1694
|
+
throw new Error("runtime.tryTick cannot run inside another tick");
|
|
1695
|
+
inTick = true;
|
|
1696
|
+
try {
|
|
1697
|
+
advanceFrame();
|
|
1698
|
+
return watchResult(observedTick([schedule], () => runScheduleUnsafe(schedule)), [schedule]);
|
|
1699
|
+
}
|
|
1700
|
+
finally {
|
|
1701
|
+
inTick = false;
|
|
1702
|
+
}
|
|
1237
1703
|
};
|
|
1238
1704
|
/**
|
|
1239
1705
|
* Evaluates a declared read-only projection without running a schedule.
|
|
@@ -1261,6 +1727,8 @@ const makeValidatedRuntime = (options) => {
|
|
|
1261
1727
|
for (const provision of machineEntries)
|
|
1262
1728
|
machinesByName.set(provision.machine.name, provision.machine);
|
|
1263
1729
|
const snapshot = () => {
|
|
1730
|
+
if (inTick)
|
|
1731
|
+
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");
|
|
1264
1732
|
const savedResources = {};
|
|
1265
1733
|
for (const [name, descriptor] of resourcesByName) {
|
|
1266
1734
|
if (!DescriptorModule.isTransient(descriptor) && resources.has(descriptor.key)) {
|
|
@@ -1281,6 +1749,7 @@ const makeValidatedRuntime = (options) => {
|
|
|
1281
1749
|
}
|
|
1282
1750
|
return {
|
|
1283
1751
|
version: Snapshot.version,
|
|
1752
|
+
gameVersion: options.saves?.latest ?? 0,
|
|
1284
1753
|
nextEntity: world.nextEntityValue(),
|
|
1285
1754
|
entities: world.exportEntities(DescriptorModule.isTransient),
|
|
1286
1755
|
relations: world.exportRelations(),
|
|
@@ -1296,7 +1765,10 @@ const makeValidatedRuntime = (options) => {
|
|
|
1296
1765
|
const parsed = Snapshot.parse(data);
|
|
1297
1766
|
if (!parsed.ok)
|
|
1298
1767
|
return parsed;
|
|
1299
|
-
const
|
|
1768
|
+
const migrated = migrateSave(parsed.value, options.saves);
|
|
1769
|
+
if (!migrated.ok)
|
|
1770
|
+
return migrated;
|
|
1771
|
+
const saved = migrated.value;
|
|
1300
1772
|
// Validate everything before touching the world.
|
|
1301
1773
|
const ids = new Set();
|
|
1302
1774
|
const entities = [];
|
|
@@ -1317,6 +1789,11 @@ const makeValidatedRuntime = (options) => {
|
|
|
1317
1789
|
return Result.failure({ _tag: "InvalidComponent", entityId: entity.id, name, error: value.error });
|
|
1318
1790
|
components.push([descriptor, value.value]);
|
|
1319
1791
|
}
|
|
1792
|
+
// Transient components are never saved, so a requirement on one is not checked here.
|
|
1793
|
+
const missing = missingIn(components, DescriptorModule.isTransient);
|
|
1794
|
+
if (missing !== undefined) {
|
|
1795
|
+
return Result.failure({ _tag: "MissingRequiredComponent", entityId: entity.id, name: missing[1], requiredBy: missing[0] });
|
|
1796
|
+
}
|
|
1320
1797
|
entities.push({ id: entity.id, components });
|
|
1321
1798
|
}
|
|
1322
1799
|
const validatedResources = [];
|
|
@@ -1358,18 +1835,40 @@ const makeValidatedRuntime = (options) => {
|
|
|
1358
1835
|
}
|
|
1359
1836
|
}
|
|
1360
1837
|
}
|
|
1361
|
-
const
|
|
1838
|
+
const restoredMachines = new Map(currentMachines);
|
|
1362
1839
|
for (const [name, value] of Object.entries(saved.machines)) {
|
|
1363
1840
|
const machine = machinesByName.get(name);
|
|
1364
1841
|
if (!machine)
|
|
1365
1842
|
return Result.failure({ _tag: "UnknownMachine", name });
|
|
1366
1843
|
if (!machine.values.includes(value))
|
|
1367
1844
|
return Result.failure({ _tag: "InvalidMachineState", name, value });
|
|
1368
|
-
|
|
1845
|
+
restoredMachines.set(machine.key, value);
|
|
1846
|
+
}
|
|
1847
|
+
// A save has no value for a sub-state whose parent is elsewhere; one that
|
|
1848
|
+
// has a value must have its parent in the state it exists in. A sub-state
|
|
1849
|
+
// missing while its parent is there enters its entry value.
|
|
1850
|
+
for (const machine of options.machineDefinitions) {
|
|
1851
|
+
const within = machine.within;
|
|
1852
|
+
if (within === undefined)
|
|
1853
|
+
continue;
|
|
1854
|
+
const active = restoredMachines.get(within.machine.key) === within.state;
|
|
1855
|
+
if (Object.hasOwn(saved.machines, machine.name)) {
|
|
1856
|
+
if (!active) {
|
|
1857
|
+
return Result.failure({ _tag: "InactiveSubState", name: machine.name, parent: within.machine.name, requires: within.state });
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
else if (active && subStateEntry.has(machine.key)) {
|
|
1861
|
+
restoredMachines.set(machine.key, subStateEntry.get(machine.key));
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
restoredMachines.delete(machine.key);
|
|
1865
|
+
}
|
|
1369
1866
|
}
|
|
1370
1867
|
const scoped = new Set();
|
|
1371
1868
|
const memberships = [];
|
|
1372
1869
|
for (const [name, members] of Object.entries(saved.scopes)) {
|
|
1870
|
+
if (!options.scopeDefinitions.some((scope) => scope.name === name))
|
|
1871
|
+
return Result.failure({ _tag: "UnknownScope", name });
|
|
1373
1872
|
for (const id of members) {
|
|
1374
1873
|
if (!ids.has(id) || scoped.has(id))
|
|
1375
1874
|
return Result.failure({ _tag: "InvalidScope", name, entityId: id });
|
|
@@ -1397,15 +1896,20 @@ const makeValidatedRuntime = (options) => {
|
|
|
1397
1896
|
}
|
|
1398
1897
|
for (const [id, key] of memberships)
|
|
1399
1898
|
world.assignEntityScope(id, key);
|
|
1400
|
-
for (const [key, value] of validatedResources)
|
|
1899
|
+
for (const [key, value] of validatedResources) {
|
|
1401
1900
|
resources.set(key, value);
|
|
1402
|
-
|
|
1901
|
+
resourceChangedAt.set(key, world.currentTick());
|
|
1902
|
+
}
|
|
1903
|
+
currentMachines.clear();
|
|
1904
|
+
for (const [key, value] of restoredMachines)
|
|
1403
1905
|
currentMachines.set(key, value);
|
|
1404
1906
|
// Locals are not saved: each system starts over from its factory.
|
|
1405
1907
|
localSlots.forEach(({ slot }, index) => { slot.context.local = rebuiltLocals[index]; });
|
|
1406
1908
|
return succeeded;
|
|
1407
1909
|
};
|
|
1408
1910
|
const tracedRestore = (data) => {
|
|
1911
|
+
if (inTick)
|
|
1912
|
+
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");
|
|
1409
1913
|
const result = restore(data);
|
|
1410
1914
|
if (tracing)
|
|
1411
1915
|
emit({ type: "restore", frame: frameCount, tick: world.currentTick(), ok: result.ok });
|
|
@@ -1546,11 +2050,17 @@ const makeValidatedRuntime = (options) => {
|
|
|
1546
2050
|
schema: options.schema,
|
|
1547
2051
|
machines: [...new Set(machinesByName.values())],
|
|
1548
2052
|
currentMachine: (machine) => currentMachines.get(machine.key),
|
|
2053
|
+
providesMachine: (machine) => providedMachineKeys.has(machine.key),
|
|
2054
|
+
scopes: options.scopeDefinitions,
|
|
1549
2055
|
providedServices: Object.keys(providedServices),
|
|
1550
2056
|
externallyWrittenEvents: new Set([...externallyWrittenEvents, ...hostEvents]),
|
|
1551
2057
|
relationMutations,
|
|
1552
2058
|
commandUses,
|
|
1553
2059
|
ranSystems,
|
|
2060
|
+
requirementViolations: [...requirementViolations.values()],
|
|
2061
|
+
nextStateReplacements: [...nextStateReplacements.values()],
|
|
2062
|
+
staleTargets: [...staleTargets.values()],
|
|
2063
|
+
streamLosses: [...streamLosses.values()],
|
|
1554
2064
|
hasResource: (descriptor) => resources.has(descriptor.key),
|
|
1555
2065
|
schedules: [...scheduleNames].map(([schedule, name]) => [name, schedule])
|
|
1556
2066
|
}),
|
|
@@ -1590,8 +2100,9 @@ const makeValidatedRuntime = (options) => {
|
|
|
1590
2100
|
* Creates a runtime for a fully built schema and a set of external services.
|
|
1591
2101
|
*
|
|
1592
2102
|
* Resources owned by constructed descriptors take raw input and are validated
|
|
1593
|
-
* here
|
|
1594
|
-
*
|
|
2103
|
+
* here. The runtime always comes back as a `Result`: typed `Success` when
|
|
2104
|
+
* nothing provided can fail (read `.value`), and a `Result` to check when a
|
|
2105
|
+
* constructed resource is provided.
|
|
1595
2106
|
*
|
|
1596
2107
|
* The runtime does not own the outer frame loop. It only owns ECS state plus
|
|
1597
2108
|
* the host-provided services that systems are allowed to depend on.
|
|
@@ -1604,13 +2115,10 @@ const makeValidatedRuntime = (options) => {
|
|
|
1604
2115
|
* ),
|
|
1605
2116
|
* resources: { Score: 0 },
|
|
1606
2117
|
* machines: Game.Runtime.machines(Game.Runtime.machine(Flow, "Menu"))
|
|
1607
|
-
* })
|
|
2118
|
+
* }).value
|
|
1608
2119
|
* ```
|
|
1609
2120
|
*/
|
|
1610
2121
|
export const make = (options) => {
|
|
1611
|
-
const fallible = Object.entries(options.schema.resources).some(([key, descriptor]) => options.resources !== undefined
|
|
1612
|
-
&& options.resources[key] !== undefined
|
|
1613
|
-
&& DescriptorModule.hasConstructor(descriptor));
|
|
1614
2122
|
const validated = collectConstructedRegistryValues(options.schema.resources, options.resources);
|
|
1615
2123
|
if (!validated.ok) {
|
|
1616
2124
|
return Result.failure({ resources: validated.error });
|
|
@@ -1621,9 +2129,19 @@ export const make = (options) => {
|
|
|
1621
2129
|
resources: validated.value,
|
|
1622
2130
|
machines: options.machines,
|
|
1623
2131
|
machineDefinitions: options.machineDefinitions ?? [],
|
|
1624
|
-
|
|
2132
|
+
scopeDefinitions: options.scopeDefinitions ?? [],
|
|
2133
|
+
debug: options.debug,
|
|
2134
|
+
saves: options.saves
|
|
1625
2135
|
});
|
|
1626
|
-
return
|
|
2136
|
+
return Result.success(runtime);
|
|
2137
|
+
};
|
|
2138
|
+
const rejectRepeated = (names, kind) => {
|
|
2139
|
+
const seen = new Set();
|
|
2140
|
+
for (const name of names) {
|
|
2141
|
+
if (seen.has(name))
|
|
2142
|
+
throw new Error(`${name} is provided twice: keep one Game.Runtime.${kind}(...) for it`);
|
|
2143
|
+
seen.add(name);
|
|
2144
|
+
}
|
|
1627
2145
|
};
|
|
1628
2146
|
/**
|
|
1629
2147
|
* Builds the descriptor-backed runtime service environment.
|
|
@@ -1634,6 +2152,8 @@ export const make = (options) => {
|
|
|
1634
2152
|
*
|
|
1635
2153
|
* The helper keeps the runtime map keyed by service descriptors instead of
|
|
1636
2154
|
* ad hoc strings, so the provision site cannot drift from the declaration site.
|
|
2155
|
+
* Each service is provided once: a second provision of the same one does not
|
|
2156
|
+
* compile.
|
|
1637
2157
|
*
|
|
1638
2158
|
* @example
|
|
1639
2159
|
* ```ts
|
|
@@ -1645,6 +2165,7 @@ export const make = (options) => {
|
|
|
1645
2165
|
* ```
|
|
1646
2166
|
*/
|
|
1647
2167
|
export const services = (...entries) => {
|
|
2168
|
+
rejectRepeated(entries.map((entry) => entry.descriptor.name), "service");
|
|
1648
2169
|
const provided = {};
|
|
1649
2170
|
for (const { descriptor, implementation } of entries) {
|
|
1650
2171
|
provided[descriptor.name] = implementation;
|
|
@@ -1667,9 +2188,12 @@ export const service = (descriptor, implementation) => ({
|
|
|
1667
2188
|
* Builds the machine initialization environment from machine definitions.
|
|
1668
2189
|
*
|
|
1669
2190
|
* Use this when gameplay phases need a known committed starting state before
|
|
1670
|
-
* any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`.
|
|
2191
|
+
* any schedule runs, for example `"Boot"`, `"Menu"`, or `"Playing"`. Each
|
|
2192
|
+
* machine is provided once: a second provision of the same one does not
|
|
2193
|
+
* compile.
|
|
1671
2194
|
*/
|
|
1672
2195
|
export const machines = (...entries) => {
|
|
2196
|
+
rejectRepeated(entries.map((entry) => entry.machine.name), "machine");
|
|
1673
2197
|
const provided = {};
|
|
1674
2198
|
for (const { machine, initial } of entries) {
|
|
1675
2199
|
provided[machine.name] = initial;
|