@storylet-studio/runtime 0.7.0 → 0.8.1
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 +10 -0
- package/dist/index.cjs +689 -57
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +117 -19
- package/dist/index.d.ts +117 -19
- package/dist/index.js +689 -57
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -257,6 +257,12 @@ function walk(node, want, evalTruthy, countingCalls) {
|
|
|
257
257
|
return evalTruthy(node) === want ? 1 : 0;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
// ../dialect/src/engine-scopes.ts
|
|
261
|
+
var ENGINE_SCOPES = [
|
|
262
|
+
{ token: "patter", engine: "Patterplay", means: "Patter's shared globals" },
|
|
263
|
+
{ token: "story", engine: "Storylet Engine", means: "the Storylet Engine's shared @story properties" }
|
|
264
|
+
];
|
|
265
|
+
|
|
260
266
|
// ../dialect/src/index.ts
|
|
261
267
|
var NEVER_PLAYED = 9999;
|
|
262
268
|
var host = (h) => h.ctx.host ?? {};
|
|
@@ -276,16 +282,17 @@ var flagsArg = (fn, args, h) => {
|
|
|
276
282
|
if (v === false) return [];
|
|
277
283
|
throw new EvalError(`${fn}() first argument must be a flags property`);
|
|
278
284
|
};
|
|
285
|
+
var OWN_SCOPES = ["story", "world", "box", "deck", "hand"];
|
|
286
|
+
var EXTERNAL_SCOPES = ENGINE_SCOPES.map((s) => s.token).filter((t) => !OWN_SCOPES.includes(t));
|
|
279
287
|
var storyletsDialect = {
|
|
280
288
|
// A missing property in a PRESENT scope is always an error: every property
|
|
281
289
|
// is declared with a default, so absence means a publish bug, a drifted
|
|
282
|
-
// save, or a foreign scope the host never fed (schema 6.2).
|
|
290
|
+
// save, or a foreign scope the host never fed (schema 6.2). The same holds
|
|
291
|
+
// for another engine's scope: `@patter.glod` is a typo, and it says so the
|
|
292
|
+
// first time the card is evaluated rather than quietly reading false.
|
|
283
293
|
scopes: [
|
|
284
|
-
{ token
|
|
285
|
-
{ token
|
|
286
|
-
{ token: "box", missing: "throw" },
|
|
287
|
-
{ token: "deck", missing: "throw" },
|
|
288
|
-
{ token: "hand", missing: "throw" }
|
|
294
|
+
...OWN_SCOPES.map((token) => ({ token, missing: "throw" })),
|
|
295
|
+
...EXTERNAL_SCOPES.map((token) => ({ token, missing: "throw" }))
|
|
289
296
|
],
|
|
290
297
|
defaultScope: "story",
|
|
291
298
|
functions: {
|
|
@@ -450,6 +457,8 @@ var parseHoleRef = (value) => {
|
|
|
450
457
|
const m = HOLE_REF.exec(value);
|
|
451
458
|
return m === null ? void 0 : { scope: m[1], name: m[2] };
|
|
452
459
|
};
|
|
460
|
+
var SAVE_SCHEMA = "storylets/save@2";
|
|
461
|
+
var SAVE_SCHEMA_V1 = "storylets/save@1";
|
|
453
462
|
|
|
454
463
|
// ../../../expr/packages/scoperegistry/src/index.ts
|
|
455
464
|
var PropertyBag = class _PropertyBag {
|
|
@@ -482,6 +491,13 @@ var PropertyBag = class _PropertyBag {
|
|
|
482
491
|
get(name) {
|
|
483
492
|
return this.values[this.norm(name)];
|
|
484
493
|
}
|
|
494
|
+
/** A name as this bag keys it: its normalisation policy applied. The registry
|
|
495
|
+
* uses it to key quality ladders and the validation schema the bag's own way,
|
|
496
|
+
* so a case-significant (identity) bag is not quietly folded to lower case
|
|
497
|
+
* one layer up. */
|
|
498
|
+
normalise(name) {
|
|
499
|
+
return this.norm(name);
|
|
500
|
+
}
|
|
485
501
|
/** Write a property. Engine writes (the default) notify subscribers;
|
|
486
502
|
* pass `silent: true` for a host write, which reaches only the audit
|
|
487
503
|
* hook. Throws on a read-only property unless the caller says it is the
|
|
@@ -565,6 +581,328 @@ function rowFor(d, value, writable, name, pathPrefix = "") {
|
|
|
565
581
|
writable: writable ?? d.writable ?? true
|
|
566
582
|
};
|
|
567
583
|
}
|
|
584
|
+
var lowerCase = (name) => name.toLowerCase();
|
|
585
|
+
var SAVE_FRAGMENT_VERSION = 1;
|
|
586
|
+
var ScopeRegistry = class {
|
|
587
|
+
scopes = /* @__PURE__ */ new Map();
|
|
588
|
+
/** Values loaded for keys nobody has registered yet, waiting to be claimed. */
|
|
589
|
+
parked = /* @__PURE__ */ new Map();
|
|
590
|
+
rev = 0;
|
|
591
|
+
/**
|
|
592
|
+
* A counter that moves whenever a scope is registered or removed, and at no
|
|
593
|
+
* other time: it starts at 0 and each registration or removal adds 1. Values
|
|
594
|
+
* changing does not move it. A caller that caches a context built by
|
|
595
|
+
* `toEvalContext()` rebuilds it when this moves, because the context's set of
|
|
596
|
+
* scopes is fixed when it is built while the values it reads stay live.
|
|
597
|
+
*/
|
|
598
|
+
get revision() {
|
|
599
|
+
return this.rev;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Register a scope this registry **owns and stores**. Its bag is seeded from
|
|
603
|
+
* each declaration's `default` (or a type default). Owned scopes are
|
|
604
|
+
* type-checked (declarations) and serialized by `save`/`load`.
|
|
605
|
+
*
|
|
606
|
+
* The third argument may be the path prefix alone (the pre-0.7 form) or an
|
|
607
|
+
* options object.
|
|
608
|
+
*/
|
|
609
|
+
defineOwned(token, declarations, opts) {
|
|
610
|
+
const o = typeof opts === "string" ? { pathPrefix: opts } : opts ?? {};
|
|
611
|
+
const bag = new PropertyBag(declarations, {
|
|
612
|
+
pathPrefix: o.pathPrefix ?? `${token}.`,
|
|
613
|
+
...o.normalise ? { normalise: o.normalise } : {}
|
|
614
|
+
});
|
|
615
|
+
return this.mountOwned(token, bag, o.owner !== void 0 ? { owner: o.owner } : void 0);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Attach an EXISTING bag as an owned scope: an engine (or a host) holds the
|
|
619
|
+
* bag and this registry reads, writes, lists and saves it like its own.
|
|
620
|
+
*
|
|
621
|
+
* If values were loaded for this key before anyone registered it, the bag
|
|
622
|
+
* claims them now: laid over its seeded defaults by the bag's own `load` rule.
|
|
623
|
+
*/
|
|
624
|
+
mountOwned(token, bag, opts) {
|
|
625
|
+
this.assertFree(token, opts?.owner);
|
|
626
|
+
this.scopes.set(token, { kind: "owned", bag, ...opts?.owner !== void 0 ? { owner: opts.owner } : {} });
|
|
627
|
+
this.rev++;
|
|
628
|
+
const waiting = this.parked.get(token);
|
|
629
|
+
if (waiting) {
|
|
630
|
+
bag.load(waiting);
|
|
631
|
+
this.parked.delete(token);
|
|
632
|
+
}
|
|
633
|
+
return this;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Unregister a scope. With `{ keep: true }` an owned scope's values are parked
|
|
637
|
+
* and handed back when the same key is next registered, which is how a live
|
|
638
|
+
* reload hands an engine's state to its replacement. Throws on an unknown key.
|
|
639
|
+
*/
|
|
640
|
+
remove(token, opts) {
|
|
641
|
+
const e = this.scopes.get(token);
|
|
642
|
+
if (!e) throw new Error(`unknown scope '@${token}'`);
|
|
643
|
+
if (opts?.keep && e.kind === "owned") this.parked.set(token, e.bag.save());
|
|
644
|
+
this.scopes.delete(token);
|
|
645
|
+
this.rev++;
|
|
646
|
+
return this;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Drop parked values nobody claimed. Parked values are kept in the next save by
|
|
650
|
+
* default, so nothing loaded is lost to a flow or deck that simply has not
|
|
651
|
+
* reopened yet; a game that knows they are dead drops them here.
|
|
652
|
+
*
|
|
653
|
+
* With a `prefix`, only keys starting with it are dropped: an engine resetting
|
|
654
|
+
* itself drops its own instance keys (`my-engine/`) and leaves every other
|
|
655
|
+
* engine's alone.
|
|
656
|
+
*/
|
|
657
|
+
discardParked(prefix) {
|
|
658
|
+
if (prefix === void 0) this.parked.clear();
|
|
659
|
+
else for (const key of [...this.parked.keys()]) if (key.startsWith(prefix)) this.parked.delete(key);
|
|
660
|
+
return this;
|
|
661
|
+
}
|
|
662
|
+
/** An owned scope's bag (subscribe, audit, rows live there). */
|
|
663
|
+
ownedBag(token) {
|
|
664
|
+
const e = this.scopes.get(token);
|
|
665
|
+
if (!e || e.kind !== "owned") throw new Error(`'@${token}' is not an owned scope`);
|
|
666
|
+
return e.bag;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Re-initialise an existing **owned** scope's bag from new declarations,
|
|
670
|
+
* clearing its current values. For scope-local state that resets on a context
|
|
671
|
+
* change (e.g. entering a new scene / site / deck) without disturbing other
|
|
672
|
+
* scopes. Mutates the bag in place, so an `EvalContext` already built from this
|
|
673
|
+
* registry stays valid.
|
|
674
|
+
*/
|
|
675
|
+
reseedOwned(token, declarations) {
|
|
676
|
+
this.ownedBag(token).reseed(declarations);
|
|
677
|
+
return this;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Register a **foreign** scope backed by a host `{ get, set? }` resolver. The
|
|
681
|
+
* values live in the host/other engine and are never stored or saved here.
|
|
682
|
+
* `declarations` (optional, e.g. imported from a `scopeRegistrySpec`) are used
|
|
683
|
+
* only for validation; omit them for an opaque scope.
|
|
684
|
+
*/
|
|
685
|
+
defineForeign(token, resolver, declarations = [], opts = true) {
|
|
686
|
+
const o = typeof opts === "boolean" ? { writable: opts } : opts;
|
|
687
|
+
this.assertFree(token, o.owner);
|
|
688
|
+
const norm = o.normalise ?? lowerCase;
|
|
689
|
+
const decls = /* @__PURE__ */ new Map();
|
|
690
|
+
for (const d of declarations) decls.set(norm(d.name), d);
|
|
691
|
+
this.scopes.set(token, {
|
|
692
|
+
kind: "foreign",
|
|
693
|
+
resolver,
|
|
694
|
+
decls,
|
|
695
|
+
scopeWritable: o.writable ?? true,
|
|
696
|
+
norm,
|
|
697
|
+
...o.owner !== void 0 ? { owner: o.owner } : {}
|
|
698
|
+
});
|
|
699
|
+
this.rev++;
|
|
700
|
+
return this;
|
|
701
|
+
}
|
|
702
|
+
has(token) {
|
|
703
|
+
return this.scopes.has(token);
|
|
704
|
+
}
|
|
705
|
+
/** Read a property; undefined if the scope or property is not present. */
|
|
706
|
+
get(scope, name) {
|
|
707
|
+
const e = this.scopes.get(scope);
|
|
708
|
+
if (!e) return void 0;
|
|
709
|
+
return e.kind === "owned" ? e.bag.get(name) : e.resolver.get(e.norm(name));
|
|
710
|
+
}
|
|
711
|
+
/** Write a property (an ENGINE write: the bag's subscribers fire; use
|
|
712
|
+
* the bag directly for silent host writes). Throws on an unknown scope.
|
|
713
|
+
*
|
|
714
|
+
* `writable: false` is the STORY's promise, so a story write is refused and
|
|
715
|
+
* a HOST write is not: pass `{ host: true }` from a host's own surface (its
|
|
716
|
+
* `setProperty`, its tooling, a coverage driver) and never from the path an
|
|
717
|
+
* outcome or effect takes. A foreign scope whose resolver has no `set` is
|
|
718
|
+
* refused for everyone, host included - that is not a rule to bypass, it is
|
|
719
|
+
* a game that gave no way to write. */
|
|
720
|
+
set(scope, name, value, opts) {
|
|
721
|
+
const e = this.scopes.get(scope);
|
|
722
|
+
if (!e) throw new Error(`unknown scope '@${scope}'`);
|
|
723
|
+
if (e.kind === "owned") {
|
|
724
|
+
try {
|
|
725
|
+
e.bag.set(name, value, opts?.host ? { host: true } : void 0);
|
|
726
|
+
} catch {
|
|
727
|
+
throw new Error(`'@${scope}.${name}' is read-only`);
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const n = e.norm(name);
|
|
732
|
+
if (!e.resolver.set) throw new Error(`'@${scope}.${name}' is read-only`);
|
|
733
|
+
if (!opts?.host && !this.foreignWritable(e, n)) throw new Error(`'@${scope}.${name}' is read-only`);
|
|
734
|
+
e.resolver.set(n, value);
|
|
735
|
+
}
|
|
736
|
+
foreignWritable(e, name) {
|
|
737
|
+
if (!e.resolver.set) return false;
|
|
738
|
+
return e.decls.get(name)?.writable ?? e.scopeWritable;
|
|
739
|
+
}
|
|
740
|
+
/** Examiner rows across every scope with a declared surface: owned bags
|
|
741
|
+
* first, then declared foreign scopes (values read through, writability
|
|
742
|
+
* reflecting the resolver). Opaque foreign scopes are not listed. */
|
|
743
|
+
listProperties() {
|
|
744
|
+
const out = [];
|
|
745
|
+
for (const [token, e] of this.scopes) {
|
|
746
|
+
const owner = e.owner !== void 0 ? { owner: e.owner } : {};
|
|
747
|
+
if (e.kind === "owned") {
|
|
748
|
+
for (const row of e.bag.rows()) out.push({ scope: token, ...owner, ...row });
|
|
749
|
+
} else {
|
|
750
|
+
for (const [n, d] of e.decls) {
|
|
751
|
+
out.push({
|
|
752
|
+
scope: token,
|
|
753
|
+
...owner,
|
|
754
|
+
...rowFor(d, e.resolver.get(n), this.foreignWritable(e, n), n, `${token}.`)
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Build the `EvalContext` expr's `evaluate` consumes: owned scopes as static
|
|
763
|
+
* bags, foreign scopes as their resolvers. `host` carries dialect-function
|
|
764
|
+
* callbacks (PRNG, tag lookups) and is passed through untouched.
|
|
765
|
+
*/
|
|
766
|
+
toEvalContext(host2, opts) {
|
|
767
|
+
const view = this.view(opts?.aliases);
|
|
768
|
+
const scopes = {};
|
|
769
|
+
for (const [token, e] of view) scopes[token] = e.kind === "owned" ? e.bag.values : e.resolver;
|
|
770
|
+
const qualities = this.qualityLadders(view);
|
|
771
|
+
return qualities.size === 0 ? { scopes, host: host2 } : {
|
|
772
|
+
scopes,
|
|
773
|
+
host: host2,
|
|
774
|
+
qualities: (scope, name) => {
|
|
775
|
+
const e = view.get(scope);
|
|
776
|
+
return e ? qualities.get(scope)?.get(normOf(e)(name)) : void 0;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* The scopes an expression sees: every registered key under its own token,
|
|
782
|
+
* then each alias token pointing at its key's entry (an alias shadows a key of
|
|
783
|
+
* the same name). Keys an engine uses for instance bags (`engine/flow-2/...`)
|
|
784
|
+
* are not valid expression tokens, so they are present but unreachable.
|
|
785
|
+
*/
|
|
786
|
+
view(aliases) {
|
|
787
|
+
const out = new Map(this.scopes);
|
|
788
|
+
for (const [token, key] of Object.entries(aliases ?? {})) {
|
|
789
|
+
const e = this.scopes.get(key);
|
|
790
|
+
if (!e) throw new Error(`alias '@${token}' names '${key}', which is not registered`);
|
|
791
|
+
out.set(token, e);
|
|
792
|
+
}
|
|
793
|
+
return out;
|
|
794
|
+
}
|
|
795
|
+
/** Every quality declaration's ladder, keyed scope token then name (the
|
|
796
|
+
* scope's own normalisation). */
|
|
797
|
+
qualityLadders(view) {
|
|
798
|
+
const out = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const [token, e] of view) {
|
|
800
|
+
for (const [n, d] of declsOf(e)) {
|
|
801
|
+
if (d.type !== "quality" || d.stages === void 0) continue;
|
|
802
|
+
let m = out.get(token);
|
|
803
|
+
if (!m) {
|
|
804
|
+
m = /* @__PURE__ */ new Map();
|
|
805
|
+
out.set(token, m);
|
|
806
|
+
}
|
|
807
|
+
m.set(n, d.stages);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return out;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Build the `ExpressionSchema` expr's validator consumes. Scopes with no
|
|
814
|
+
* declarations are **omitted** (opaque - references into them are not flagged);
|
|
815
|
+
* declared scopes contribute their property types for validation. Aliases
|
|
816
|
+
* apply as they do to `toEvalContext`, so a condition written against `@scene`
|
|
817
|
+
* validates against the instance bag the engine names.
|
|
818
|
+
*/
|
|
819
|
+
toSchema(opts) {
|
|
820
|
+
const properties = /* @__PURE__ */ new Map();
|
|
821
|
+
for (const [token, e] of this.view(opts?.aliases)) {
|
|
822
|
+
const decls = declsOf(e);
|
|
823
|
+
if (decls.length === 0) continue;
|
|
824
|
+
const m = /* @__PURE__ */ new Map();
|
|
825
|
+
for (const [n, d] of decls) m.set(n, {
|
|
826
|
+
type: d.type,
|
|
827
|
+
enumValues: d.values,
|
|
828
|
+
...d.stages !== void 0 ? { stages: d.stages } : {}
|
|
829
|
+
});
|
|
830
|
+
properties.set(token, m);
|
|
831
|
+
}
|
|
832
|
+
return { properties };
|
|
833
|
+
}
|
|
834
|
+
/** Serialize **owned** scopes (foreign scopes are the game's, and the game
|
|
835
|
+
* saves them), as bare bags keyed by token, plus any values still parked, so
|
|
836
|
+
* a save taken before every engine has re-registered loses nothing. The
|
|
837
|
+
* registry knows nothing about game saves: a game embeds this in its own. */
|
|
838
|
+
save() {
|
|
839
|
+
const out = {};
|
|
840
|
+
for (const [token, e] of this.scopes) if (e.kind === "owned") out[token] = e.bag.save();
|
|
841
|
+
for (const [token, vals] of this.parked) out[token] = structuredClone(vals);
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Restore from a `save` blob. An owned scope lays its section over its current
|
|
846
|
+
* values (the bag's `load` rule). A section for a key nobody has registered
|
|
847
|
+
* yet is PARKED and handed over when that key registers, so a game can load
|
|
848
|
+
* its registry before its engines have reopened their flows or decks. A
|
|
849
|
+
* section for a foreign scope is ignored: those values are the game's.
|
|
850
|
+
*
|
|
851
|
+
* A load replaces whatever was parked before it: it is a whole restore, and
|
|
852
|
+
* residue from an earlier load must not leak into this one.
|
|
853
|
+
*
|
|
854
|
+
* Changed in 0.7.0: sections for unregistered keys used to be dropped.
|
|
855
|
+
*/
|
|
856
|
+
load(blob, opts) {
|
|
857
|
+
if (!opts?.keepParked) this.parked.clear();
|
|
858
|
+
for (const [token, vals] of Object.entries(blob)) {
|
|
859
|
+
const e = this.scopes.get(token);
|
|
860
|
+
if (e?.kind === "owned") e.bag.load(vals);
|
|
861
|
+
else if (!e) this.parked.set(token, structuredClone(vals));
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* `save()` wrapped with a version stamp.
|
|
866
|
+
*
|
|
867
|
+
* @deprecated Versioning belongs to the save that embeds the values; no
|
|
868
|
+
* engine ever called this. Embed `save()` in your own versioned save.
|
|
869
|
+
* Removed at the next breaking release.
|
|
870
|
+
*/
|
|
871
|
+
saveFragment() {
|
|
872
|
+
return { version: SAVE_FRAGMENT_VERSION, scopes: this.save() };
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Restore from a versioned fragment; an unsupported version throws.
|
|
876
|
+
*
|
|
877
|
+
* @deprecated See `saveFragment`. Removed at the next breaking release.
|
|
878
|
+
*/
|
|
879
|
+
loadFragment(fragment) {
|
|
880
|
+
if (fragment.version !== SAVE_FRAGMENT_VERSION) {
|
|
881
|
+
throw new Error(`unsupported owned-state fragment version ${fragment.version} (supported: ${SAVE_FRAGMENT_VERSION})`);
|
|
882
|
+
}
|
|
883
|
+
this.load(fragment.scopes);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* A token is taken once. There is no reserved-token list: a clash surfaces
|
|
887
|
+
* here, the moment a game combines its engines, which is the only moment
|
|
888
|
+
* anyone knows which engines are present. With owners recorded the error says
|
|
889
|
+
* whose token it already is.
|
|
890
|
+
*/
|
|
891
|
+
assertFree(token, owner) {
|
|
892
|
+
const e = this.scopes.get(token);
|
|
893
|
+
if (!e) return;
|
|
894
|
+
const by = e.owner !== void 0 ? ` by ${e.owner}` : "";
|
|
895
|
+
const wants = owner !== void 0 ? ` (wanted by ${owner})` : "";
|
|
896
|
+
throw new Error(`scope '@${token}' is already registered${by}${wants}`);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
function declsOf(e) {
|
|
900
|
+
if (e.kind === "foreign") return [...e.decls.entries()];
|
|
901
|
+
return e.bag.declarations().map((d) => [e.bag.normalise(d.name), d]);
|
|
902
|
+
}
|
|
903
|
+
function normOf(e) {
|
|
904
|
+
return e.kind === "foreign" ? e.norm : (n) => e.bag.normalise(n);
|
|
905
|
+
}
|
|
568
906
|
function defaultFor(d) {
|
|
569
907
|
if (d.default !== void 0) return d.default;
|
|
570
908
|
switch (d.type) {
|
|
@@ -595,6 +933,48 @@ function defaultFor(d) {
|
|
|
595
933
|
var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
|
|
596
934
|
var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
|
|
597
935
|
var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
|
|
936
|
+
var OWNER = "Storylet Engine";
|
|
937
|
+
var identity = (n) => n;
|
|
938
|
+
var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
|
|
939
|
+
var unesc = (id) => id.replace(/%2F/g, "/").replace(/%25/g, "%");
|
|
940
|
+
var sharedKey = (kind, id) => `storylets/${kind}/${esc(id)}`;
|
|
941
|
+
var flowPrefix = (flowId) => `storylets/flow/${esc(flowId)}/`;
|
|
942
|
+
var flowKey = (flowId, kind, id) => kind === "story" ? `${flowPrefix(flowId)}story` : `${flowPrefix(flowId)}${kind}/${esc(id)}`;
|
|
943
|
+
var emptyPartitionValues = () => ({ story: {}, box: {}, deck: {}, hand: {}, value: {} });
|
|
944
|
+
function partitionsFromSections(sections, flowIds) {
|
|
945
|
+
const shared = emptyPartitionValues();
|
|
946
|
+
const flows = /* @__PURE__ */ new Map();
|
|
947
|
+
const rest = {};
|
|
948
|
+
const flowOf = (escaped) => {
|
|
949
|
+
const id = unesc(escaped);
|
|
950
|
+
if (!flowIds.has(id)) return void 0;
|
|
951
|
+
let p = flows.get(id);
|
|
952
|
+
if (!p) {
|
|
953
|
+
p = emptyPartitionValues();
|
|
954
|
+
flows.set(id, p);
|
|
955
|
+
}
|
|
956
|
+
return p;
|
|
957
|
+
};
|
|
958
|
+
for (const [key, values] of Object.entries(sections)) {
|
|
959
|
+
let m;
|
|
960
|
+
if (key === "story") shared.story = values;
|
|
961
|
+
else if (m = /^storylets\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) shared[m[1]][unesc(m[2])] = values;
|
|
962
|
+
else if (m = /^storylets\/flow\/([^/]+)\/story$/.exec(key)) {
|
|
963
|
+
const p = flowOf(m[1]);
|
|
964
|
+
if (p) p.story = values;
|
|
965
|
+
} else if (m = /^storylets\/flow\/([^/]+)\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) {
|
|
966
|
+
const p = flowOf(m[1]);
|
|
967
|
+
if (p) p[m[2]][unesc(m[3])] = values;
|
|
968
|
+
} else if (!key.startsWith("storylets/")) rest[key] = values;
|
|
969
|
+
}
|
|
970
|
+
return { shared, flows, rest };
|
|
971
|
+
}
|
|
972
|
+
function sectionsOf(p, keyOf, out) {
|
|
973
|
+
if (Object.keys(p.story).length > 0) out[keyOf("story")] = p.story;
|
|
974
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
975
|
+
for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
598
978
|
var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
|
|
599
979
|
function conditionPasses(v) {
|
|
600
980
|
if (typeof v === "boolean") return v;
|
|
@@ -779,7 +1159,7 @@ function finishReport(bundle, saved, flows, draft) {
|
|
|
779
1159
|
retypedProperties
|
|
780
1160
|
};
|
|
781
1161
|
}
|
|
782
|
-
var Engine = class {
|
|
1162
|
+
var Engine = class _Engine {
|
|
783
1163
|
internals;
|
|
784
1164
|
seed;
|
|
785
1165
|
onReplacedFlow;
|
|
@@ -789,7 +1169,13 @@ var Engine = class {
|
|
|
789
1169
|
* outlives reset/loadGame (the host's container is the host's). The
|
|
790
1170
|
* self-backed resolver is rebuilt instead. */
|
|
791
1171
|
hostWorld;
|
|
1172
|
+
/** The options this engine was built with: hotSwap builds its replacement from them. */
|
|
1173
|
+
creationOptions;
|
|
1174
|
+
/** How to register each shared scope again, in registration order: a failed
|
|
1175
|
+
* hotSwap puts this engine back exactly as it was. */
|
|
1176
|
+
sharedMounts = [];
|
|
792
1177
|
constructor(bundle, opts = {}) {
|
|
1178
|
+
this.creationOptions = opts;
|
|
793
1179
|
this.seed = opts.seed ?? 0;
|
|
794
1180
|
this.onReplacedFlow = opts.onReplacedFlow;
|
|
795
1181
|
if (opts.world !== void 0) this.hostWorld = opts.world;
|
|
@@ -813,6 +1199,10 @@ var Engine = class {
|
|
|
813
1199
|
flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
|
|
814
1200
|
sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
|
|
815
1201
|
shared: void 0,
|
|
1202
|
+
registry: opts.registry ?? new ScopeRegistry(),
|
|
1203
|
+
ownsRegistry: opts.registry === void 0,
|
|
1204
|
+
selfWorld: false,
|
|
1205
|
+
registryView: () => view(),
|
|
816
1206
|
worldResolver: void 0,
|
|
817
1207
|
worldReadOnly: /* @__PURE__ */ new Set(),
|
|
818
1208
|
emitEngine: (flow, event, turn) => {
|
|
@@ -827,6 +1217,19 @@ var Engine = class {
|
|
|
827
1217
|
engineTracing: () => this.engineTraceHandlers.size > 0
|
|
828
1218
|
};
|
|
829
1219
|
this.internals = internals;
|
|
1220
|
+
let viewRevision = -1;
|
|
1221
|
+
let viewCache = { scopes: {}, qualities: void 0 };
|
|
1222
|
+
const view = () => {
|
|
1223
|
+
const reg = internals.registry;
|
|
1224
|
+
if (reg.revision !== viewRevision) {
|
|
1225
|
+
const ctx = reg.toEvalContext();
|
|
1226
|
+
const scopes = {};
|
|
1227
|
+
for (const [k, v] of Object.entries(ctx.scopes)) if (!k.includes("/")) scopes[k] = v;
|
|
1228
|
+
viewCache = { scopes, qualities: ctx.qualities };
|
|
1229
|
+
viewRevision = reg.revision;
|
|
1230
|
+
}
|
|
1231
|
+
return viewCache;
|
|
1232
|
+
};
|
|
830
1233
|
indexValueOwners(internals.owners.value, bundle);
|
|
831
1234
|
for (const box of bundle.boxes) {
|
|
832
1235
|
internals.boxesById.set(box.id, box);
|
|
@@ -873,34 +1276,79 @@ var Engine = class {
|
|
|
873
1276
|
internals.sharedDecls = declSet(sharedHalf);
|
|
874
1277
|
this.initShared(this.hostWorld);
|
|
875
1278
|
}
|
|
876
|
-
/** Build the shared stores and
|
|
877
|
-
* engine's
|
|
1279
|
+
/** Build the shared stores, register them and @world, and set up the @world
|
|
1280
|
+
* seam. Once, for the engine's life: reset and loads reseed the bags in
|
|
1281
|
+
* place, so the registry never sees them come and go. */
|
|
878
1282
|
initShared(hostWorld) {
|
|
879
1283
|
const internals = this.internals;
|
|
1284
|
+
const reg = internals.registry;
|
|
1285
|
+
const worldDecls = internals.bundle.world.properties;
|
|
880
1286
|
internals.shared = buildPartition(internals, sharedHalf);
|
|
881
1287
|
internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
|
|
1288
|
+
const registered = [];
|
|
1289
|
+
const mount = (key, register) => {
|
|
1290
|
+
register();
|
|
1291
|
+
registered.push(key);
|
|
1292
|
+
this.sharedMounts.push({ key, mount: register });
|
|
1293
|
+
};
|
|
1294
|
+
try {
|
|
1295
|
+
const story = internals.shared.story;
|
|
1296
|
+
mount("story", () => {
|
|
1297
|
+
reg.mountOwned("story", story, { owner: OWNER });
|
|
1298
|
+
});
|
|
1299
|
+
for (const kind of OWNED_SCOPES) {
|
|
1300
|
+
for (const [id, bag] of internals.shared[kind]) {
|
|
1301
|
+
if (bag.declarations().length === 0) continue;
|
|
1302
|
+
mount(sharedKey(kind, id), () => {
|
|
1303
|
+
reg.mountOwned(sharedKey(kind, id), bag, { owner: OWNER });
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (hostWorld !== void 0) {
|
|
1308
|
+
mount("world", () => {
|
|
1309
|
+
reg.defineForeign("world", hostWorld, worldDecls, { normalise: identity, owner: OWNER });
|
|
1310
|
+
});
|
|
1311
|
+
} else if (internals.ownsRegistry && !reg.has("world")) {
|
|
1312
|
+
reg.defineOwned("world", worldDecls, { normalise: identity, pathPrefix: "world.", owner: OWNER });
|
|
1313
|
+
const worldBag = reg.ownedBag("world");
|
|
1314
|
+
registered.push("world");
|
|
1315
|
+
this.sharedMounts.push({ key: "world", mount: () => {
|
|
1316
|
+
reg.mountOwned("world", worldBag, { owner: OWNER });
|
|
1317
|
+
} });
|
|
1318
|
+
internals.selfWorld = true;
|
|
1319
|
+
}
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
for (const k of registered) reg.remove(k, { keep: true });
|
|
1322
|
+
throw e;
|
|
1323
|
+
}
|
|
1324
|
+
internals.worldResolver = {
|
|
1325
|
+
get: (n) => reg.get("world", n),
|
|
1326
|
+
set: (n, v) => {
|
|
1327
|
+
reg.set("world", n, v);
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
882
1330
|
if (hostWorld !== void 0) {
|
|
883
|
-
internals.
|
|
884
|
-
|
|
885
|
-
internals.worldSet = set !== void 0 ? (n, v) => {
|
|
886
|
-
set(n, v);
|
|
1331
|
+
internals.worldSet = hostWorld.set !== void 0 ? (n, v) => {
|
|
1332
|
+
reg.set("world", n, v, { host: true });
|
|
887
1333
|
} : void 0;
|
|
888
1334
|
} else {
|
|
889
|
-
const bag = bagFromDecls(internals.bundle.world.properties, "world.");
|
|
890
|
-
internals.worldResolver = {
|
|
891
|
-
// The engine writes through worldSet below, not through this; the `set`
|
|
892
|
-
// is the resolver's SHAPE, so @world still reads as writable to anything
|
|
893
|
-
// inspecting the seam, and it is the story's door: no host flag on it.
|
|
894
|
-
get: (n) => bag.get(n),
|
|
895
|
-
set: (n, v) => {
|
|
896
|
-
bag.set(n, v);
|
|
897
|
-
}
|
|
898
|
-
};
|
|
899
1335
|
internals.worldSet = (n, v, host2) => {
|
|
900
|
-
|
|
1336
|
+
reg.set("world", n, v, host2 === true ? { host: true } : void 0);
|
|
901
1337
|
};
|
|
902
1338
|
}
|
|
903
1339
|
}
|
|
1340
|
+
/** Every shared bag back to its declared defaults, in place (the registry
|
|
1341
|
+
* keeps them registered), the self-backed @world included. */
|
|
1342
|
+
reseedShared() {
|
|
1343
|
+
const { shared, sharedDecls, registry } = this.internals;
|
|
1344
|
+
shared.story.reseed(sharedDecls.story);
|
|
1345
|
+
for (const kind of OWNED_SCOPES) {
|
|
1346
|
+
for (const [id, bag] of shared[kind]) bag.reseed(sharedDecls[kind].get(id) ?? []);
|
|
1347
|
+
}
|
|
1348
|
+
if (this.internals.selfWorld) {
|
|
1349
|
+
registry.reseedOwned("world", this.internals.bundle.world.properties);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
904
1352
|
/** Quality ladders by scope for the eval channel (design/quality.md):
|
|
905
1353
|
* world/story keyed by name; box/deck/value keyed by owner id then name.
|
|
906
1354
|
* Built once - a bundle's declarations never change. Ladders are
|
|
@@ -932,6 +1380,13 @@ var Engine = class {
|
|
|
932
1380
|
* state; shared state is untouched. There is no default flow: "main" is
|
|
933
1381
|
* a caller convention, not an engine rule. */
|
|
934
1382
|
openFlow(id, opts = {}) {
|
|
1383
|
+
return this.open(id, opts, false);
|
|
1384
|
+
}
|
|
1385
|
+
/** openFlow, and loadGame's rebuild. `claim` says the new flow's bags take
|
|
1386
|
+
* the values the registry holds for them (a load); a fresh open is a reset
|
|
1387
|
+
* of that name, so anything waiting for it is discarded first. */
|
|
1388
|
+
open(id, opts, claim) {
|
|
1389
|
+
this.assertExternalScopes();
|
|
935
1390
|
const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
|
|
936
1391
|
const existing = this.flowsById.get(id);
|
|
937
1392
|
if (existing) {
|
|
@@ -939,6 +1394,7 @@ var Engine = class {
|
|
|
939
1394
|
if (dealt > 0) this.onReplacedFlow?.(id, dealt);
|
|
940
1395
|
existing.markClosed();
|
|
941
1396
|
}
|
|
1397
|
+
if (!claim) this.internals.registry.discardParked(flowPrefix(id));
|
|
942
1398
|
const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
|
|
943
1399
|
this.flowsById.set(id, flow);
|
|
944
1400
|
if (opts.restore !== void 0) {
|
|
@@ -975,11 +1431,22 @@ var Engine = class {
|
|
|
975
1431
|
* self-backed @world included; a host-bound @world is the host's and is
|
|
976
1432
|
* not touched). */
|
|
977
1433
|
reset() {
|
|
1434
|
+
this.dropRun(false);
|
|
1435
|
+
this.reseedShared();
|
|
1436
|
+
this.internals.registry.discardParked("storylets/");
|
|
1437
|
+
}
|
|
1438
|
+
/** End the run: clear the log, close every flow, forget spent cards. Each
|
|
1439
|
+
* flow's bags leave the registry; `keepFlows` names the flows whose values
|
|
1440
|
+
* are kept there for the flow that replaces them (a load into the game's
|
|
1441
|
+
* registry). */
|
|
1442
|
+
dropRun(keepFlows) {
|
|
978
1443
|
this.engineLog = [];
|
|
979
|
-
for (const flow of this.flowsById
|
|
1444
|
+
for (const [id, flow] of this.flowsById) {
|
|
1445
|
+
flow.releaseBags(keepFlows !== false && keepFlows.has(id));
|
|
1446
|
+
flow.markClosed();
|
|
1447
|
+
}
|
|
980
1448
|
this.flowsById.clear();
|
|
981
1449
|
this.spent.clear();
|
|
982
|
-
this.initShared(this.hostWorld);
|
|
983
1450
|
}
|
|
984
1451
|
// --- shared scarcity (design/shared-scarcity.md) -----------------------------
|
|
985
1452
|
/** Cards a shared `redraw: "never"` has taken out of the world, by card id.
|
|
@@ -1041,7 +1508,7 @@ var Engine = class {
|
|
|
1041
1508
|
*/
|
|
1042
1509
|
getProperty(path) {
|
|
1043
1510
|
const found = this.resolveShared(path);
|
|
1044
|
-
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.bag.get(found.name);
|
|
1511
|
+
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.kind === "scope" ? this.internals.registry.get(found.token, found.name) : found.bag.get(found.name);
|
|
1045
1512
|
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
1046
1513
|
return value;
|
|
1047
1514
|
}
|
|
@@ -1054,6 +1521,10 @@ var Engine = class {
|
|
|
1054
1521
|
this.internals.worldSet(found.name, value, true);
|
|
1055
1522
|
return;
|
|
1056
1523
|
}
|
|
1524
|
+
if (found.kind === "scope") {
|
|
1525
|
+
this.internals.registry.set(found.token, found.name, value, { host: true });
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1057
1528
|
found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
|
|
1058
1529
|
}
|
|
1059
1530
|
resolveShared(path) {
|
|
@@ -1062,6 +1533,9 @@ var Engine = class {
|
|
|
1062
1533
|
throw new Error(`"${path}" is per-flow state - read it on a Flow, not the Engine`);
|
|
1063
1534
|
};
|
|
1064
1535
|
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
1536
|
+
if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
|
|
1537
|
+
return { kind: "scope", token: parts[0], name: parts[1] };
|
|
1538
|
+
}
|
|
1065
1539
|
if (parts.length === 2 && parts[0] === "story") {
|
|
1066
1540
|
const name = parts[1];
|
|
1067
1541
|
if (this.internals.shared.story.get(name) !== void 0) return { kind: "bag", bag: this.internals.shared.story, name };
|
|
@@ -1089,6 +1563,18 @@ var Engine = class {
|
|
|
1089
1563
|
diagnose(message) {
|
|
1090
1564
|
this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
|
|
1091
1565
|
}
|
|
1566
|
+
/** Content that names another engine's scope (`@patter.visits`) runs only
|
|
1567
|
+
* where that engine is on this registry: without it every read would answer
|
|
1568
|
+
* false and every write fail, so the flow is refused as it opens, before
|
|
1569
|
+
* anything changes. By then a game has built all of its engines, whatever
|
|
1570
|
+
* order it built them in. The same message on every runtime. */
|
|
1571
|
+
assertExternalScopes() {
|
|
1572
|
+
for (const token of this.internals.bundle.externalScopes ?? []) {
|
|
1573
|
+
if (!this.internals.registry.has(token)) {
|
|
1574
|
+
throw new Error(`this content names @${token}, which no engine on this registry registered: give every engine the game's one registry`);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1092
1578
|
/** The shared surface as examiner rows: @world (read through the
|
|
1093
1579
|
* resolver) then the shared partitions. Per-flow rows live on each Flow. */
|
|
1094
1580
|
listProperties() {
|
|
@@ -1140,15 +1626,77 @@ var Engine = class {
|
|
|
1140
1626
|
return () => this.engineTraceHandlers.delete(handler);
|
|
1141
1627
|
}
|
|
1142
1628
|
// --- persistence (schema 4) -------------------------------------------------
|
|
1143
|
-
/**
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1629
|
+
/**
|
|
1630
|
+
* Live bundle refresh: rebuild on an edited bundle with the whole run carried
|
|
1631
|
+
* over, and return the replacement with the report its load produced.
|
|
1632
|
+
*
|
|
1633
|
+
* Standalone, that is a save and a load into a new engine, and this one is left
|
|
1634
|
+
* untouched (discard it). With the game's registry the two cannot both hold the
|
|
1635
|
+
* same keys, so this engine is spent afterwards (its flows closed, the
|
|
1636
|
+
* replacement holding everything on the same registry): it carries its
|
|
1637
|
+
* own values into the snapshot, steps out of the registry, and the replacement
|
|
1638
|
+
* loads them the way a standalone save loads: so the report covers the
|
|
1639
|
+
* properties the edit dropped, defaulted, or retyped, and a dropped property
|
|
1640
|
+
* is dropped rather than kept. Values the game loaded that were still waiting
|
|
1641
|
+
* for a flow of this engine carry across as they were, and nothing belonging
|
|
1642
|
+
* to any other engine is touched. A save for another project is refused before
|
|
1643
|
+
* anything moves; if the rebuild fails for any other reason, this engine takes
|
|
1644
|
+
* its registrations back and is left exactly as it was.
|
|
1645
|
+
*/
|
|
1646
|
+
hotSwap(bundle, opts = {}) {
|
|
1647
|
+
if (bundle.content.project !== this.internals.bundle.content.project) {
|
|
1648
|
+
throw new Error(`save is for project "${this.internals.bundle.content.project}", bundle is "${bundle.content.project}"`);
|
|
1649
|
+
}
|
|
1650
|
+
const options = { ...this.creationOptions, ...opts };
|
|
1651
|
+
const snapshot = this.saveGame();
|
|
1652
|
+
const reg = this.internals.registry;
|
|
1653
|
+
if (this.internals.ownsRegistry) {
|
|
1654
|
+
const next2 = new _Engine(bundle, { ...options, registry: void 0 });
|
|
1655
|
+
return { engine: next2, report: next2.loadGame(snapshot) };
|
|
1656
|
+
}
|
|
1657
|
+
const mine = {};
|
|
1658
|
+
for (const [key, values] of Object.entries(reg.save())) {
|
|
1659
|
+
if (key === "story" || key.startsWith("storylets/")) mine[key] = values;
|
|
1660
|
+
}
|
|
1661
|
+
const registeredKeys = /* @__PURE__ */ new Set([
|
|
1662
|
+
...this.sharedMounts.map((m) => m.key),
|
|
1663
|
+
...[...this.flowsById.values()].flatMap((f) => f.registeredKeys())
|
|
1664
|
+
]);
|
|
1665
|
+
const waiting = Object.fromEntries(Object.entries(mine).filter(([key]) => !registeredKeys.has(key)));
|
|
1666
|
+
for (const { key } of this.sharedMounts) if (reg.has(key)) reg.remove(key);
|
|
1667
|
+
for (const flow of this.flowsById.values()) flow.releaseBags(false);
|
|
1668
|
+
let next;
|
|
1669
|
+
try {
|
|
1670
|
+
next = new _Engine(bundle, { ...options, registry: reg });
|
|
1671
|
+
const report = next.loadGame({ ...snapshot, registry: mine });
|
|
1672
|
+
const stillWaiting = Object.fromEntries(Object.entries(waiting).filter(([key]) => !reg.has(key)));
|
|
1673
|
+
if (Object.keys(stillWaiting).length > 0) reg.load(stillWaiting, { keepParked: true });
|
|
1674
|
+
this.dropRun(false);
|
|
1675
|
+
return { engine: next, report };
|
|
1676
|
+
} catch (e) {
|
|
1677
|
+
next?.dropRun(false);
|
|
1678
|
+
for (const { key } of next?.sharedMounts ?? []) if (reg.has(key)) reg.remove(key);
|
|
1679
|
+
reg.discardParked("storylets/");
|
|
1680
|
+
for (const { mount } of this.sharedMounts) mount();
|
|
1681
|
+
for (const flow of this.flowsById.values()) flow.mountBags();
|
|
1682
|
+
this.reseedShared();
|
|
1683
|
+
reg.load(mine, { keepParked: true });
|
|
1684
|
+
throw e;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
/** The whole engine's NON-property state, one envelope: the spent cards
|
|
1688
|
+
* once, then every live flow (board, clocks, cooldowns, PRNG, play log)
|
|
1689
|
+
* keyed by its id. The property values are the registry's: a standalone
|
|
1690
|
+
* engine (one that made its own registry) carries them here under
|
|
1691
|
+
* `registry`, self-backed @world included; a game that passed a registry
|
|
1692
|
+
* saves it once itself, beside each engine's envelope. */
|
|
1146
1693
|
saveGame() {
|
|
1147
1694
|
return structuredClone({
|
|
1148
|
-
schema:
|
|
1695
|
+
schema: SAVE_SCHEMA,
|
|
1149
1696
|
content: this.internals.bundle.content,
|
|
1150
|
-
|
|
1151
|
-
|
|
1697
|
+
...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
|
|
1698
|
+
shared: { spent: [...this.spent].sort() },
|
|
1699
|
+
flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
|
|
1152
1700
|
});
|
|
1153
1701
|
}
|
|
1154
1702
|
/** ONE flow's blob, to park a visit that is walking away: the same shape
|
|
@@ -1160,7 +1708,7 @@ var Engine = class {
|
|
|
1160
1708
|
saveFlow(id) {
|
|
1161
1709
|
const flow = this.flowsById.get(id);
|
|
1162
1710
|
if (!flow) throw new Error(`unknown flow "${id}"`);
|
|
1163
|
-
return structuredClone(flow.snapshot());
|
|
1711
|
+
return structuredClone(flow.snapshot(true));
|
|
1164
1712
|
}
|
|
1165
1713
|
/** What `loadGame(envelope)` would do that is not a plain restore, without
|
|
1166
1714
|
* doing any of it (design/engine-server.md 4.9). Pure: nothing on this
|
|
@@ -1183,16 +1731,31 @@ var Engine = class {
|
|
|
1183
1731
|
* Handles held from before the load are closed and inert (Patter's
|
|
1184
1732
|
* rule); take fresh ones from getFlow()/flows().
|
|
1185
1733
|
*
|
|
1734
|
+
* Property values come from the registry. An envelope that carries them
|
|
1735
|
+
* (a standalone engine's, or a version 1 envelope) has them walked, cleaned,
|
|
1736
|
+
* and moved into the registry here, over fresh defaults. Otherwise the game
|
|
1737
|
+
* loads its registry itself, before or after this call: each flow's bags
|
|
1738
|
+
* are handed back to the registry with their values, and the restored
|
|
1739
|
+
* flows claim them. The report then covers only what this envelope holds;
|
|
1740
|
+
* the registry's own load rule applies to the values.
|
|
1741
|
+
*
|
|
1186
1742
|
* Returns the report `previewLoad` would have given for this envelope: the
|
|
1187
1743
|
* drift tolerance that makes a load forgiving is what hides its cost, so
|
|
1188
1744
|
* the cost comes back with the load whether or not anybody looked first. */
|
|
1189
1745
|
loadGame(envelope) {
|
|
1190
1746
|
this.assertSameProject(envelope);
|
|
1747
|
+
this.assertExternalScopes();
|
|
1191
1748
|
const plan = this.planLoad(structuredClone(envelope));
|
|
1192
|
-
this.
|
|
1193
|
-
|
|
1749
|
+
const reg = this.internals.registry;
|
|
1750
|
+
if (plan.sections !== void 0) {
|
|
1751
|
+
this.reset();
|
|
1752
|
+
if (this.internals.ownsRegistry) reg.load(plan.sections);
|
|
1753
|
+
else reg.load(plan.sections, { keepParked: true });
|
|
1754
|
+
} else {
|
|
1755
|
+
this.dropRun(new Set(plan.flows.map(([id]) => id)));
|
|
1756
|
+
}
|
|
1194
1757
|
for (const id of plan.spent) this.spent.add(id);
|
|
1195
|
-
for (const [id, clean] of plan.flows) this.
|
|
1758
|
+
for (const [id, clean] of plan.flows) this.open(id, {}, true).restore(clean);
|
|
1196
1759
|
return plan.report;
|
|
1197
1760
|
}
|
|
1198
1761
|
assertSameProject(envelope) {
|
|
@@ -1204,26 +1767,43 @@ var Engine = class {
|
|
|
1204
1767
|
* half writes. Nothing here touches the engine, which is what lets
|
|
1205
1768
|
* previewLoad and loadGame share it. */
|
|
1206
1769
|
planLoad(envelope) {
|
|
1770
|
+
const schema = envelope.schema;
|
|
1771
|
+
if (schema !== SAVE_SCHEMA && schema !== SAVE_SCHEMA_V1) throw new Error(`unsupported save schema: ${String(schema)}`);
|
|
1207
1772
|
const draft = emptyDraft();
|
|
1208
|
-
const
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1773
|
+
const flowIds = new Set(Object.keys(envelope.flows ?? {}));
|
|
1774
|
+
let moved;
|
|
1775
|
+
if (envelope.schema === SAVE_SCHEMA_V1) {
|
|
1776
|
+
moved = {
|
|
1777
|
+
shared: envelope.shared?.props ?? emptyPartitionValues(),
|
|
1778
|
+
flows: new Map(Object.entries(envelope.flows ?? {}).map(([id, f]) => [id, f.props ?? emptyPartitionValues()])),
|
|
1779
|
+
rest: {}
|
|
1780
|
+
};
|
|
1781
|
+
} else if (envelope.registry !== void 0) {
|
|
1782
|
+
moved = partitionsFromSections(envelope.registry, flowIds);
|
|
1783
|
+
}
|
|
1784
|
+
const shared = moved !== void 0 ? walkPartition(this.internals, this.internals.sharedDecls, moved.shared, void 0, draft) : void 0;
|
|
1215
1785
|
const spent = [];
|
|
1216
1786
|
for (const cardId of envelope.shared?.spent ?? []) {
|
|
1217
1787
|
if (this.internals.cardsById.has(cardId)) spent.push(cardId);
|
|
1218
1788
|
else draft.droppedSpent.push(cardId);
|
|
1219
1789
|
}
|
|
1220
1790
|
const flows = [];
|
|
1791
|
+
const sections = moved !== void 0 ? { ...moved.rest } : void 0;
|
|
1792
|
+
if (sections !== void 0 && shared !== void 0) {
|
|
1793
|
+
sectionsOf(shared, (kind, id) => kind === "story" ? "story" : sharedKey(kind, id), sections);
|
|
1794
|
+
}
|
|
1221
1795
|
for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
|
|
1222
|
-
flows.
|
|
1796
|
+
const withProps = moved !== void 0 ? { ...saved, props: moved.flows.get(id) ?? emptyPartitionValues() } : { ...saved };
|
|
1797
|
+
const clean = this.planFlowRestore(id, withProps, void 0, draft);
|
|
1798
|
+
if (sections !== void 0 && clean.props !== void 0) {
|
|
1799
|
+
sectionsOf(clean.props, (kind, owner) => flowKey(id, kind, owner), sections);
|
|
1800
|
+
delete clean.props;
|
|
1801
|
+
}
|
|
1802
|
+
flows.push([id, clean]);
|
|
1223
1803
|
}
|
|
1224
1804
|
return {
|
|
1225
1805
|
report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
|
|
1226
|
-
|
|
1806
|
+
...sections !== void 0 ? { sections } : {},
|
|
1227
1807
|
spent,
|
|
1228
1808
|
flows
|
|
1229
1809
|
};
|
|
@@ -1234,7 +1814,7 @@ var Engine = class {
|
|
|
1234
1814
|
* there is nobody else to compete with. */
|
|
1235
1815
|
planFlowRestore(id, saved, otherClaims, draft) {
|
|
1236
1816
|
const internals = this.internals;
|
|
1237
|
-
const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
|
|
1817
|
+
const props = saved.props !== void 0 ? walkPartition(internals, internals.flowDecls, saved.props, id, draft) : void 0;
|
|
1238
1818
|
const cooldowns = {};
|
|
1239
1819
|
for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
|
|
1240
1820
|
if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
|
|
@@ -1275,7 +1855,7 @@ var Engine = class {
|
|
|
1275
1855
|
board[handId] = kept;
|
|
1276
1856
|
}
|
|
1277
1857
|
return {
|
|
1278
|
-
props,
|
|
1858
|
+
...props !== void 0 ? { props } : {},
|
|
1279
1859
|
turns: saved.turns ?? {},
|
|
1280
1860
|
prng: saved.prng,
|
|
1281
1861
|
cooldowns,
|
|
@@ -1318,8 +1898,12 @@ var Flow = class {
|
|
|
1318
1898
|
lastPlayOf = /* @__PURE__ */ new Map();
|
|
1319
1899
|
tagPlayCount = /* @__PURE__ */ new Map();
|
|
1320
1900
|
lastPlayInTag = /* @__PURE__ */ new Map();
|
|
1321
|
-
/** The per-flow property partitions (the not-shared halves)
|
|
1901
|
+
/** The per-flow property partitions (the not-shared halves), each bag
|
|
1902
|
+
* that declares something registered under this flow's keys. */
|
|
1322
1903
|
stores;
|
|
1904
|
+
registered = [];
|
|
1905
|
+
/** This flow's bags that declare something, under their registry keys. */
|
|
1906
|
+
bagKeys = [];
|
|
1323
1907
|
traceHandlers = /* @__PURE__ */ new Set();
|
|
1324
1908
|
logEntries = [];
|
|
1325
1909
|
logSeq = 0;
|
|
@@ -1337,6 +1921,13 @@ var Flow = class {
|
|
|
1337
1921
|
this.id = id;
|
|
1338
1922
|
this.prng = makePrng(seed);
|
|
1339
1923
|
this.stores = buildPartition(internals, flowHalf);
|
|
1924
|
+
const put = (key, bag) => {
|
|
1925
|
+
if (bag.declarations().length === 0) return;
|
|
1926
|
+
this.bagKeys.push([key, bag]);
|
|
1927
|
+
};
|
|
1928
|
+
put(flowKey(id, "story"), this.stores.story);
|
|
1929
|
+
for (const kind of OWNED_SCOPES) for (const [owner, bag] of this.stores[kind]) put(flowKey(id, kind, owner), bag);
|
|
1930
|
+
this.mountBags();
|
|
1340
1931
|
for (const box of internals.bundle.boxes) {
|
|
1341
1932
|
this.turnCounts.set(box.id, 0);
|
|
1342
1933
|
for (const hand of box.hands) this.boardContents.set(hand.id, []);
|
|
@@ -1364,8 +1955,28 @@ var Flow = class {
|
|
|
1364
1955
|
}
|
|
1365
1956
|
/** @internal */
|
|
1366
1957
|
markClosed() {
|
|
1958
|
+
this.releaseBags(false);
|
|
1367
1959
|
this.closed = true;
|
|
1368
1960
|
}
|
|
1961
|
+
/** @internal - take this flow's bags out of the registry; with `keep`, their
|
|
1962
|
+
* values wait there for the flow that replaces this one (a load into the
|
|
1963
|
+
* game's registry). Idempotent. */
|
|
1964
|
+
releaseBags(keep) {
|
|
1965
|
+
for (const key of this.registered) this.internals.registry.remove(key, { keep });
|
|
1966
|
+
this.registered.length = 0;
|
|
1967
|
+
}
|
|
1968
|
+
/** @internal - the registry keys this flow holds right now. */
|
|
1969
|
+
registeredKeys() {
|
|
1970
|
+
return [...this.registered];
|
|
1971
|
+
}
|
|
1972
|
+
/** @internal - register this flow's bags (again): at construction, and when a
|
|
1973
|
+
* failed hotSwap hands them back. Each claims what the registry holds for it. */
|
|
1974
|
+
mountBags() {
|
|
1975
|
+
for (const [key, bag] of this.bagKeys) {
|
|
1976
|
+
this.internals.registry.mountOwned(key, bag, { owner: OWNER });
|
|
1977
|
+
this.registered.push(key);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1369
1980
|
assertOpen() {
|
|
1370
1981
|
if (this.closed) throw new Error(`flow "${this.id}" is closed`);
|
|
1371
1982
|
}
|
|
@@ -1502,8 +2113,12 @@ var Flow = class {
|
|
|
1502
2113
|
* is the flow's MERGED view - its own copies over the shared values,
|
|
1503
2114
|
* names disjoint - and @world reads through the engine's resolver. */
|
|
1504
2115
|
evalCtx(box, deck, handEnv) {
|
|
2116
|
+
const others = this.internals.registryView();
|
|
1505
2117
|
return {
|
|
1506
2118
|
scopes: {
|
|
2119
|
+
// Every other engine's game-wide scope first (every engine reads every
|
|
2120
|
+
// scope); this engine's own tokens are its merged views, over the top.
|
|
2121
|
+
...others.scopes,
|
|
1507
2122
|
world: this.internals.worldResolver,
|
|
1508
2123
|
story: this.storyReader,
|
|
1509
2124
|
box: this.boxReaders.get(box.id) ?? {},
|
|
@@ -1514,8 +2129,8 @@ var Flow = class {
|
|
|
1514
2129
|
// The quality channel, answering for THIS ask's box and deck. Only wired
|
|
1515
2130
|
// when a quality exists, so a bundle without one evaluates byte-
|
|
1516
2131
|
// identically to before the feature.
|
|
1517
|
-
...this.internals.hasQualities ? {
|
|
1518
|
-
qualities: (scope, name) => scope === "world" ? this.internals.ladders.world.get(name) : scope === "story" ? this.internals.ladders.story.get(name) : scope === "box" ? this.internals.ladders.box.get(box.id)?.get(name) : scope === "deck" && deck ? this.internals.ladders.deck.get(deck.id)?.get(name) : scope === "hand" ? this.handLadder(handEnv, name) :
|
|
2132
|
+
...this.internals.hasQualities || others.qualities !== void 0 ? {
|
|
2133
|
+
qualities: (scope, name) => scope === "world" ? this.internals.ladders.world.get(name) ?? others.qualities?.(scope, name) : scope === "story" ? this.internals.ladders.story.get(name) : scope === "box" ? this.internals.ladders.box.get(box.id)?.get(name) : scope === "deck" && deck ? this.internals.ladders.deck.get(deck.id)?.get(name) : scope === "hand" ? this.handLadder(handEnv, name) : others.qualities?.(scope, name)
|
|
1519
2134
|
} : {}
|
|
1520
2135
|
};
|
|
1521
2136
|
}
|
|
@@ -2154,8 +2769,17 @@ var Flow = class {
|
|
|
2154
2769
|
if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
|
|
2155
2770
|
return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
|
|
2156
2771
|
}
|
|
2157
|
-
default:
|
|
2772
|
+
default: {
|
|
2773
|
+
if (this.internals.registry.has(scope)) {
|
|
2774
|
+
const prev = this.internals.registry.get(scope, name);
|
|
2775
|
+
this.internals.registry.set(scope, name, value);
|
|
2776
|
+
return { path: `${scope}.${name}`, ...prev !== void 0 ? { prev } : {} };
|
|
2777
|
+
}
|
|
2778
|
+
if (this.internals.bundle.externalScopes?.includes(scope)) {
|
|
2779
|
+
throw new Error(`@${scope}.${name} cannot be written: no engine on this registry registered @${scope}`);
|
|
2780
|
+
}
|
|
2158
2781
|
throw new Error(`bad change target scope "@${scope}"`);
|
|
2782
|
+
}
|
|
2159
2783
|
}
|
|
2160
2784
|
}
|
|
2161
2785
|
/** Advance one box's clock (schema 3.4): a turn is one draw-from-stock
|
|
@@ -2248,7 +2872,7 @@ var Flow = class {
|
|
|
2248
2872
|
getProperty(path) {
|
|
2249
2873
|
this.assertOpen();
|
|
2250
2874
|
const found = this.resolvePath(path);
|
|
2251
|
-
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
|
|
2875
|
+
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.kind === "scope" ? this.internals.registry.get(found.token, found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
|
|
2252
2876
|
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
2253
2877
|
return value;
|
|
2254
2878
|
}
|
|
@@ -2260,6 +2884,10 @@ var Flow = class {
|
|
|
2260
2884
|
this.internals.worldSet(found.name, value, true);
|
|
2261
2885
|
return;
|
|
2262
2886
|
}
|
|
2887
|
+
if (found.kind === "scope") {
|
|
2888
|
+
this.internals.registry.set(found.token, found.name, value, { host: true });
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2263
2891
|
const bag = found.own !== void 0 && found.own.get(found.name) !== void 0 ? found.own : found.shared !== void 0 && found.shared.get(found.name) !== void 0 ? found.shared : void 0;
|
|
2264
2892
|
if (bag === void 0) throw new Error(`no property at "${path}"`);
|
|
2265
2893
|
bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
|
|
@@ -2267,6 +2895,9 @@ var Flow = class {
|
|
|
2267
2895
|
resolvePath(path) {
|
|
2268
2896
|
const parts = path.split(".");
|
|
2269
2897
|
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
2898
|
+
if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
|
|
2899
|
+
return { kind: "scope", token: parts[0], name: parts[1] };
|
|
2900
|
+
}
|
|
2270
2901
|
if (parts.length === 2 && parts[0] === "story") {
|
|
2271
2902
|
return { kind: "bag", own: this.stores.story, shared: this.internals.shared.story, name: parts[1] };
|
|
2272
2903
|
}
|
|
@@ -2285,10 +2916,11 @@ var Flow = class {
|
|
|
2285
2916
|
throw new Error(`bad property path "${path}"`);
|
|
2286
2917
|
}
|
|
2287
2918
|
// --- persistence (schema 4) -------------------------------------------------
|
|
2288
|
-
/** @internal - this flow's blob inside the engine's envelope
|
|
2289
|
-
|
|
2919
|
+
/** @internal - this flow's blob: inside the engine's envelope without its
|
|
2920
|
+
* properties (the registry has them), or parked whole by saveFlow. */
|
|
2921
|
+
snapshot(withProps) {
|
|
2290
2922
|
return {
|
|
2291
|
-
props: partitionValues(this.stores),
|
|
2923
|
+
...withProps ? { props: partitionValues(this.stores) } : {},
|
|
2292
2924
|
turns: Object.fromEntries(this.turnCounts),
|
|
2293
2925
|
prng: this.prng.state(),
|
|
2294
2926
|
cooldowns: this.cooldowns,
|
|
@@ -2299,7 +2931,7 @@ var Flow = class {
|
|
|
2299
2931
|
/** @internal - restore a freshly opened flow from its blob (loadGame).
|
|
2300
2932
|
* Orphaned keys (deleted entities) drop; new declarations keep defaults. */
|
|
2301
2933
|
restore(saved) {
|
|
2302
|
-
loadPartition(this.stores, saved.props);
|
|
2934
|
+
if (saved.props !== void 0) loadPartition(this.stores, saved.props);
|
|
2303
2935
|
this.turnCounts = new Map(this.internals.bundle.boxes.map((b) => [b.id, 0]));
|
|
2304
2936
|
for (const [boxId, turn] of Object.entries(saved.turns ?? {})) {
|
|
2305
2937
|
if (this.turnCounts.has(boxId)) this.turnCounts.set(boxId, turn);
|