@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.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.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -33,7 +33,10 @@
33
33
  // ancestor walk, then floor), `roomSceneLayout` (the room split into a wall
34
34
  // band and floor stacks, over `roomSceneObjects` and `scenePlacement`),
35
35
  // `roomKindForRoom` (a room's border treatment, from its own rdf:type),
36
- // `carriedItems` (every object placed with the player), and `visitedRoomGraph`
36
+ // `carriedItems` (every object placed with the player), `exitDoorways` (the
37
+ // written ways out of one room, in compass order, each marked with whether
38
+ // this session has walked it — what the room view's own door plates are
39
+ // drawn from), and `visitedRoomGraph`
37
40
  // (a directions-only layout of the rooms a session has actually visited —
38
41
  // see its own header for the exposure discipline). None of these import
39
42
  // anything beyond this module's own exports, which is what keeps a raw
@@ -81,7 +84,7 @@
81
84
  // an edit implies run through the browser bundle's own `session.applyEdit`
82
85
  // (adventure-browser-entry.mjs), never here — this module only renders and
83
86
  // reads.
84
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
87
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel } from "./viz-theme.mjs";
85
88
  import { createTicker } from "./viz-ticker.mjs";
86
89
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
87
90
  import { exposedFacts } from "./adventure-autoplay.mjs";
@@ -109,22 +112,41 @@ export function spriteClassForObject(rows, subject) {
109
112
  return typeRow ? typeRow.object : "portable";
110
113
  }
111
114
 
112
- /** `rows` plus one synthetic rdfs:subClassOf edge from `subject`'s own name
113
- * to its declared sprite class (spriteClassForObject's own result) never
114
- * written back to the corpus, just handed to sprite-templates.mjs's
115
- * resolver so a NAMED object (`cabinet`, `butler`) can carry its own sprite
116
- * template while an unauthored object of the same declared class still
117
- * falls back through it cleanly. Ashcombe Hall's own objects have no
118
- * rdfs:subClassOf chain of their own (each is directly typed, e.g. `cabinet
119
- * rdf:type furniture`); this is the exact same ancestor-walk mechanism
120
- * spider-fly's real poodle-IsA-dog taxonomy already exercises, just
121
- * synthesized at render time for a world whose taxonomy doesn't reach this
122
- * deep. A no-op when the subject's own name already IS its declared class
123
- * (nothing to synthesize). Pure. */
115
+ /** `rows` plus synthetic rdfs:subClassOf edges covering `subject`'s whole
116
+ * class chain never written back to the corpus, just handed to
117
+ * sprite-templates.mjs's resolver so a NAMED object (`cabinet`, `butler`)
118
+ * can carry its own sprite template while an unauthored object of the same
119
+ * class still falls back cleanly, one ancestor at a time, to the nearest
120
+ * ancestor that HAS a sprite. Two kinds of edge are synthesized: one from
121
+ * the subject's own name to its declared sprite class
122
+ * (spriteClassForObject's own result), and one mirroring every `rdf:type`
123
+ * hop further up the chain. The mirroring is what makes the fallback reach
124
+ * past the first hop: the resolver walks rdfs:subClassOf only, so a world
125
+ * that writes `turnip rdf:type vegetable` would otherwise stop at `turnip`
126
+ * and skip a perfectly good `vegetable` sprite. Ashcombe Hall's own objects
127
+ * have no rdfs:subClassOf chain at all (each is directly typed, e.g.
128
+ * `cabinet rdf:type furniture`); this is the same ancestor-walk mechanism
129
+ * spider-fly's real poodle-IsA-dog taxonomy already exercises, synthesized
130
+ * at render time for a world whose taxonomy doesn't reach that deep. Returns
131
+ * `rows` itself when there is nothing to synthesize. Pure. */
124
132
  export function spriteAncestryRows(rows, subject) {
125
133
  const declaredClass = spriteClassForObject(rows, subject);
126
- if (subject === declaredClass) return rows;
127
- return [...(rows || []), { subject, predicate: "rdfs:subClassOf", object: declaredClass }];
134
+ const synthesized = [];
135
+ if (subject !== declaredClass) synthesized.push({ subject, predicate: "rdfs:subClassOf", object: declaredClass });
136
+ const seen = new Set([subject]);
137
+ const queue = [declaredClass];
138
+ while (queue.length) {
139
+ const node = queue.shift();
140
+ if (seen.has(node)) continue;
141
+ seen.add(node);
142
+ for (const row of rows || []) {
143
+ if (row.subject !== node || row.object === node) continue;
144
+ if (row.predicate === "rdf:type") synthesized.push({ subject: node, predicate: "rdfs:subClassOf", object: row.object });
145
+ else if (row.predicate !== "rdfs:subClassOf") continue;
146
+ queue.push(row.object);
147
+ }
148
+ }
149
+ return synthesized.length ? [...(rows || []), ...synthesized] : rows;
128
150
  }
129
151
 
130
152
  /** Every fact row belonging to `subject` — the small `{predicate, object}`
@@ -146,15 +168,21 @@ export function factsForSubject(rows, subject) {
146
168
  * feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
147
169
  * keeps a not-yet-visited location unknown. Pure, self-contained — no
148
170
  * reference to adventure.mjs's own private helpers, since those aren't
149
- * exported. */
150
- export function visibleRoomOf(rows, state, subject) {
171
+ * exported. `actingSubject` is the identity the world places as the
172
+ * carrier/viewer — a parameter, not a baked-in name, the same way
173
+ * adventure.mjs's own `runWorldCommand`/`roomAffordances` take it; it
174
+ * defaults to "player" because that's the one identity Ashcombe Hall's own
175
+ * world facts actually use, not because this function only ever means
176
+ * that individual (mud-viz.mjs threads its own per-character viewer through
177
+ * the same shape, see `mudRoomSceneObjects`/`carriedItemsFor`). */
178
+ export function visibleRoomOf(rows, state, subject, actingSubject = "player") {
151
179
  const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
152
180
  if (isRoom(subject)) return subject;
153
181
  const place = state.placements.get(subject);
154
182
  if (!place || place.predicate === "mgx:hidden-in") return null;
155
183
  if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
156
184
  const holder = place.object;
157
- if (holder === "player") return null;
185
+ if (holder === actingSubject) return null;
158
186
  if (!state.openness.get(holder)?.open) return null;
159
187
  const holderPlace = state.placements.get(holder);
160
188
  return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
@@ -163,13 +191,14 @@ export function visibleRoomOf(rows, state, subject) {
163
191
  /** Every subject actually visible in `here`, sorted, each with its sprite
164
192
  * class — built over `visibleRoomOf` (one containment hop through an OPEN
165
193
  * container) so this can never draw a hidden or carried object the text
166
- * digest wouldn't also mention. `player` is excluded; the caller draws the
167
- * player's own adventurer sprite separately. Pure. */
168
- export function roomSceneObjects(rows, state, here) {
194
+ * digest wouldn't also mention. `actingSubject` is excluded (default
195
+ * "player"); the caller draws that individual's own adventurer sprite
196
+ * separately. Pure. */
197
+ export function roomSceneObjects(rows, state, here, actingSubject = "player") {
169
198
  const out = [];
170
199
  for (const subject of [...state.placements.keys()].sort()) {
171
- if (subject === "player") continue;
172
- if (visibleRoomOf(rows, state, subject) !== here) continue;
200
+ if (subject === actingSubject) continue;
201
+ if (visibleRoomOf(rows, state, subject, actingSubject) !== here) continue;
173
202
  out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
174
203
  }
175
204
  return out;
@@ -307,13 +336,13 @@ export function roomKindForRoom(rows, roomId) {
307
336
  return "indoor";
308
337
  }
309
338
 
310
- /** Every object currently `mgx:located-in` "player", sorted, each with its
311
- * sprite class — the exact placement `worldDigestRows`'/`inventoryAnswer`'s
312
- * own "carries the" branch already reads, just returned as a plain list
313
- * instead of prose. Pure. */
314
- export function carriedItems(rows, state) {
339
+ /** Every object currently `mgx:located-in` `actingSubject` (default
340
+ * "player"), sorted, each with its sprite class — the exact placement
341
+ * `worldDigestRows`'/`inventoryAnswer`'s own "carries the" branch already
342
+ * reads, just returned as a plain list instead of prose. Pure. */
343
+ export function carriedItems(rows, state, actingSubject = "player") {
315
344
  return [...state.placements]
316
- .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
345
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === actingSubject)
317
346
  .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
318
347
  .sort((a, b) => a.subject.localeCompare(b.subject));
319
348
  }
@@ -333,10 +362,10 @@ export function carriedItems(rows, state) {
333
362
  * Disconnected visited rooms (not reachable from each other by traveled
334
363
  * edges) lay out as separate side-by-side blocks rather than overlapping.
335
364
  * Pure. */
336
- export function visitedRoomGraph(state, visitedRoomIds) {
365
+ export function visitedRoomGraph(state, visitedRoomIds, actingSubject = "player") {
337
366
  const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
338
367
  const visited = new Set(visitedRoomIds || []);
339
- const here = state.placements.get("player")?.object ?? null;
368
+ const here = state.placements.get(actingSubject)?.object ?? null;
340
369
  const positions = new Map();
341
370
  const edges = [];
342
371
  const edgeKeys = new Set();
@@ -415,6 +444,28 @@ export function goalStatusLines(rows, state, visitedRoomIds) {
415
444
  });
416
445
  }
417
446
 
447
+ /** Every written way out of `here`, in a fixed compass order, each with the
448
+ * room it leads to and whether this session has already walked there:
449
+ * `[{ direction, target, walked }]`. Only exits the world actually writes
450
+ * appear, so a doorway drawn from this list can never offer a `go` the
451
+ * engine would then refuse. `walked` reads the SAME visited set the manor
452
+ * map and the goal lines already do — the rooms the player has stood in, so
453
+ * marking a door as new tells them nothing they haven't earned. Ashcombe
454
+ * Hall teaches no `dig` action family, so unlike the burrow's own compass
455
+ * ring there is no second kind of way out to draw. Pure and
456
+ * `.toString()`-splice-safe. */
457
+ export function exitDoorways(state, here, visitedRoomIds) {
458
+ const compass = ["north", "east", "south", "west", "up", "down"];
459
+ const walked = new Set(visitedRoomIds || []);
460
+ const exits = state.exits.get(here);
461
+ if (!exits) return [];
462
+ return compass.filter((direction) => exits.has(direction)).map((direction) => ({
463
+ direction,
464
+ target: exits.get(direction),
465
+ walked: walked.has(exits.get(direction)),
466
+ }));
467
+ }
468
+
418
469
  /** The clickable command suggestions for the room the player is CURRENTLY
419
470
  * in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
420
471
  * (the exact same data take/open/talk/examine already check), so a pill can
@@ -514,12 +565,16 @@ export function roomCaptionText(rows, state, here) {
514
565
  export function renderAdventureHtml({
515
566
  title = DEFAULT_TITLE,
516
567
  worldPayload = { facts: [], rules: [], opening: "" },
568
+ scenarios = [],
517
569
  spriteTemplates = [],
518
570
  largeSpriteTemplates = [],
519
571
  engineBundleJs = "",
520
572
  } = {}) {
573
+ const scenarioList = scenarios.length
574
+ ? scenarios
575
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload }];
521
576
  const pageData = embedJson({
522
- world: worldPayload,
577
+ scenarios: scenarioList,
523
578
  previewMaxTicks: PREVIEW_MAX_TICKS,
524
579
  tickWaitMs: TICK_WAIT_MS,
525
580
  spriteTemplates,
@@ -667,10 +722,72 @@ ${THEME_TOKENS_CSS}
667
722
  falls back through room's own ancestor chain to the generic room icon).
668
723
  Rendered at 120px — twice a room-object sprite's own 60px frame
669
724
  (.sprite-frame below) — so the room itself reads as the biggest thing
670
- drawn in its own scene. */
671
- .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; opacity: .92; }
725
+ drawn in its own scene. It takes the wall's own brass rather than the
726
+ page ink: at that size an ink-black icon out-shouts every person and
727
+ prop standing in front of it, and this is a mark ON the wall, not
728
+ another thing in the room. */
729
+ .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; color: var(--gilt); opacity: .5; }
672
730
  .room-kind-icon svg { width: 100%; height: 100%; display: block; }
673
731
  .room-kind-icon:empty { display: none; }
732
+ /* the doorways — every written way out drawn where it actually points,
733
+ as a brass door plate set into the wall it leads through. The plate
734
+ borrows the room plaque's own treatment above (parchment ground, gilt
735
+ rule, inset mat): a manor has one brass vocabulary, and a door plate and
736
+ a room plate are the same object. North and south sit in a band OUTSIDE
737
+ the frame so neither fights the plaque or the room-kind icon; west and
738
+ east sit just inside their own walls. Stairs get a round chip instead —
739
+ neither up nor down is a compass point, and neither hangs on a wall. */
740
+ .room-stage { position: relative; padding: 1.85rem 0; }
741
+ /* the "ways out" eyebrow — this ring is the ONLY place movement controls
742
+ exist on the page (the pill row deliberately drops every "go", see
743
+ renderPills), so a first-time player needs a named affordance here, not
744
+ just brass plates they have to guess at. */
745
+ .ways-out-label {
746
+ font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .14em; text-transform: uppercase;
747
+ color: var(--gilt); opacity: .85; margin: 0 0 -.3rem .1rem;
748
+ }
749
+ .dir-ring { position: absolute; inset: 0; pointer-events: none; }
750
+ .dir-slot { position: absolute; pointer-events: auto; }
751
+ .dir-north { top: 0; left: 50%; transform: translateX(-50%); }
752
+ .dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
753
+ .dir-west { left: .4rem; top: 50%; transform: translateY(-50%); }
754
+ .dir-east { right: .4rem; top: 50%; transform: translateY(-50%); }
755
+ /* the stairs stand at the left of each band: the frame's own corner
756
+ flourishes sit top-left and bottom-right, and this is the one inset that
757
+ clears both. */
758
+ .dir-up { top: 0; left: 1.5rem; }
759
+ .dir-down { bottom: 0; left: 1.5rem; }
760
+ /* a brass PUSH-plate, not a name plate: real button chrome (an outer drop
761
+ shadow, not just the inset highlight the static room/door plaques use),
762
+ a bigger touch target, and a lift on hover/press — so this reads as
763
+ clickable at a glance instead of blending into the wallpaper the way a
764
+ plain brass label would. The leading triangle is a direction-neutral
765
+ "go" cue, the same role an arrow plays on any door-push icon. */
766
+ .dir-door {
767
+ position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .3rem;
768
+ font-family: ${MONO_STACK}; font-size: .74rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
769
+ color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt); border-radius: 3px;
770
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 2px 4px rgba(0, 0, 0, .25);
771
+ padding: .5rem .95rem; white-space: nowrap;
772
+ transition: transform .08s ease, box-shadow .08s ease, background .08s ease, color .08s ease;
773
+ }
774
+ .dir-door::before { content: "\\25B8"; font-size: .62em; opacity: .9; }
775
+ .dir-door:hover, .dir-door:focus-visible {
776
+ background: var(--gilt); color: var(--parchment);
777
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 4px 7px rgba(0, 0, 0, .32);
778
+ transform: translateY(-1.5px);
779
+ }
780
+ .dir-door:active {
781
+ transform: translateY(0);
782
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 1px 2px rgba(0, 0, 0, .28);
783
+ }
784
+ .dir-door.stair { border-radius: 50%; width: 2.15rem; height: 2.15rem; padding: 0; font-size: .86rem; letter-spacing: 0; }
785
+ .dir-door.stair::before { content: ""; }
786
+ /* a door onto a room this session has not stood in yet, marked with the
787
+ same gilt dot the manor board prints for a direction it cannot draw a
788
+ room for — one hint shape, two surfaces. */
789
+ .dir-door .unwalked { position: absolute; top: -.2rem; right: -.2rem; width: .4rem; height: .4rem; border-radius: 50%; background: var(--gilt); border: 1px solid var(--parchment); }
790
+
674
791
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
675
792
  /* the floor band: every non-wall-mounted stack, wrapping and pinned to the
676
793
  left edge of the room, as the flat sprite row always has. */
@@ -849,6 +966,9 @@ ${THEME_TOKENS_CSS}
849
966
  .controls-row button { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
850
967
  .controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
851
968
  .controls-row button:disabled { opacity: .4; cursor: default; }
969
+ .controls-row select { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
970
+ .controls-row select:hover:not(:disabled) { background: var(--parchment-strong); }
971
+ .controls-row select:disabled { opacity: .4; cursor: default; }
852
972
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); background: var(--parchment); border: 1px solid var(--gilt); padding: .3rem .6rem; font-variant-numeric: tabular-nums; }
853
973
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
854
974
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
@@ -874,6 +994,10 @@ ${THEME_TOKENS_CSS}
874
994
  #legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
875
995
 
876
996
  body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
997
+ /* the hero iframe is a picture of a room, not a control surface — every
998
+ other control is hidden there, and the doorways go with them. */
999
+ body.preview .room-stage { padding: 0; }
1000
+ body.preview .dir-ring { display: none; }
877
1001
  body.preview main { padding: 0; max-width: none; }
878
1002
  body.preview .stage { display: block; }
879
1003
  body.preview .eyebrow, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
@@ -885,11 +1009,14 @@ ${THEME_TOKENS_CSS}
885
1009
  <div class="eyebrow">tmct &middot; the adventure</div>
886
1010
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
887
1011
  </div>
888
- <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
1012
+ <p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
889
1013
  <div class="stage" id="playStage">
890
1014
  <div class="stage-left">
891
1015
  <div class="controls-row" id="playControls">
892
1016
  <button id="resetBtn" type="button" disabled>reset</button>
1017
+ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" aria-label="which world to play" disabled>
1018
+ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
1019
+ </select>` : ""}
893
1020
  <button id="playBtn" type="button" disabled>&#9654; play</button>
894
1021
  <button id="stepBtn" type="button" disabled>step</button>
895
1022
  <span class="turn mono" id="turnLabel">turn: 0</span>
@@ -904,13 +1031,24 @@ ${THEME_TOKENS_CSS}
904
1031
  <h2>satchel</h2>
905
1032
  <div class="chips" id="carryList"></div>
906
1033
  </div>
907
- <div class="room-frame" id="roomFrame">
908
- <div class="room-plaque mono" id="roomName"></div>
909
- <div class="room-kind-icon" id="roomKindIcon"></div>
910
- <div class="wall-row" id="wallRow"></div>
911
- <div class="sprite-row" id="spriteRow">
912
- <div class="floor-row" id="floorRow"></div>
913
- <div class="you-slot" id="youSlot"></div>
1034
+ <div class="ways-out-label" aria-hidden="true">ways out</div>
1035
+ <div class="room-stage">
1036
+ <div class="room-frame" id="roomFrame">
1037
+ <div class="room-plaque mono" id="roomName"></div>
1038
+ <div class="room-kind-icon" id="roomKindIcon"></div>
1039
+ <div class="wall-row" id="wallRow"></div>
1040
+ <div class="sprite-row" id="spriteRow">
1041
+ <div class="floor-row" id="floorRow"></div>
1042
+ <div class="you-slot" id="youSlot"></div>
1043
+ </div>
1044
+ </div>
1045
+ <div class="dir-ring" id="dirRing" aria-label="the ways out of this room">
1046
+ <div class="dir-slot dir-north" id="dir-north"></div>
1047
+ <div class="dir-slot dir-east" id="dir-east"></div>
1048
+ <div class="dir-slot dir-south" id="dir-south"></div>
1049
+ <div class="dir-slot dir-west" id="dir-west"></div>
1050
+ <div class="dir-slot dir-up" id="dir-up"></div>
1051
+ <div class="dir-slot dir-down" id="dir-down"></div>
914
1052
  </div>
915
1053
  </div>
916
1054
  <div class="panel command">
@@ -995,15 +1133,23 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
995
1133
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
996
1134
  const allRoomIds = ${allRoomIds.toString()};
997
1135
  const groundedPlaceholder = ${groundedPlaceholder.toString()};
1136
+ const exitDoorways = ${exitDoorways.toString()};
998
1137
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
999
1138
  const factsForSubject = ${factsForSubject.toString()};
1000
1139
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
1001
1140
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
1002
1141
  const esc = ${escapeHtml.toString()};
1142
+ // The identity Ashcombe Hall's own world facts place as the carrier/viewer
1143
+ // — one named constant standing in for what was six separate "player"
1144
+ // string literals scattered through the room/inventory/map drawing calls
1145
+ // below, so a future scenario shipping a differently-named single
1146
+ // character never falls out of step with the calls that use it.
1147
+ const ACTING_SUBJECT = "player";
1003
1148
  const el = (id) => document.getElementById(id);
1004
1149
  const roomFrameEl = el("roomFrame");
1005
1150
  const roomNameEl = el("roomName");
1006
1151
  const roomKindIconEl = el("roomKindIcon");
1152
+ const dirRingEl = el("dirRing");
1007
1153
  const wallRowEl = el("wallRow");
1008
1154
  const floorRowEl = el("floorRow");
1009
1155
  const youSlotEl = el("youSlot");
@@ -1042,6 +1188,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1042
1188
  const roomDetailSpritesEl = el("roomDetailSprites");
1043
1189
  const roomDetailCaptionEl = el("roomDetailCaption");
1044
1190
  const legendListEl = el("legendList");
1191
+ const scenarioSelectEl = el("scenarioSelect");
1192
+ const pageNoteEl = el("pageNote");
1193
+
1194
+ // Which of the shipped worlds is loaded. Every read of the world — the
1195
+ // session it is opened over, its opening line, the provenance prefix edit
1196
+ // mode filters on, the key its progress is saved under — goes through this
1197
+ // one index, so picking another world needs no second copy of any of them.
1198
+ let worldIndex = 0;
1199
+ const world = () => ADVENTURE.scenarios[worldIndex].worldPayload;
1045
1200
 
1046
1201
  const params = new URLSearchParams(location.search);
1047
1202
  const preview = params.get("preview") === "1";
@@ -1171,12 +1326,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1171
1326
  // holding it has actually been visited this session.
1172
1327
  function goalStatusLinesFor(rows, state, visitedRoomIds) {
1173
1328
  const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
1174
- const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
1329
+ const carriedIds = new Set(carriedItems(rows, state, ACTING_SUBJECT).map((o) => o.subject));
1175
1330
  const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
1176
1331
  const exposedState = tmctAdventure.foldWorldState(exposedRows);
1177
1332
  return ids.map((id) => {
1178
1333
  if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
1179
- const room = visibleRoomOf(exposedRows, exposedState, id);
1334
+ const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
1180
1335
  return room
1181
1336
  ? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
1182
1337
  : { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
@@ -1187,7 +1342,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1187
1342
  // the same non-interactive chip shape the room's own pills use for
1188
1343
  // actions, so the sidebar reads as one visual family.
1189
1344
  function renderCarrying(rows, state) {
1190
- const items = carriedItems(rows, state);
1345
+ const items = carriedItems(rows, state, ACTING_SUBJECT);
1191
1346
  carryListEl.innerHTML = items.length
1192
1347
  ? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
1193
1348
  : '<span class="empty-note">nothing yet</span>';
@@ -1232,7 +1387,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1232
1387
  + edgesSvg + hintsSvg + nodesSvg + "</svg>";
1233
1388
  }
1234
1389
  function renderRoomMap(rows, state, visitedRoomIds) {
1235
- mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
1390
+ mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds, ACTING_SUBJECT), false) || '<span class="empty-note">nowhere yet</span>';
1236
1391
  }
1237
1392
 
1238
1393
  // ---- the map lightbox — clicking the fixed-square play-mode map redraws
@@ -1240,7 +1395,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1240
1395
  // backdrop (not the board itself) or pressing Escape closes it.
1241
1396
  function openMapLightbox() {
1242
1397
  if (!lastSnapshot) return;
1243
- const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1398
+ const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds, ACTING_SUBJECT), false);
1244
1399
  if (!svg) return;
1245
1400
  mapLightboxInnerEl.innerHTML = svg;
1246
1401
  mapLightboxEl.hidden = false;
@@ -1348,7 +1503,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1348
1503
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
1349
1504
 
1350
1505
  function renderEditMap(rows, state) {
1351
- editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1506
+ editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows), ACTING_SUBJECT), true) || '<span class="empty-note">this world defines no rooms</span>';
1352
1507
  }
1353
1508
  editMapWrapEl.addEventListener("click", (e) => {
1354
1509
  const g = e.target.closest("[data-room]");
@@ -1386,11 +1541,16 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1386
1541
  // Clicking one inserts its exact command text into the input and focuses
1387
1542
  // it; it never auto-submits, so free typing still works and a clicked
1388
1543
  // suggestion can still be edited first.
1544
+ //
1545
+ // The row shows what to do with the things IN the room; the doorways above
1546
+ // own every way OUT of it. A "go" command is the one affordance no player
1547
+ // ever wants to edit before running, and drawing it in both places would
1548
+ // make one control a suggestion and its twin an action.
1389
1549
  function pillsFor(rows, state, here) {
1390
1550
  return tmctAdventure.roomAffordances(rows, state, here);
1391
1551
  }
1392
1552
  function renderPills(rows, state, here) {
1393
- const actions = pillsFor(rows, state, here);
1553
+ const actions = pillsFor(rows, state, here).filter((a) => a.indexOf("go ") !== 0);
1394
1554
  pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1395
1555
  // The empty input teaches the grounded noun form off the same affordance
1396
1556
  // list the pills read — real props from THIS room, so a first-time player
@@ -1422,11 +1582,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1422
1582
  // stay keyed on that declared class unchanged. The player's own "you" row
1423
1583
  // has no backing fact row, so it resolves directly by its fixed
1424
1584
  // "adventurer" class with no ancestry/property rows to check.
1585
+ //
1586
+ // When the whole chain misses, the root the resolver lands on is chosen by
1587
+ // what KIND of thing the class is, never the resolver's own "animal"
1588
+ // default: an unknown prop in a manor should read as a plain parcel and an
1589
+ // unknown character as a figure, and neither should ever draw a creature.
1590
+ const ROOT_FALLBACK_BY_CLASS = { adventurer: "person", person: "person", room: "room" };
1591
+ const rootFallbackFor = (cls) => ROOT_FALLBACK_BY_CLASS[cls] || "portable";
1425
1592
  function resolveObjectSprite(rows, s) {
1426
- if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "you" });
1593
+ if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { rootFallback: "person", instanceKey: "you" });
1427
1594
  return tmctAdventure.resolveSpriteAsset(
1428
1595
  s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
1429
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: s.subject },
1596
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1597
+ { rootFallback: rootFallbackFor(s.spriteClass), instanceKey: s.subject },
1430
1598
  );
1431
1599
  }
1432
1600
 
@@ -1438,10 +1606,38 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1438
1606
  function roomKindIconSvg(rows, here) {
1439
1607
  return tmctAdventure.resolveSpriteAsset(
1440
1608
  here, spriteAncestryRows(rows, here), factsForSubject(rows, here),
1441
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "roomkind-" + here },
1609
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1610
+ { rootFallback: "room", instanceKey: "roomkind-" + here },
1442
1611
  );
1443
1612
  }
1444
1613
 
1614
+ // ---- the doorways — one brass plate per written exit, in the slot that
1615
+ // exit actually points at, drawn from exitDoorways over the SAME snapshot
1616
+ // the room scene reads. A direction the world writes no exit for draws
1617
+ // nothing at all, so the ring never offers a "go" the engine would refuse.
1618
+ const STAIR_GLYPH = { up: "\\u2191", down: "\\u2193" };
1619
+ function renderDoorways(state, here, visitedRoomIds) {
1620
+ const bySlot = {};
1621
+ exitDoorways(state, here, visitedRoomIds).forEach((d) => { bySlot[d.direction] = d; });
1622
+ for (const direction of ["north", "east", "south", "west", "up", "down"]) {
1623
+ const slot = el("dir-" + direction);
1624
+ const doorway = bySlot[direction];
1625
+ if (!doorway) { slot.innerHTML = ""; continue; }
1626
+ const stair = direction === "up" || direction === "down";
1627
+ const label = "go " + direction + (doorway.walked ? "" : " (not walked yet)");
1628
+ slot.innerHTML = '<button type="button" class="dir-door' + (stair ? " stair" : "") + '"'
1629
+ + ' data-direction="' + esc(direction) + '" title="' + esc(label) + '" aria-label="' + esc(label) + '">'
1630
+ + (stair ? STAIR_GLYPH[direction] : esc(direction))
1631
+ + (doorway.walked ? "" : '<span class="unwalked" aria-hidden="true"></span>') + "</button>";
1632
+ }
1633
+ }
1634
+ // A door is not a suggestion to edit — clicking one walks through it.
1635
+ dirRingEl.addEventListener("click", (e) => {
1636
+ const btn = e.target.closest(".dir-door");
1637
+ if (!btn || !session) return;
1638
+ runTurn("go " + btn.getAttribute("data-direction"));
1639
+ });
1640
+
1445
1641
  function redraw(snap) {
1446
1642
  lastSnapshot = snap;
1447
1643
  const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
@@ -1460,27 +1656,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1460
1656
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
1461
1657
  turnLabelEl.textContent = "turn: " + snap.turn;
1462
1658
  renderPills(snap.rows, snap.state, snap.here);
1659
+ renderDoorways(snap.state, snap.here, snap.visitedRoomIds);
1463
1660
  renderCarrying(snap.rows, snap.state);
1464
1661
  renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
1465
1662
  renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
1466
1663
  schedulePersistSave();
1467
1664
  }
1468
1665
 
1469
- chatformEl.addEventListener("submit", (e) => {
1470
- e.preventDefault();
1471
- const q = chatqEl.value.trim();
1472
- if (!q || !session) return;
1473
- chatqEl.value = "";
1474
- // A manual command lands mid-tick otherwise — pause first, and stay
1475
- // paused until the visitor presses play again.
1666
+ // Every command a visitor issues by hand — typed, or walked through a
1667
+ // doorway — runs here, so the transcript, the pause and the redraw are the
1668
+ // same for both. A manual command lands mid-tick otherwise, so this pauses
1669
+ // first, and stays paused until the visitor presses play again.
1670
+ function runTurn(line) {
1476
1671
  ticker.pause();
1477
- addChatLine("u", esc(q));
1478
- withLock(async () => {
1479
- const result = await session.turn(q);
1672
+ addChatLine("u", esc(line));
1673
+ return withLock(async () => {
1674
+ const result = await session.turn(line);
1480
1675
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1481
1676
  const snap = await session.snapshot();
1482
1677
  redraw(snap);
1483
1678
  });
1679
+ }
1680
+
1681
+ chatformEl.addEventListener("submit", (e) => {
1682
+ e.preventDefault();
1683
+ const q = chatqEl.value.trim();
1684
+ if (!q || !session) return;
1685
+ chatqEl.value = "";
1686
+ runTurn(q);
1484
1687
  });
1485
1688
 
1486
1689
  // ---- edit mode ---------------------------------------------------------
@@ -1498,7 +1701,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1498
1701
  // diff + write; this page only ever reads its result back.
1499
1702
 
1500
1703
  function worldOnlyRows(rows) {
1501
- const prefix = "world:" + ADVENTURE.world.name;
1704
+ const prefix = "world:" + world().name;
1502
1705
  return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
1503
1706
  }
1504
1707
 
@@ -1512,7 +1715,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1512
1715
  }
1513
1716
  roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
1514
1717
  roomDetailTitleEl.textContent = selectedRoomId;
1515
- const objects = roomSceneObjects(editRows, editState, selectedRoomId);
1718
+ const objects = roomSceneObjects(editRows, editState, selectedRoomId, ACTING_SUBJECT);
1516
1719
  roomDetailSpritesEl.innerHTML = objects.length
1517
1720
  ? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
1518
1721
  : '<span class="empty-note">nothing placed here yet</span>';
@@ -1522,12 +1725,20 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1522
1725
  // ---- legend — every known object/character class this world actually
1523
1726
  // uses, by its real icon. Scoped to worldOnlyRows so the background
1524
1727
  // corpus's own (unrelated) rdf:type vocabulary never floods the list.
1728
+ // Each class resolves through the SAME ancestry walk and kind-aware root
1729
+ // the room's own cards use, so a class an edited world invents climbs to
1730
+ // the nearest ancestor that has a picture instead of dropping straight to
1731
+ // a generic one.
1525
1732
  function renderLegend(rows) {
1526
1733
  const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
1527
1734
  const classes = new Set(["adventurer"]);
1528
- subjects.forEach((s) => { if (s !== "player") classes.add(spriteClassForObject(rows, s)); });
1735
+ subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
1529
1736
  legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
1530
- const svg = tmctAdventure.resolveSpriteAsset(cls, [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "legend-" + cls });
1737
+ const svg = tmctAdventure.resolveSpriteAsset(
1738
+ cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
1739
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1740
+ { rootFallback: rootFallbackFor(cls), instanceKey: "legend-" + cls },
1741
+ );
1531
1742
  return spriteCardHtml(cls, cls, svg);
1532
1743
  }).join("");
1533
1744
  }
@@ -1639,7 +1850,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1639
1850
  async function boot({ fresh = false } = {}) {
1640
1851
  const saved = !fresh && persist ? await persist.load() : null;
1641
1852
  session = await tmctAdventure.createAdventureSession(
1642
- ADVENTURE.world,
1853
+ world(),
1643
1854
  saved && saved.payload && saved.payload.memoryPayload
1644
1855
  ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1645
1856
  : {},
@@ -1650,12 +1861,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1650
1861
  goalLineEl.textContent = "";
1651
1862
  chatlogEl.innerHTML = "";
1652
1863
  statusEl.textContent = "";
1653
- addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
1864
+ if (pageNoteEl) pageNoteEl.textContent = world().opening || "";
1865
+ addChatLine("t", esc(world().opening || "the adventure begins."));
1654
1866
  if (saved && snap.turn > 0) {
1655
1867
  addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
1656
1868
  }
1657
1869
  chatqEl.disabled = false;
1658
1870
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
1871
+ if (scenarioSelectEl) scenarioSelectEl.disabled = false;
1659
1872
  }
1660
1873
 
1661
1874
  const ticker = createTicker({
@@ -1690,11 +1903,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1690
1903
  stepBtn.addEventListener("click", () => ticker.stepOnce());
1691
1904
  resetBtn.addEventListener("click", () => ticker.reset());
1692
1905
 
1906
+ // Each world keeps its own saved progress, so the stamp carries the world's
1907
+ // name and a switch opens the store belonging to the world being switched
1908
+ // to. Without that, one world's snapshot would restore into another's graph.
1909
+ let siteVersion = "";
1910
+ async function openPersistFor(worldName) {
1911
+ if (preview || !tmctAdventure.openPersistedStore) return;
1912
+ if (!siteVersion) siteVersion = await fetchSiteVersion();
1913
+ persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + worldName });
1914
+ }
1915
+
1916
+ if (scenarioSelectEl) {
1917
+ scenarioSelectEl.addEventListener("change", () => withLock(async () => {
1918
+ const picked = Number(scenarioSelectEl.value);
1919
+ if (!ADVENTURE.scenarios[picked] || picked === worldIndex) return;
1920
+ ticker.pause();
1921
+ // The pending save belongs to the world being left, and it is about to
1922
+ // be written under the world being opened. Cancel it rather than let it
1923
+ // land in the wrong store.
1924
+ clearTimeout(persistSaveTimer);
1925
+ persistSaveTimer = null;
1926
+ worldIndex = picked;
1927
+ await openPersistFor(world().name);
1928
+ await boot();
1929
+ }));
1930
+ }
1931
+
1693
1932
  (async () => {
1694
- if (!preview && tmctAdventure.openPersistedStore) {
1695
- const siteVersion = await fetchSiteVersion();
1696
- persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
1697
- }
1933
+ await openPersistFor(world().name);
1698
1934
  await boot();
1699
1935
  if (preview) ticker.play();
1700
1936
  })();