@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.cjs
CHANGED
|
@@ -287,6 +287,12 @@ function walk(node, want, evalTruthy, countingCalls) {
|
|
|
287
287
|
return evalTruthy(node) === want ? 1 : 0;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// ../dialect/src/engine-scopes.ts
|
|
291
|
+
var ENGINE_SCOPES = [
|
|
292
|
+
{ token: "patter", engine: "Patterplay", means: "Patter's shared globals" },
|
|
293
|
+
{ token: "story", engine: "Storylet Engine", means: "the Storylet Engine's shared @story properties" }
|
|
294
|
+
];
|
|
295
|
+
|
|
290
296
|
// ../dialect/src/index.ts
|
|
291
297
|
var NEVER_PLAYED = 9999;
|
|
292
298
|
var host = (h) => h.ctx.host ?? {};
|
|
@@ -306,16 +312,17 @@ var flagsArg = (fn, args, h) => {
|
|
|
306
312
|
if (v === false) return [];
|
|
307
313
|
throw new EvalError(`${fn}() first argument must be a flags property`);
|
|
308
314
|
};
|
|
315
|
+
var OWN_SCOPES = ["story", "world", "box", "deck", "hand"];
|
|
316
|
+
var EXTERNAL_SCOPES = ENGINE_SCOPES.map((s) => s.token).filter((t) => !OWN_SCOPES.includes(t));
|
|
309
317
|
var storyletsDialect = {
|
|
310
318
|
// A missing property in a PRESENT scope is always an error: every property
|
|
311
319
|
// is declared with a default, so absence means a publish bug, a drifted
|
|
312
|
-
// save, or a foreign scope the host never fed (schema 6.2).
|
|
320
|
+
// save, or a foreign scope the host never fed (schema 6.2). The same holds
|
|
321
|
+
// for another engine's scope: `@patter.glod` is a typo, and it says so the
|
|
322
|
+
// first time the card is evaluated rather than quietly reading false.
|
|
313
323
|
scopes: [
|
|
314
|
-
{ token
|
|
315
|
-
{ token
|
|
316
|
-
{ token: "box", missing: "throw" },
|
|
317
|
-
{ token: "deck", missing: "throw" },
|
|
318
|
-
{ token: "hand", missing: "throw" }
|
|
324
|
+
...OWN_SCOPES.map((token) => ({ token, missing: "throw" })),
|
|
325
|
+
...EXTERNAL_SCOPES.map((token) => ({ token, missing: "throw" }))
|
|
319
326
|
],
|
|
320
327
|
defaultScope: "story",
|
|
321
328
|
functions: {
|
|
@@ -480,6 +487,8 @@ var parseHoleRef = (value) => {
|
|
|
480
487
|
const m = HOLE_REF.exec(value);
|
|
481
488
|
return m === null ? void 0 : { scope: m[1], name: m[2] };
|
|
482
489
|
};
|
|
490
|
+
var SAVE_SCHEMA = "storylets/save@2";
|
|
491
|
+
var SAVE_SCHEMA_V1 = "storylets/save@1";
|
|
483
492
|
|
|
484
493
|
// ../../../expr/packages/scoperegistry/src/index.ts
|
|
485
494
|
var PropertyBag = class _PropertyBag {
|
|
@@ -512,6 +521,13 @@ var PropertyBag = class _PropertyBag {
|
|
|
512
521
|
get(name) {
|
|
513
522
|
return this.values[this.norm(name)];
|
|
514
523
|
}
|
|
524
|
+
/** A name as this bag keys it: its normalisation policy applied. The registry
|
|
525
|
+
* uses it to key quality ladders and the validation schema the bag's own way,
|
|
526
|
+
* so a case-significant (identity) bag is not quietly folded to lower case
|
|
527
|
+
* one layer up. */
|
|
528
|
+
normalise(name) {
|
|
529
|
+
return this.norm(name);
|
|
530
|
+
}
|
|
515
531
|
/** Write a property. Engine writes (the default) notify subscribers;
|
|
516
532
|
* pass `silent: true` for a host write, which reaches only the audit
|
|
517
533
|
* hook. Throws on a read-only property unless the caller says it is the
|
|
@@ -595,6 +611,328 @@ function rowFor(d, value, writable, name, pathPrefix = "") {
|
|
|
595
611
|
writable: writable ?? d.writable ?? true
|
|
596
612
|
};
|
|
597
613
|
}
|
|
614
|
+
var lowerCase = (name) => name.toLowerCase();
|
|
615
|
+
var SAVE_FRAGMENT_VERSION = 1;
|
|
616
|
+
var ScopeRegistry = class {
|
|
617
|
+
scopes = /* @__PURE__ */ new Map();
|
|
618
|
+
/** Values loaded for keys nobody has registered yet, waiting to be claimed. */
|
|
619
|
+
parked = /* @__PURE__ */ new Map();
|
|
620
|
+
rev = 0;
|
|
621
|
+
/**
|
|
622
|
+
* A counter that moves whenever a scope is registered or removed, and at no
|
|
623
|
+
* other time: it starts at 0 and each registration or removal adds 1. Values
|
|
624
|
+
* changing does not move it. A caller that caches a context built by
|
|
625
|
+
* `toEvalContext()` rebuilds it when this moves, because the context's set of
|
|
626
|
+
* scopes is fixed when it is built while the values it reads stay live.
|
|
627
|
+
*/
|
|
628
|
+
get revision() {
|
|
629
|
+
return this.rev;
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Register a scope this registry **owns and stores**. Its bag is seeded from
|
|
633
|
+
* each declaration's `default` (or a type default). Owned scopes are
|
|
634
|
+
* type-checked (declarations) and serialized by `save`/`load`.
|
|
635
|
+
*
|
|
636
|
+
* The third argument may be the path prefix alone (the pre-0.7 form) or an
|
|
637
|
+
* options object.
|
|
638
|
+
*/
|
|
639
|
+
defineOwned(token, declarations, opts) {
|
|
640
|
+
const o = typeof opts === "string" ? { pathPrefix: opts } : opts ?? {};
|
|
641
|
+
const bag = new PropertyBag(declarations, {
|
|
642
|
+
pathPrefix: o.pathPrefix ?? `${token}.`,
|
|
643
|
+
...o.normalise ? { normalise: o.normalise } : {}
|
|
644
|
+
});
|
|
645
|
+
return this.mountOwned(token, bag, o.owner !== void 0 ? { owner: o.owner } : void 0);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Attach an EXISTING bag as an owned scope: an engine (or a host) holds the
|
|
649
|
+
* bag and this registry reads, writes, lists and saves it like its own.
|
|
650
|
+
*
|
|
651
|
+
* If values were loaded for this key before anyone registered it, the bag
|
|
652
|
+
* claims them now: laid over its seeded defaults by the bag's own `load` rule.
|
|
653
|
+
*/
|
|
654
|
+
mountOwned(token, bag, opts) {
|
|
655
|
+
this.assertFree(token, opts?.owner);
|
|
656
|
+
this.scopes.set(token, { kind: "owned", bag, ...opts?.owner !== void 0 ? { owner: opts.owner } : {} });
|
|
657
|
+
this.rev++;
|
|
658
|
+
const waiting = this.parked.get(token);
|
|
659
|
+
if (waiting) {
|
|
660
|
+
bag.load(waiting);
|
|
661
|
+
this.parked.delete(token);
|
|
662
|
+
}
|
|
663
|
+
return this;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Unregister a scope. With `{ keep: true }` an owned scope's values are parked
|
|
667
|
+
* and handed back when the same key is next registered, which is how a live
|
|
668
|
+
* reload hands an engine's state to its replacement. Throws on an unknown key.
|
|
669
|
+
*/
|
|
670
|
+
remove(token, opts) {
|
|
671
|
+
const e = this.scopes.get(token);
|
|
672
|
+
if (!e) throw new Error(`unknown scope '@${token}'`);
|
|
673
|
+
if (opts?.keep && e.kind === "owned") this.parked.set(token, e.bag.save());
|
|
674
|
+
this.scopes.delete(token);
|
|
675
|
+
this.rev++;
|
|
676
|
+
return this;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Drop parked values nobody claimed. Parked values are kept in the next save by
|
|
680
|
+
* default, so nothing loaded is lost to a flow or deck that simply has not
|
|
681
|
+
* reopened yet; a game that knows they are dead drops them here.
|
|
682
|
+
*
|
|
683
|
+
* With a `prefix`, only keys starting with it are dropped: an engine resetting
|
|
684
|
+
* itself drops its own instance keys (`my-engine/`) and leaves every other
|
|
685
|
+
* engine's alone.
|
|
686
|
+
*/
|
|
687
|
+
discardParked(prefix) {
|
|
688
|
+
if (prefix === void 0) this.parked.clear();
|
|
689
|
+
else for (const key of [...this.parked.keys()]) if (key.startsWith(prefix)) this.parked.delete(key);
|
|
690
|
+
return this;
|
|
691
|
+
}
|
|
692
|
+
/** An owned scope's bag (subscribe, audit, rows live there). */
|
|
693
|
+
ownedBag(token) {
|
|
694
|
+
const e = this.scopes.get(token);
|
|
695
|
+
if (!e || e.kind !== "owned") throw new Error(`'@${token}' is not an owned scope`);
|
|
696
|
+
return e.bag;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Re-initialise an existing **owned** scope's bag from new declarations,
|
|
700
|
+
* clearing its current values. For scope-local state that resets on a context
|
|
701
|
+
* change (e.g. entering a new scene / site / deck) without disturbing other
|
|
702
|
+
* scopes. Mutates the bag in place, so an `EvalContext` already built from this
|
|
703
|
+
* registry stays valid.
|
|
704
|
+
*/
|
|
705
|
+
reseedOwned(token, declarations) {
|
|
706
|
+
this.ownedBag(token).reseed(declarations);
|
|
707
|
+
return this;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Register a **foreign** scope backed by a host `{ get, set? }` resolver. The
|
|
711
|
+
* values live in the host/other engine and are never stored or saved here.
|
|
712
|
+
* `declarations` (optional, e.g. imported from a `scopeRegistrySpec`) are used
|
|
713
|
+
* only for validation; omit them for an opaque scope.
|
|
714
|
+
*/
|
|
715
|
+
defineForeign(token, resolver, declarations = [], opts = true) {
|
|
716
|
+
const o = typeof opts === "boolean" ? { writable: opts } : opts;
|
|
717
|
+
this.assertFree(token, o.owner);
|
|
718
|
+
const norm = o.normalise ?? lowerCase;
|
|
719
|
+
const decls = /* @__PURE__ */ new Map();
|
|
720
|
+
for (const d of declarations) decls.set(norm(d.name), d);
|
|
721
|
+
this.scopes.set(token, {
|
|
722
|
+
kind: "foreign",
|
|
723
|
+
resolver,
|
|
724
|
+
decls,
|
|
725
|
+
scopeWritable: o.writable ?? true,
|
|
726
|
+
norm,
|
|
727
|
+
...o.owner !== void 0 ? { owner: o.owner } : {}
|
|
728
|
+
});
|
|
729
|
+
this.rev++;
|
|
730
|
+
return this;
|
|
731
|
+
}
|
|
732
|
+
has(token) {
|
|
733
|
+
return this.scopes.has(token);
|
|
734
|
+
}
|
|
735
|
+
/** Read a property; undefined if the scope or property is not present. */
|
|
736
|
+
get(scope, name) {
|
|
737
|
+
const e = this.scopes.get(scope);
|
|
738
|
+
if (!e) return void 0;
|
|
739
|
+
return e.kind === "owned" ? e.bag.get(name) : e.resolver.get(e.norm(name));
|
|
740
|
+
}
|
|
741
|
+
/** Write a property (an ENGINE write: the bag's subscribers fire; use
|
|
742
|
+
* the bag directly for silent host writes). Throws on an unknown scope.
|
|
743
|
+
*
|
|
744
|
+
* `writable: false` is the STORY's promise, so a story write is refused and
|
|
745
|
+
* a HOST write is not: pass `{ host: true }` from a host's own surface (its
|
|
746
|
+
* `setProperty`, its tooling, a coverage driver) and never from the path an
|
|
747
|
+
* outcome or effect takes. A foreign scope whose resolver has no `set` is
|
|
748
|
+
* refused for everyone, host included - that is not a rule to bypass, it is
|
|
749
|
+
* a game that gave no way to write. */
|
|
750
|
+
set(scope, name, value, opts) {
|
|
751
|
+
const e = this.scopes.get(scope);
|
|
752
|
+
if (!e) throw new Error(`unknown scope '@${scope}'`);
|
|
753
|
+
if (e.kind === "owned") {
|
|
754
|
+
try {
|
|
755
|
+
e.bag.set(name, value, opts?.host ? { host: true } : void 0);
|
|
756
|
+
} catch {
|
|
757
|
+
throw new Error(`'@${scope}.${name}' is read-only`);
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const n = e.norm(name);
|
|
762
|
+
if (!e.resolver.set) throw new Error(`'@${scope}.${name}' is read-only`);
|
|
763
|
+
if (!opts?.host && !this.foreignWritable(e, n)) throw new Error(`'@${scope}.${name}' is read-only`);
|
|
764
|
+
e.resolver.set(n, value);
|
|
765
|
+
}
|
|
766
|
+
foreignWritable(e, name) {
|
|
767
|
+
if (!e.resolver.set) return false;
|
|
768
|
+
return e.decls.get(name)?.writable ?? e.scopeWritable;
|
|
769
|
+
}
|
|
770
|
+
/** Examiner rows across every scope with a declared surface: owned bags
|
|
771
|
+
* first, then declared foreign scopes (values read through, writability
|
|
772
|
+
* reflecting the resolver). Opaque foreign scopes are not listed. */
|
|
773
|
+
listProperties() {
|
|
774
|
+
const out = [];
|
|
775
|
+
for (const [token, e] of this.scopes) {
|
|
776
|
+
const owner = e.owner !== void 0 ? { owner: e.owner } : {};
|
|
777
|
+
if (e.kind === "owned") {
|
|
778
|
+
for (const row of e.bag.rows()) out.push({ scope: token, ...owner, ...row });
|
|
779
|
+
} else {
|
|
780
|
+
for (const [n, d] of e.decls) {
|
|
781
|
+
out.push({
|
|
782
|
+
scope: token,
|
|
783
|
+
...owner,
|
|
784
|
+
...rowFor(d, e.resolver.get(n), this.foreignWritable(e, n), n, `${token}.`)
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return out;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Build the `EvalContext` expr's `evaluate` consumes: owned scopes as static
|
|
793
|
+
* bags, foreign scopes as their resolvers. `host` carries dialect-function
|
|
794
|
+
* callbacks (PRNG, tag lookups) and is passed through untouched.
|
|
795
|
+
*/
|
|
796
|
+
toEvalContext(host2, opts) {
|
|
797
|
+
const view = this.view(opts?.aliases);
|
|
798
|
+
const scopes = {};
|
|
799
|
+
for (const [token, e] of view) scopes[token] = e.kind === "owned" ? e.bag.values : e.resolver;
|
|
800
|
+
const qualities = this.qualityLadders(view);
|
|
801
|
+
return qualities.size === 0 ? { scopes, host: host2 } : {
|
|
802
|
+
scopes,
|
|
803
|
+
host: host2,
|
|
804
|
+
qualities: (scope, name) => {
|
|
805
|
+
const e = view.get(scope);
|
|
806
|
+
return e ? qualities.get(scope)?.get(normOf(e)(name)) : void 0;
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* The scopes an expression sees: every registered key under its own token,
|
|
812
|
+
* then each alias token pointing at its key's entry (an alias shadows a key of
|
|
813
|
+
* the same name). Keys an engine uses for instance bags (`engine/flow-2/...`)
|
|
814
|
+
* are not valid expression tokens, so they are present but unreachable.
|
|
815
|
+
*/
|
|
816
|
+
view(aliases) {
|
|
817
|
+
const out = new Map(this.scopes);
|
|
818
|
+
for (const [token, key] of Object.entries(aliases ?? {})) {
|
|
819
|
+
const e = this.scopes.get(key);
|
|
820
|
+
if (!e) throw new Error(`alias '@${token}' names '${key}', which is not registered`);
|
|
821
|
+
out.set(token, e);
|
|
822
|
+
}
|
|
823
|
+
return out;
|
|
824
|
+
}
|
|
825
|
+
/** Every quality declaration's ladder, keyed scope token then name (the
|
|
826
|
+
* scope's own normalisation). */
|
|
827
|
+
qualityLadders(view) {
|
|
828
|
+
const out = /* @__PURE__ */ new Map();
|
|
829
|
+
for (const [token, e] of view) {
|
|
830
|
+
for (const [n, d] of declsOf(e)) {
|
|
831
|
+
if (d.type !== "quality" || d.stages === void 0) continue;
|
|
832
|
+
let m = out.get(token);
|
|
833
|
+
if (!m) {
|
|
834
|
+
m = /* @__PURE__ */ new Map();
|
|
835
|
+
out.set(token, m);
|
|
836
|
+
}
|
|
837
|
+
m.set(n, d.stages);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Build the `ExpressionSchema` expr's validator consumes. Scopes with no
|
|
844
|
+
* declarations are **omitted** (opaque - references into them are not flagged);
|
|
845
|
+
* declared scopes contribute their property types for validation. Aliases
|
|
846
|
+
* apply as they do to `toEvalContext`, so a condition written against `@scene`
|
|
847
|
+
* validates against the instance bag the engine names.
|
|
848
|
+
*/
|
|
849
|
+
toSchema(opts) {
|
|
850
|
+
const properties = /* @__PURE__ */ new Map();
|
|
851
|
+
for (const [token, e] of this.view(opts?.aliases)) {
|
|
852
|
+
const decls = declsOf(e);
|
|
853
|
+
if (decls.length === 0) continue;
|
|
854
|
+
const m = /* @__PURE__ */ new Map();
|
|
855
|
+
for (const [n, d] of decls) m.set(n, {
|
|
856
|
+
type: d.type,
|
|
857
|
+
enumValues: d.values,
|
|
858
|
+
...d.stages !== void 0 ? { stages: d.stages } : {}
|
|
859
|
+
});
|
|
860
|
+
properties.set(token, m);
|
|
861
|
+
}
|
|
862
|
+
return { properties };
|
|
863
|
+
}
|
|
864
|
+
/** Serialize **owned** scopes (foreign scopes are the game's, and the game
|
|
865
|
+
* saves them), as bare bags keyed by token, plus any values still parked, so
|
|
866
|
+
* a save taken before every engine has re-registered loses nothing. The
|
|
867
|
+
* registry knows nothing about game saves: a game embeds this in its own. */
|
|
868
|
+
save() {
|
|
869
|
+
const out = {};
|
|
870
|
+
for (const [token, e] of this.scopes) if (e.kind === "owned") out[token] = e.bag.save();
|
|
871
|
+
for (const [token, vals] of this.parked) out[token] = structuredClone(vals);
|
|
872
|
+
return out;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Restore from a `save` blob. An owned scope lays its section over its current
|
|
876
|
+
* values (the bag's `load` rule). A section for a key nobody has registered
|
|
877
|
+
* yet is PARKED and handed over when that key registers, so a game can load
|
|
878
|
+
* its registry before its engines have reopened their flows or decks. A
|
|
879
|
+
* section for a foreign scope is ignored: those values are the game's.
|
|
880
|
+
*
|
|
881
|
+
* A load replaces whatever was parked before it: it is a whole restore, and
|
|
882
|
+
* residue from an earlier load must not leak into this one.
|
|
883
|
+
*
|
|
884
|
+
* Changed in 0.7.0: sections for unregistered keys used to be dropped.
|
|
885
|
+
*/
|
|
886
|
+
load(blob, opts) {
|
|
887
|
+
if (!opts?.keepParked) this.parked.clear();
|
|
888
|
+
for (const [token, vals] of Object.entries(blob)) {
|
|
889
|
+
const e = this.scopes.get(token);
|
|
890
|
+
if (e?.kind === "owned") e.bag.load(vals);
|
|
891
|
+
else if (!e) this.parked.set(token, structuredClone(vals));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* `save()` wrapped with a version stamp.
|
|
896
|
+
*
|
|
897
|
+
* @deprecated Versioning belongs to the save that embeds the values; no
|
|
898
|
+
* engine ever called this. Embed `save()` in your own versioned save.
|
|
899
|
+
* Removed at the next breaking release.
|
|
900
|
+
*/
|
|
901
|
+
saveFragment() {
|
|
902
|
+
return { version: SAVE_FRAGMENT_VERSION, scopes: this.save() };
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Restore from a versioned fragment; an unsupported version throws.
|
|
906
|
+
*
|
|
907
|
+
* @deprecated See `saveFragment`. Removed at the next breaking release.
|
|
908
|
+
*/
|
|
909
|
+
loadFragment(fragment) {
|
|
910
|
+
if (fragment.version !== SAVE_FRAGMENT_VERSION) {
|
|
911
|
+
throw new Error(`unsupported owned-state fragment version ${fragment.version} (supported: ${SAVE_FRAGMENT_VERSION})`);
|
|
912
|
+
}
|
|
913
|
+
this.load(fragment.scopes);
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* A token is taken once. There is no reserved-token list: a clash surfaces
|
|
917
|
+
* here, the moment a game combines its engines, which is the only moment
|
|
918
|
+
* anyone knows which engines are present. With owners recorded the error says
|
|
919
|
+
* whose token it already is.
|
|
920
|
+
*/
|
|
921
|
+
assertFree(token, owner) {
|
|
922
|
+
const e = this.scopes.get(token);
|
|
923
|
+
if (!e) return;
|
|
924
|
+
const by = e.owner !== void 0 ? ` by ${e.owner}` : "";
|
|
925
|
+
const wants = owner !== void 0 ? ` (wanted by ${owner})` : "";
|
|
926
|
+
throw new Error(`scope '@${token}' is already registered${by}${wants}`);
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
function declsOf(e) {
|
|
930
|
+
if (e.kind === "foreign") return [...e.decls.entries()];
|
|
931
|
+
return e.bag.declarations().map((d) => [e.bag.normalise(d.name), d]);
|
|
932
|
+
}
|
|
933
|
+
function normOf(e) {
|
|
934
|
+
return e.kind === "foreign" ? e.norm : (n) => e.bag.normalise(n);
|
|
935
|
+
}
|
|
598
936
|
function defaultFor(d) {
|
|
599
937
|
if (d.default !== void 0) return d.default;
|
|
600
938
|
switch (d.type) {
|
|
@@ -625,6 +963,48 @@ function defaultFor(d) {
|
|
|
625
963
|
var tagKey = (groupId, tagId) => `${groupId}${tagId}`;
|
|
626
964
|
var cardIsShared = (card, deckShared) => card.shared ?? deckShared;
|
|
627
965
|
var sharedCap = (card) => card.sharedCopies ?? card.copies ?? 1;
|
|
966
|
+
var OWNER = "Storylet Engine";
|
|
967
|
+
var identity = (n) => n;
|
|
968
|
+
var esc = (id) => id.replace(/%/g, "%25").replace(/\//g, "%2F");
|
|
969
|
+
var unesc = (id) => id.replace(/%2F/g, "/").replace(/%25/g, "%");
|
|
970
|
+
var sharedKey = (kind, id) => `storylets/${kind}/${esc(id)}`;
|
|
971
|
+
var flowPrefix = (flowId) => `storylets/flow/${esc(flowId)}/`;
|
|
972
|
+
var flowKey = (flowId, kind, id) => kind === "story" ? `${flowPrefix(flowId)}story` : `${flowPrefix(flowId)}${kind}/${esc(id)}`;
|
|
973
|
+
var emptyPartitionValues = () => ({ story: {}, box: {}, deck: {}, hand: {}, value: {} });
|
|
974
|
+
function partitionsFromSections(sections, flowIds) {
|
|
975
|
+
const shared = emptyPartitionValues();
|
|
976
|
+
const flows = /* @__PURE__ */ new Map();
|
|
977
|
+
const rest = {};
|
|
978
|
+
const flowOf = (escaped) => {
|
|
979
|
+
const id = unesc(escaped);
|
|
980
|
+
if (!flowIds.has(id)) return void 0;
|
|
981
|
+
let p = flows.get(id);
|
|
982
|
+
if (!p) {
|
|
983
|
+
p = emptyPartitionValues();
|
|
984
|
+
flows.set(id, p);
|
|
985
|
+
}
|
|
986
|
+
return p;
|
|
987
|
+
};
|
|
988
|
+
for (const [key, values] of Object.entries(sections)) {
|
|
989
|
+
let m;
|
|
990
|
+
if (key === "story") shared.story = values;
|
|
991
|
+
else if (m = /^storylets\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) shared[m[1]][unesc(m[2])] = values;
|
|
992
|
+
else if (m = /^storylets\/flow\/([^/]+)\/story$/.exec(key)) {
|
|
993
|
+
const p = flowOf(m[1]);
|
|
994
|
+
if (p) p.story = values;
|
|
995
|
+
} else if (m = /^storylets\/flow\/([^/]+)\/(box|deck|hand|value)\/([^/]+)$/.exec(key)) {
|
|
996
|
+
const p = flowOf(m[1]);
|
|
997
|
+
if (p) p[m[2]][unesc(m[3])] = values;
|
|
998
|
+
} else if (!key.startsWith("storylets/")) rest[key] = values;
|
|
999
|
+
}
|
|
1000
|
+
return { shared, flows, rest };
|
|
1001
|
+
}
|
|
1002
|
+
function sectionsOf(p, keyOf, out) {
|
|
1003
|
+
if (Object.keys(p.story).length > 0) out[keyOf("story")] = p.story;
|
|
1004
|
+
for (const kind of ["box", "deck", "hand", "value"]) {
|
|
1005
|
+
for (const [id, values] of Object.entries(p[kind])) if (Object.keys(values).length > 0) out[keyOf(kind, id)] = values;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
628
1008
|
var bagFromDecls = (decls, pathPrefix) => new PropertyBag(decls, { normalise: (n) => n, pathPrefix });
|
|
629
1009
|
function conditionPasses(v) {
|
|
630
1010
|
if (typeof v === "boolean") return v;
|
|
@@ -809,7 +1189,7 @@ function finishReport(bundle, saved, flows, draft) {
|
|
|
809
1189
|
retypedProperties
|
|
810
1190
|
};
|
|
811
1191
|
}
|
|
812
|
-
var Engine = class {
|
|
1192
|
+
var Engine = class _Engine {
|
|
813
1193
|
internals;
|
|
814
1194
|
seed;
|
|
815
1195
|
onReplacedFlow;
|
|
@@ -819,7 +1199,13 @@ var Engine = class {
|
|
|
819
1199
|
* outlives reset/loadGame (the host's container is the host's). The
|
|
820
1200
|
* self-backed resolver is rebuilt instead. */
|
|
821
1201
|
hostWorld;
|
|
1202
|
+
/** The options this engine was built with: hotSwap builds its replacement from them. */
|
|
1203
|
+
creationOptions;
|
|
1204
|
+
/** How to register each shared scope again, in registration order: a failed
|
|
1205
|
+
* hotSwap puts this engine back exactly as it was. */
|
|
1206
|
+
sharedMounts = [];
|
|
822
1207
|
constructor(bundle, opts = {}) {
|
|
1208
|
+
this.creationOptions = opts;
|
|
823
1209
|
this.seed = opts.seed ?? 0;
|
|
824
1210
|
this.onReplacedFlow = opts.onReplacedFlow;
|
|
825
1211
|
if (opts.world !== void 0) this.hostWorld = opts.world;
|
|
@@ -843,6 +1229,10 @@ var Engine = class {
|
|
|
843
1229
|
flowDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
|
|
844
1230
|
sharedDecls: { story: [], box: /* @__PURE__ */ new Map(), deck: /* @__PURE__ */ new Map(), hand: /* @__PURE__ */ new Map(), value: /* @__PURE__ */ new Map() },
|
|
845
1231
|
shared: void 0,
|
|
1232
|
+
registry: opts.registry ?? new ScopeRegistry(),
|
|
1233
|
+
ownsRegistry: opts.registry === void 0,
|
|
1234
|
+
selfWorld: false,
|
|
1235
|
+
registryView: () => view(),
|
|
846
1236
|
worldResolver: void 0,
|
|
847
1237
|
worldReadOnly: /* @__PURE__ */ new Set(),
|
|
848
1238
|
emitEngine: (flow, event, turn) => {
|
|
@@ -857,6 +1247,19 @@ var Engine = class {
|
|
|
857
1247
|
engineTracing: () => this.engineTraceHandlers.size > 0
|
|
858
1248
|
};
|
|
859
1249
|
this.internals = internals;
|
|
1250
|
+
let viewRevision = -1;
|
|
1251
|
+
let viewCache = { scopes: {}, qualities: void 0 };
|
|
1252
|
+
const view = () => {
|
|
1253
|
+
const reg = internals.registry;
|
|
1254
|
+
if (reg.revision !== viewRevision) {
|
|
1255
|
+
const ctx = reg.toEvalContext();
|
|
1256
|
+
const scopes = {};
|
|
1257
|
+
for (const [k, v] of Object.entries(ctx.scopes)) if (!k.includes("/")) scopes[k] = v;
|
|
1258
|
+
viewCache = { scopes, qualities: ctx.qualities };
|
|
1259
|
+
viewRevision = reg.revision;
|
|
1260
|
+
}
|
|
1261
|
+
return viewCache;
|
|
1262
|
+
};
|
|
860
1263
|
indexValueOwners(internals.owners.value, bundle);
|
|
861
1264
|
for (const box of bundle.boxes) {
|
|
862
1265
|
internals.boxesById.set(box.id, box);
|
|
@@ -903,34 +1306,79 @@ var Engine = class {
|
|
|
903
1306
|
internals.sharedDecls = declSet(sharedHalf);
|
|
904
1307
|
this.initShared(this.hostWorld);
|
|
905
1308
|
}
|
|
906
|
-
/** Build the shared stores and
|
|
907
|
-
* engine's
|
|
1309
|
+
/** Build the shared stores, register them and @world, and set up the @world
|
|
1310
|
+
* seam. Once, for the engine's life: reset and loads reseed the bags in
|
|
1311
|
+
* place, so the registry never sees them come and go. */
|
|
908
1312
|
initShared(hostWorld) {
|
|
909
1313
|
const internals = this.internals;
|
|
1314
|
+
const reg = internals.registry;
|
|
1315
|
+
const worldDecls = internals.bundle.world.properties;
|
|
910
1316
|
internals.shared = buildPartition(internals, sharedHalf);
|
|
911
1317
|
internals.worldReadOnly = new Set(internals.bundle.world.properties.filter((d) => d.writable === false).map((d) => d.name));
|
|
1318
|
+
const registered = [];
|
|
1319
|
+
const mount = (key, register) => {
|
|
1320
|
+
register();
|
|
1321
|
+
registered.push(key);
|
|
1322
|
+
this.sharedMounts.push({ key, mount: register });
|
|
1323
|
+
};
|
|
1324
|
+
try {
|
|
1325
|
+
const story = internals.shared.story;
|
|
1326
|
+
mount("story", () => {
|
|
1327
|
+
reg.mountOwned("story", story, { owner: OWNER });
|
|
1328
|
+
});
|
|
1329
|
+
for (const kind of OWNED_SCOPES) {
|
|
1330
|
+
for (const [id, bag] of internals.shared[kind]) {
|
|
1331
|
+
if (bag.declarations().length === 0) continue;
|
|
1332
|
+
mount(sharedKey(kind, id), () => {
|
|
1333
|
+
reg.mountOwned(sharedKey(kind, id), bag, { owner: OWNER });
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (hostWorld !== void 0) {
|
|
1338
|
+
mount("world", () => {
|
|
1339
|
+
reg.defineForeign("world", hostWorld, worldDecls, { normalise: identity, owner: OWNER });
|
|
1340
|
+
});
|
|
1341
|
+
} else if (internals.ownsRegistry && !reg.has("world")) {
|
|
1342
|
+
reg.defineOwned("world", worldDecls, { normalise: identity, pathPrefix: "world.", owner: OWNER });
|
|
1343
|
+
const worldBag = reg.ownedBag("world");
|
|
1344
|
+
registered.push("world");
|
|
1345
|
+
this.sharedMounts.push({ key: "world", mount: () => {
|
|
1346
|
+
reg.mountOwned("world", worldBag, { owner: OWNER });
|
|
1347
|
+
} });
|
|
1348
|
+
internals.selfWorld = true;
|
|
1349
|
+
}
|
|
1350
|
+
} catch (e) {
|
|
1351
|
+
for (const k of registered) reg.remove(k, { keep: true });
|
|
1352
|
+
throw e;
|
|
1353
|
+
}
|
|
1354
|
+
internals.worldResolver = {
|
|
1355
|
+
get: (n) => reg.get("world", n),
|
|
1356
|
+
set: (n, v) => {
|
|
1357
|
+
reg.set("world", n, v);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
912
1360
|
if (hostWorld !== void 0) {
|
|
913
|
-
internals.
|
|
914
|
-
|
|
915
|
-
internals.worldSet = set !== void 0 ? (n, v) => {
|
|
916
|
-
set(n, v);
|
|
1361
|
+
internals.worldSet = hostWorld.set !== void 0 ? (n, v) => {
|
|
1362
|
+
reg.set("world", n, v, { host: true });
|
|
917
1363
|
} : void 0;
|
|
918
1364
|
} else {
|
|
919
|
-
const bag = bagFromDecls(internals.bundle.world.properties, "world.");
|
|
920
|
-
internals.worldResolver = {
|
|
921
|
-
// The engine writes through worldSet below, not through this; the `set`
|
|
922
|
-
// is the resolver's SHAPE, so @world still reads as writable to anything
|
|
923
|
-
// inspecting the seam, and it is the story's door: no host flag on it.
|
|
924
|
-
get: (n) => bag.get(n),
|
|
925
|
-
set: (n, v) => {
|
|
926
|
-
bag.set(n, v);
|
|
927
|
-
}
|
|
928
|
-
};
|
|
929
1365
|
internals.worldSet = (n, v, host2) => {
|
|
930
|
-
|
|
1366
|
+
reg.set("world", n, v, host2 === true ? { host: true } : void 0);
|
|
931
1367
|
};
|
|
932
1368
|
}
|
|
933
1369
|
}
|
|
1370
|
+
/** Every shared bag back to its declared defaults, in place (the registry
|
|
1371
|
+
* keeps them registered), the self-backed @world included. */
|
|
1372
|
+
reseedShared() {
|
|
1373
|
+
const { shared, sharedDecls, registry } = this.internals;
|
|
1374
|
+
shared.story.reseed(sharedDecls.story);
|
|
1375
|
+
for (const kind of OWNED_SCOPES) {
|
|
1376
|
+
for (const [id, bag] of shared[kind]) bag.reseed(sharedDecls[kind].get(id) ?? []);
|
|
1377
|
+
}
|
|
1378
|
+
if (this.internals.selfWorld) {
|
|
1379
|
+
registry.reseedOwned("world", this.internals.bundle.world.properties);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
934
1382
|
/** Quality ladders by scope for the eval channel (design/quality.md):
|
|
935
1383
|
* world/story keyed by name; box/deck/value keyed by owner id then name.
|
|
936
1384
|
* Built once - a bundle's declarations never change. Ladders are
|
|
@@ -962,6 +1410,13 @@ var Engine = class {
|
|
|
962
1410
|
* state; shared state is untouched. There is no default flow: "main" is
|
|
963
1411
|
* a caller convention, not an engine rule. */
|
|
964
1412
|
openFlow(id, opts = {}) {
|
|
1413
|
+
return this.open(id, opts, false);
|
|
1414
|
+
}
|
|
1415
|
+
/** openFlow, and loadGame's rebuild. `claim` says the new flow's bags take
|
|
1416
|
+
* the values the registry holds for them (a load); a fresh open is a reset
|
|
1417
|
+
* of that name, so anything waiting for it is discarded first. */
|
|
1418
|
+
open(id, opts, claim) {
|
|
1419
|
+
this.assertExternalScopes();
|
|
965
1420
|
const otherClaims = opts.restore !== void 0 ? this.sharedClaimsExcept(id) : void 0;
|
|
966
1421
|
const existing = this.flowsById.get(id);
|
|
967
1422
|
if (existing) {
|
|
@@ -969,6 +1424,7 @@ var Engine = class {
|
|
|
969
1424
|
if (dealt > 0) this.onReplacedFlow?.(id, dealt);
|
|
970
1425
|
existing.markClosed();
|
|
971
1426
|
}
|
|
1427
|
+
if (!claim) this.internals.registry.discardParked(flowPrefix(id));
|
|
972
1428
|
const flow = new Flow(this, this.internals, id, opts.seed ?? this.seed);
|
|
973
1429
|
this.flowsById.set(id, flow);
|
|
974
1430
|
if (opts.restore !== void 0) {
|
|
@@ -1005,11 +1461,22 @@ var Engine = class {
|
|
|
1005
1461
|
* self-backed @world included; a host-bound @world is the host's and is
|
|
1006
1462
|
* not touched). */
|
|
1007
1463
|
reset() {
|
|
1464
|
+
this.dropRun(false);
|
|
1465
|
+
this.reseedShared();
|
|
1466
|
+
this.internals.registry.discardParked("storylets/");
|
|
1467
|
+
}
|
|
1468
|
+
/** End the run: clear the log, close every flow, forget spent cards. Each
|
|
1469
|
+
* flow's bags leave the registry; `keepFlows` names the flows whose values
|
|
1470
|
+
* are kept there for the flow that replaces them (a load into the game's
|
|
1471
|
+
* registry). */
|
|
1472
|
+
dropRun(keepFlows) {
|
|
1008
1473
|
this.engineLog = [];
|
|
1009
|
-
for (const flow of this.flowsById
|
|
1474
|
+
for (const [id, flow] of this.flowsById) {
|
|
1475
|
+
flow.releaseBags(keepFlows !== false && keepFlows.has(id));
|
|
1476
|
+
flow.markClosed();
|
|
1477
|
+
}
|
|
1010
1478
|
this.flowsById.clear();
|
|
1011
1479
|
this.spent.clear();
|
|
1012
|
-
this.initShared(this.hostWorld);
|
|
1013
1480
|
}
|
|
1014
1481
|
// --- shared scarcity (design/shared-scarcity.md) -----------------------------
|
|
1015
1482
|
/** Cards a shared `redraw: "never"` has taken out of the world, by card id.
|
|
@@ -1071,7 +1538,7 @@ var Engine = class {
|
|
|
1071
1538
|
*/
|
|
1072
1539
|
getProperty(path) {
|
|
1073
1540
|
const found = this.resolveShared(path);
|
|
1074
|
-
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.bag.get(found.name);
|
|
1541
|
+
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);
|
|
1075
1542
|
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
1076
1543
|
return value;
|
|
1077
1544
|
}
|
|
@@ -1084,6 +1551,10 @@ var Engine = class {
|
|
|
1084
1551
|
this.internals.worldSet(found.name, value, true);
|
|
1085
1552
|
return;
|
|
1086
1553
|
}
|
|
1554
|
+
if (found.kind === "scope") {
|
|
1555
|
+
this.internals.registry.set(found.token, found.name, value, { host: true });
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1087
1558
|
found.bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
|
|
1088
1559
|
}
|
|
1089
1560
|
resolveShared(path) {
|
|
@@ -1092,6 +1563,9 @@ var Engine = class {
|
|
|
1092
1563
|
throw new Error(`"${path}" is per-flow state - read it on a Flow, not the Engine`);
|
|
1093
1564
|
};
|
|
1094
1565
|
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
1566
|
+
if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
|
|
1567
|
+
return { kind: "scope", token: parts[0], name: parts[1] };
|
|
1568
|
+
}
|
|
1095
1569
|
if (parts.length === 2 && parts[0] === "story") {
|
|
1096
1570
|
const name = parts[1];
|
|
1097
1571
|
if (this.internals.shared.story.get(name) !== void 0) return { kind: "bag", bag: this.internals.shared.story, name };
|
|
@@ -1119,6 +1593,18 @@ var Engine = class {
|
|
|
1119
1593
|
diagnose(message) {
|
|
1120
1594
|
this.internals.emitEngine("", { type: "diagnostic", where: "property address", message });
|
|
1121
1595
|
}
|
|
1596
|
+
/** Content that names another engine's scope (`@patter.visits`) runs only
|
|
1597
|
+
* where that engine is on this registry: without it every read would answer
|
|
1598
|
+
* false and every write fail, so the flow is refused as it opens, before
|
|
1599
|
+
* anything changes. By then a game has built all of its engines, whatever
|
|
1600
|
+
* order it built them in. The same message on every runtime. */
|
|
1601
|
+
assertExternalScopes() {
|
|
1602
|
+
for (const token of this.internals.bundle.externalScopes ?? []) {
|
|
1603
|
+
if (!this.internals.registry.has(token)) {
|
|
1604
|
+
throw new Error(`this content names @${token}, which no engine on this registry registered: give every engine the game's one registry`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1122
1608
|
/** The shared surface as examiner rows: @world (read through the
|
|
1123
1609
|
* resolver) then the shared partitions. Per-flow rows live on each Flow. */
|
|
1124
1610
|
listProperties() {
|
|
@@ -1170,15 +1656,77 @@ var Engine = class {
|
|
|
1170
1656
|
return () => this.engineTraceHandlers.delete(handler);
|
|
1171
1657
|
}
|
|
1172
1658
|
// --- persistence (schema 4) -------------------------------------------------
|
|
1173
|
-
/**
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1659
|
+
/**
|
|
1660
|
+
* Live bundle refresh: rebuild on an edited bundle with the whole run carried
|
|
1661
|
+
* over, and return the replacement with the report its load produced.
|
|
1662
|
+
*
|
|
1663
|
+
* Standalone, that is a save and a load into a new engine, and this one is left
|
|
1664
|
+
* untouched (discard it). With the game's registry the two cannot both hold the
|
|
1665
|
+
* same keys, so this engine is spent afterwards (its flows closed, the
|
|
1666
|
+
* replacement holding everything on the same registry): it carries its
|
|
1667
|
+
* own values into the snapshot, steps out of the registry, and the replacement
|
|
1668
|
+
* loads them the way a standalone save loads: so the report covers the
|
|
1669
|
+
* properties the edit dropped, defaulted, or retyped, and a dropped property
|
|
1670
|
+
* is dropped rather than kept. Values the game loaded that were still waiting
|
|
1671
|
+
* for a flow of this engine carry across as they were, and nothing belonging
|
|
1672
|
+
* to any other engine is touched. A save for another project is refused before
|
|
1673
|
+
* anything moves; if the rebuild fails for any other reason, this engine takes
|
|
1674
|
+
* its registrations back and is left exactly as it was.
|
|
1675
|
+
*/
|
|
1676
|
+
hotSwap(bundle, opts = {}) {
|
|
1677
|
+
if (bundle.content.project !== this.internals.bundle.content.project) {
|
|
1678
|
+
throw new Error(`save is for project "${this.internals.bundle.content.project}", bundle is "${bundle.content.project}"`);
|
|
1679
|
+
}
|
|
1680
|
+
const options = { ...this.creationOptions, ...opts };
|
|
1681
|
+
const snapshot = this.saveGame();
|
|
1682
|
+
const reg = this.internals.registry;
|
|
1683
|
+
if (this.internals.ownsRegistry) {
|
|
1684
|
+
const next2 = new _Engine(bundle, { ...options, registry: void 0 });
|
|
1685
|
+
return { engine: next2, report: next2.loadGame(snapshot) };
|
|
1686
|
+
}
|
|
1687
|
+
const mine = {};
|
|
1688
|
+
for (const [key, values] of Object.entries(reg.save())) {
|
|
1689
|
+
if (key === "story" || key.startsWith("storylets/")) mine[key] = values;
|
|
1690
|
+
}
|
|
1691
|
+
const registeredKeys = /* @__PURE__ */ new Set([
|
|
1692
|
+
...this.sharedMounts.map((m) => m.key),
|
|
1693
|
+
...[...this.flowsById.values()].flatMap((f) => f.registeredKeys())
|
|
1694
|
+
]);
|
|
1695
|
+
const waiting = Object.fromEntries(Object.entries(mine).filter(([key]) => !registeredKeys.has(key)));
|
|
1696
|
+
for (const { key } of this.sharedMounts) if (reg.has(key)) reg.remove(key);
|
|
1697
|
+
for (const flow of this.flowsById.values()) flow.releaseBags(false);
|
|
1698
|
+
let next;
|
|
1699
|
+
try {
|
|
1700
|
+
next = new _Engine(bundle, { ...options, registry: reg });
|
|
1701
|
+
const report = next.loadGame({ ...snapshot, registry: mine });
|
|
1702
|
+
const stillWaiting = Object.fromEntries(Object.entries(waiting).filter(([key]) => !reg.has(key)));
|
|
1703
|
+
if (Object.keys(stillWaiting).length > 0) reg.load(stillWaiting, { keepParked: true });
|
|
1704
|
+
this.dropRun(false);
|
|
1705
|
+
return { engine: next, report };
|
|
1706
|
+
} catch (e) {
|
|
1707
|
+
next?.dropRun(false);
|
|
1708
|
+
for (const { key } of next?.sharedMounts ?? []) if (reg.has(key)) reg.remove(key);
|
|
1709
|
+
reg.discardParked("storylets/");
|
|
1710
|
+
for (const { mount } of this.sharedMounts) mount();
|
|
1711
|
+
for (const flow of this.flowsById.values()) flow.mountBags();
|
|
1712
|
+
this.reseedShared();
|
|
1713
|
+
reg.load(mine, { keepParked: true });
|
|
1714
|
+
throw e;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
/** The whole engine's NON-property state, one envelope: the spent cards
|
|
1718
|
+
* once, then every live flow (board, clocks, cooldowns, PRNG, play log)
|
|
1719
|
+
* keyed by its id. The property values are the registry's: a standalone
|
|
1720
|
+
* engine (one that made its own registry) carries them here under
|
|
1721
|
+
* `registry`, self-backed @world included; a game that passed a registry
|
|
1722
|
+
* saves it once itself, beside each engine's envelope. */
|
|
1176
1723
|
saveGame() {
|
|
1177
1724
|
return structuredClone({
|
|
1178
|
-
schema:
|
|
1725
|
+
schema: SAVE_SCHEMA,
|
|
1179
1726
|
content: this.internals.bundle.content,
|
|
1180
|
-
|
|
1181
|
-
|
|
1727
|
+
...this.internals.ownsRegistry ? { registry: this.internals.registry.save() } : {},
|
|
1728
|
+
shared: { spent: [...this.spent].sort() },
|
|
1729
|
+
flows: Object.fromEntries([...this.flowsById].map(([id, flow]) => [id, flow.snapshot(false)]))
|
|
1182
1730
|
});
|
|
1183
1731
|
}
|
|
1184
1732
|
/** ONE flow's blob, to park a visit that is walking away: the same shape
|
|
@@ -1190,7 +1738,7 @@ var Engine = class {
|
|
|
1190
1738
|
saveFlow(id) {
|
|
1191
1739
|
const flow = this.flowsById.get(id);
|
|
1192
1740
|
if (!flow) throw new Error(`unknown flow "${id}"`);
|
|
1193
|
-
return structuredClone(flow.snapshot());
|
|
1741
|
+
return structuredClone(flow.snapshot(true));
|
|
1194
1742
|
}
|
|
1195
1743
|
/** What `loadGame(envelope)` would do that is not a plain restore, without
|
|
1196
1744
|
* doing any of it (design/engine-server.md 4.9). Pure: nothing on this
|
|
@@ -1213,16 +1761,31 @@ var Engine = class {
|
|
|
1213
1761
|
* Handles held from before the load are closed and inert (Patter's
|
|
1214
1762
|
* rule); take fresh ones from getFlow()/flows().
|
|
1215
1763
|
*
|
|
1764
|
+
* Property values come from the registry. An envelope that carries them
|
|
1765
|
+
* (a standalone engine's, or a version 1 envelope) has them walked, cleaned,
|
|
1766
|
+
* and moved into the registry here, over fresh defaults. Otherwise the game
|
|
1767
|
+
* loads its registry itself, before or after this call: each flow's bags
|
|
1768
|
+
* are handed back to the registry with their values, and the restored
|
|
1769
|
+
* flows claim them. The report then covers only what this envelope holds;
|
|
1770
|
+
* the registry's own load rule applies to the values.
|
|
1771
|
+
*
|
|
1216
1772
|
* Returns the report `previewLoad` would have given for this envelope: the
|
|
1217
1773
|
* drift tolerance that makes a load forgiving is what hides its cost, so
|
|
1218
1774
|
* the cost comes back with the load whether or not anybody looked first. */
|
|
1219
1775
|
loadGame(envelope) {
|
|
1220
1776
|
this.assertSameProject(envelope);
|
|
1777
|
+
this.assertExternalScopes();
|
|
1221
1778
|
const plan = this.planLoad(structuredClone(envelope));
|
|
1222
|
-
this.
|
|
1223
|
-
|
|
1779
|
+
const reg = this.internals.registry;
|
|
1780
|
+
if (plan.sections !== void 0) {
|
|
1781
|
+
this.reset();
|
|
1782
|
+
if (this.internals.ownsRegistry) reg.load(plan.sections);
|
|
1783
|
+
else reg.load(plan.sections, { keepParked: true });
|
|
1784
|
+
} else {
|
|
1785
|
+
this.dropRun(new Set(plan.flows.map(([id]) => id)));
|
|
1786
|
+
}
|
|
1224
1787
|
for (const id of plan.spent) this.spent.add(id);
|
|
1225
|
-
for (const [id, clean] of plan.flows) this.
|
|
1788
|
+
for (const [id, clean] of plan.flows) this.open(id, {}, true).restore(clean);
|
|
1226
1789
|
return plan.report;
|
|
1227
1790
|
}
|
|
1228
1791
|
assertSameProject(envelope) {
|
|
@@ -1234,26 +1797,43 @@ var Engine = class {
|
|
|
1234
1797
|
* half writes. Nothing here touches the engine, which is what lets
|
|
1235
1798
|
* previewLoad and loadGame share it. */
|
|
1236
1799
|
planLoad(envelope) {
|
|
1800
|
+
const schema = envelope.schema;
|
|
1801
|
+
if (schema !== SAVE_SCHEMA && schema !== SAVE_SCHEMA_V1) throw new Error(`unsupported save schema: ${String(schema)}`);
|
|
1237
1802
|
const draft = emptyDraft();
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1803
|
+
const flowIds = new Set(Object.keys(envelope.flows ?? {}));
|
|
1804
|
+
let moved;
|
|
1805
|
+
if (envelope.schema === SAVE_SCHEMA_V1) {
|
|
1806
|
+
moved = {
|
|
1807
|
+
shared: envelope.shared?.props ?? emptyPartitionValues(),
|
|
1808
|
+
flows: new Map(Object.entries(envelope.flows ?? {}).map(([id, f]) => [id, f.props ?? emptyPartitionValues()])),
|
|
1809
|
+
rest: {}
|
|
1810
|
+
};
|
|
1811
|
+
} else if (envelope.registry !== void 0) {
|
|
1812
|
+
moved = partitionsFromSections(envelope.registry, flowIds);
|
|
1813
|
+
}
|
|
1814
|
+
const shared = moved !== void 0 ? walkPartition(this.internals, this.internals.sharedDecls, moved.shared, void 0, draft) : void 0;
|
|
1245
1815
|
const spent = [];
|
|
1246
1816
|
for (const cardId of envelope.shared?.spent ?? []) {
|
|
1247
1817
|
if (this.internals.cardsById.has(cardId)) spent.push(cardId);
|
|
1248
1818
|
else draft.droppedSpent.push(cardId);
|
|
1249
1819
|
}
|
|
1250
1820
|
const flows = [];
|
|
1821
|
+
const sections = moved !== void 0 ? { ...moved.rest } : void 0;
|
|
1822
|
+
if (sections !== void 0 && shared !== void 0) {
|
|
1823
|
+
sectionsOf(shared, (kind, id) => kind === "story" ? "story" : sharedKey(kind, id), sections);
|
|
1824
|
+
}
|
|
1251
1825
|
for (const [id, saved] of Object.entries(envelope.flows ?? {})) {
|
|
1252
|
-
flows.
|
|
1826
|
+
const withProps = moved !== void 0 ? { ...saved, props: moved.flows.get(id) ?? emptyPartitionValues() } : { ...saved };
|
|
1827
|
+
const clean = this.planFlowRestore(id, withProps, void 0, draft);
|
|
1828
|
+
if (sections !== void 0 && clean.props !== void 0) {
|
|
1829
|
+
sectionsOf(clean.props, (kind, owner) => flowKey(id, kind, owner), sections);
|
|
1830
|
+
delete clean.props;
|
|
1831
|
+
}
|
|
1832
|
+
flows.push([id, clean]);
|
|
1253
1833
|
}
|
|
1254
1834
|
return {
|
|
1255
1835
|
report: finishReport(this.internals.bundle.content, envelope.content, flows.map(([id]) => id), draft),
|
|
1256
|
-
|
|
1836
|
+
...sections !== void 0 ? { sections } : {},
|
|
1257
1837
|
spent,
|
|
1258
1838
|
flows
|
|
1259
1839
|
};
|
|
@@ -1264,7 +1844,7 @@ var Engine = class {
|
|
|
1264
1844
|
* there is nobody else to compete with. */
|
|
1265
1845
|
planFlowRestore(id, saved, otherClaims, draft) {
|
|
1266
1846
|
const internals = this.internals;
|
|
1267
|
-
const props = walkPartition(internals, internals.flowDecls, saved.props, id, draft);
|
|
1847
|
+
const props = saved.props !== void 0 ? walkPartition(internals, internals.flowDecls, saved.props, id, draft) : void 0;
|
|
1268
1848
|
const cooldowns = {};
|
|
1269
1849
|
for (const [cardId, turn] of Object.entries(saved.cooldowns ?? {})) {
|
|
1270
1850
|
if (internals.cardsById.has(cardId)) cooldowns[cardId] = turn;
|
|
@@ -1305,7 +1885,7 @@ var Engine = class {
|
|
|
1305
1885
|
board[handId] = kept;
|
|
1306
1886
|
}
|
|
1307
1887
|
return {
|
|
1308
|
-
props,
|
|
1888
|
+
...props !== void 0 ? { props } : {},
|
|
1309
1889
|
turns: saved.turns ?? {},
|
|
1310
1890
|
prng: saved.prng,
|
|
1311
1891
|
cooldowns,
|
|
@@ -1348,8 +1928,12 @@ var Flow = class {
|
|
|
1348
1928
|
lastPlayOf = /* @__PURE__ */ new Map();
|
|
1349
1929
|
tagPlayCount = /* @__PURE__ */ new Map();
|
|
1350
1930
|
lastPlayInTag = /* @__PURE__ */ new Map();
|
|
1351
|
-
/** The per-flow property partitions (the not-shared halves)
|
|
1931
|
+
/** The per-flow property partitions (the not-shared halves), each bag
|
|
1932
|
+
* that declares something registered under this flow's keys. */
|
|
1352
1933
|
stores;
|
|
1934
|
+
registered = [];
|
|
1935
|
+
/** This flow's bags that declare something, under their registry keys. */
|
|
1936
|
+
bagKeys = [];
|
|
1353
1937
|
traceHandlers = /* @__PURE__ */ new Set();
|
|
1354
1938
|
logEntries = [];
|
|
1355
1939
|
logSeq = 0;
|
|
@@ -1367,6 +1951,13 @@ var Flow = class {
|
|
|
1367
1951
|
this.id = id;
|
|
1368
1952
|
this.prng = makePrng(seed);
|
|
1369
1953
|
this.stores = buildPartition(internals, flowHalf);
|
|
1954
|
+
const put = (key, bag) => {
|
|
1955
|
+
if (bag.declarations().length === 0) return;
|
|
1956
|
+
this.bagKeys.push([key, bag]);
|
|
1957
|
+
};
|
|
1958
|
+
put(flowKey(id, "story"), this.stores.story);
|
|
1959
|
+
for (const kind of OWNED_SCOPES) for (const [owner, bag] of this.stores[kind]) put(flowKey(id, kind, owner), bag);
|
|
1960
|
+
this.mountBags();
|
|
1370
1961
|
for (const box of internals.bundle.boxes) {
|
|
1371
1962
|
this.turnCounts.set(box.id, 0);
|
|
1372
1963
|
for (const hand of box.hands) this.boardContents.set(hand.id, []);
|
|
@@ -1394,8 +1985,28 @@ var Flow = class {
|
|
|
1394
1985
|
}
|
|
1395
1986
|
/** @internal */
|
|
1396
1987
|
markClosed() {
|
|
1988
|
+
this.releaseBags(false);
|
|
1397
1989
|
this.closed = true;
|
|
1398
1990
|
}
|
|
1991
|
+
/** @internal - take this flow's bags out of the registry; with `keep`, their
|
|
1992
|
+
* values wait there for the flow that replaces this one (a load into the
|
|
1993
|
+
* game's registry). Idempotent. */
|
|
1994
|
+
releaseBags(keep) {
|
|
1995
|
+
for (const key of this.registered) this.internals.registry.remove(key, { keep });
|
|
1996
|
+
this.registered.length = 0;
|
|
1997
|
+
}
|
|
1998
|
+
/** @internal - the registry keys this flow holds right now. */
|
|
1999
|
+
registeredKeys() {
|
|
2000
|
+
return [...this.registered];
|
|
2001
|
+
}
|
|
2002
|
+
/** @internal - register this flow's bags (again): at construction, and when a
|
|
2003
|
+
* failed hotSwap hands them back. Each claims what the registry holds for it. */
|
|
2004
|
+
mountBags() {
|
|
2005
|
+
for (const [key, bag] of this.bagKeys) {
|
|
2006
|
+
this.internals.registry.mountOwned(key, bag, { owner: OWNER });
|
|
2007
|
+
this.registered.push(key);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
1399
2010
|
assertOpen() {
|
|
1400
2011
|
if (this.closed) throw new Error(`flow "${this.id}" is closed`);
|
|
1401
2012
|
}
|
|
@@ -1532,8 +2143,12 @@ var Flow = class {
|
|
|
1532
2143
|
* is the flow's MERGED view - its own copies over the shared values,
|
|
1533
2144
|
* names disjoint - and @world reads through the engine's resolver. */
|
|
1534
2145
|
evalCtx(box, deck, handEnv) {
|
|
2146
|
+
const others = this.internals.registryView();
|
|
1535
2147
|
return {
|
|
1536
2148
|
scopes: {
|
|
2149
|
+
// Every other engine's game-wide scope first (every engine reads every
|
|
2150
|
+
// scope); this engine's own tokens are its merged views, over the top.
|
|
2151
|
+
...others.scopes,
|
|
1537
2152
|
world: this.internals.worldResolver,
|
|
1538
2153
|
story: this.storyReader,
|
|
1539
2154
|
box: this.boxReaders.get(box.id) ?? {},
|
|
@@ -1544,8 +2159,8 @@ var Flow = class {
|
|
|
1544
2159
|
// The quality channel, answering for THIS ask's box and deck. Only wired
|
|
1545
2160
|
// when a quality exists, so a bundle without one evaluates byte-
|
|
1546
2161
|
// identically to before the feature.
|
|
1547
|
-
...this.internals.hasQualities ? {
|
|
1548
|
-
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) :
|
|
2162
|
+
...this.internals.hasQualities || others.qualities !== void 0 ? {
|
|
2163
|
+
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)
|
|
1549
2164
|
} : {}
|
|
1550
2165
|
};
|
|
1551
2166
|
}
|
|
@@ -2184,8 +2799,17 @@ var Flow = class {
|
|
|
2184
2799
|
if (source.kind === "criteria") throw new Error(`@hand.${name} is a chosen tag / criteria name and cannot be written`);
|
|
2185
2800
|
return this.landIn(source.kind, source.id, name, value, `${this.address(source.kind, source.id)}.${name}`);
|
|
2186
2801
|
}
|
|
2187
|
-
default:
|
|
2802
|
+
default: {
|
|
2803
|
+
if (this.internals.registry.has(scope)) {
|
|
2804
|
+
const prev = this.internals.registry.get(scope, name);
|
|
2805
|
+
this.internals.registry.set(scope, name, value);
|
|
2806
|
+
return { path: `${scope}.${name}`, ...prev !== void 0 ? { prev } : {} };
|
|
2807
|
+
}
|
|
2808
|
+
if (this.internals.bundle.externalScopes?.includes(scope)) {
|
|
2809
|
+
throw new Error(`@${scope}.${name} cannot be written: no engine on this registry registered @${scope}`);
|
|
2810
|
+
}
|
|
2188
2811
|
throw new Error(`bad change target scope "@${scope}"`);
|
|
2812
|
+
}
|
|
2189
2813
|
}
|
|
2190
2814
|
}
|
|
2191
2815
|
/** Advance one box's clock (schema 3.4): a turn is one draw-from-stock
|
|
@@ -2278,7 +2902,7 @@ var Flow = class {
|
|
|
2278
2902
|
getProperty(path) {
|
|
2279
2903
|
this.assertOpen();
|
|
2280
2904
|
const found = this.resolvePath(path);
|
|
2281
|
-
const value = found.kind === "world" ? this.internals.worldResolver.get(found.name) : found.own?.get(found.name) ?? found.shared?.get(found.name);
|
|
2905
|
+
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);
|
|
2282
2906
|
if (value === void 0) throw new Error(`no property at "${path}"`);
|
|
2283
2907
|
return value;
|
|
2284
2908
|
}
|
|
@@ -2290,6 +2914,10 @@ var Flow = class {
|
|
|
2290
2914
|
this.internals.worldSet(found.name, value, true);
|
|
2291
2915
|
return;
|
|
2292
2916
|
}
|
|
2917
|
+
if (found.kind === "scope") {
|
|
2918
|
+
this.internals.registry.set(found.token, found.name, value, { host: true });
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2293
2921
|
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;
|
|
2294
2922
|
if (bag === void 0) throw new Error(`no property at "${path}"`);
|
|
2295
2923
|
bag.set(found.name, value, { silent: true, reason: "host setProperty", host: true });
|
|
@@ -2297,6 +2925,9 @@ var Flow = class {
|
|
|
2297
2925
|
resolvePath(path) {
|
|
2298
2926
|
const parts = path.split(".");
|
|
2299
2927
|
if (parts.length === 2 && parts[0] === "world") return { kind: "world", name: parts[1] };
|
|
2928
|
+
if (parts.length === 2 && parts[0] !== "story" && this.internals.registry.has(parts[0])) {
|
|
2929
|
+
return { kind: "scope", token: parts[0], name: parts[1] };
|
|
2930
|
+
}
|
|
2300
2931
|
if (parts.length === 2 && parts[0] === "story") {
|
|
2301
2932
|
return { kind: "bag", own: this.stores.story, shared: this.internals.shared.story, name: parts[1] };
|
|
2302
2933
|
}
|
|
@@ -2315,10 +2946,11 @@ var Flow = class {
|
|
|
2315
2946
|
throw new Error(`bad property path "${path}"`);
|
|
2316
2947
|
}
|
|
2317
2948
|
// --- persistence (schema 4) -------------------------------------------------
|
|
2318
|
-
/** @internal - this flow's blob inside the engine's envelope
|
|
2319
|
-
|
|
2949
|
+
/** @internal - this flow's blob: inside the engine's envelope without its
|
|
2950
|
+
* properties (the registry has them), or parked whole by saveFlow. */
|
|
2951
|
+
snapshot(withProps) {
|
|
2320
2952
|
return {
|
|
2321
|
-
props: partitionValues(this.stores),
|
|
2953
|
+
...withProps ? { props: partitionValues(this.stores) } : {},
|
|
2322
2954
|
turns: Object.fromEntries(this.turnCounts),
|
|
2323
2955
|
prng: this.prng.state(),
|
|
2324
2956
|
cooldowns: this.cooldowns,
|
|
@@ -2329,7 +2961,7 @@ var Flow = class {
|
|
|
2329
2961
|
/** @internal - restore a freshly opened flow from its blob (loadGame).
|
|
2330
2962
|
* Orphaned keys (deleted entities) drop; new declarations keep defaults. */
|
|
2331
2963
|
restore(saved) {
|
|
2332
|
-
loadPartition(this.stores, saved.props);
|
|
2964
|
+
if (saved.props !== void 0) loadPartition(this.stores, saved.props);
|
|
2333
2965
|
this.turnCounts = new Map(this.internals.bundle.boxes.map((b) => [b.id, 0]));
|
|
2334
2966
|
for (const [boxId, turn] of Object.entries(saved.turns ?? {})) {
|
|
2335
2967
|
if (this.turnCounts.has(boxId)) this.turnCounts.set(boxId, turn);
|