@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +178 -9
  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 +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  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-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  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 +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -19,7 +19,7 @@
19
19
  // already takes with its own precomputed memory payload, just applied to a
20
20
  // second kind of build-time data.
21
21
  //
22
- // Eight pure, `.toString()`-splice-safe pieces are exported as real functions
22
+ // Seven pure, `.toString()`-splice-safe pieces are exported as real functions
23
23
  // (not raw inline-script text) so they can be pinned directly by tests, the
24
24
  // same discipline spider-fly-viz.mjs holds classOfAgentId/
25
25
  // threadCellsForSpiderPlan to: `spriteClassForObject` (an object's sprite
@@ -33,27 +33,35 @@
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), `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`
40
- // (a directions-only layout of the rooms a session has actually visited
41
- // see its own header for the exposure discipline). None of these import
42
- // anything beyond this module's own exports, which is what keeps a raw
43
- // `.toString()` splice safe. Three further pure helpers are exported for
44
- // testing but NOT spliced, because each calls another module's export the
45
- // in-page script instead reaches through the browser bundle's own
46
- // `tmctAdventure` global (mirroring how the inline script calls
47
- // `tmctSpiderFly.*` rather than re-importing spider-fly-world.mjs):
48
- // `roomCaptionText` (calls `worldDigestRows`; the in-page `captionFor`
49
- // mirrors it against `tmctAdventure.worldDigestRows`), `pillsForRoom` (a thin
50
- // wrapper over adventure.mjs's own exported `roomAffordances`, whose header
51
- // explains why its list can never promise an action one of take/open/talk/
52
- // examine would then refuse; the in-page `pillsFor` mirrors it against
53
- // `tmctAdventure.roomAffordances`), and `goalStatusLines` (calls
54
- // `foldWorldState` and adventure-autoplay.mjs's own `exposedFacts`; the
55
- // in-page `goalStatusLinesFor` mirrors both against the `tmctAdventure`
56
- // global too).
36
+ // `carriedItems` (every object currently placed with a `holder`, "player" by
37
+ // default), and `exitDoorways` (the written ways out of one room, in compass
38
+ // order, each marked with whether this session has walked it — what the room
39
+ // view's own door plates are drawn from). None of these import anything
40
+ // beyond this module's own exports, which is what keeps a raw `.toString()`
41
+ // splice safe.
42
+ //
43
+ // Four further pure helpers are exported for testing but NOT spliced,
44
+ // because each calls another module's export the in-page script instead
45
+ // reaches through the browser bundle's own `tmctAdventure` global (mirroring
46
+ // how the inline script calls `tmctSpiderFly.*` rather than re-importing
47
+ // spider-fly-world.mjs): `roomCaptionText` (calls `worldDigestRows`; the
48
+ // in-page `captionFor` mirrors it against `tmctAdventure.worldDigestRows`),
49
+ // `pillsForRoom` (a thin wrapper over adventure.mjs's own exported
50
+ // `roomAffordances`, whose header explains why its list can never promise an
51
+ // action one of take/open/talk/examine would then refuse; the in-page
52
+ // `pillsFor` mirrors it against `tmctAdventure.roomAffordances`),
53
+ // `goalStatusLines` (calls `foldWorldState` and adventure-autoplay.mjs's own
54
+ // `exposedFacts`; the in-page `goalStatusLinesFor` mirrors both against the
55
+ // `tmctAdventure` global too), and `visitedRoomGraph` (a directions-only
56
+ // layout of the rooms a session has actually visited, now a thin wrapper over
57
+ // viz-room-graph.mjs's shared `directedGridLayout` — mud-viz.mjs's own
58
+ // burrowGraph wrote the same BFS-grid layout a second time under its own
59
+ // name, and that shared module is where the layout lives now; see its own
60
+ // header for the algorithm and what `hints`/disconnected components mean).
61
+ // The in-page script never splices `directedGridLayout`/`roomGraphSvg`
62
+ // either, for the same not-self-contained reason: it calls
63
+ // `tmctAdventure.directedGridLayout`/`tmctAdventure.roomGraphSvg` straight
64
+ // through the bundle instead of re-implementing the room map a second time.
57
65
  //
58
66
  // The chat dock (chatlog/chatform/chatq/pills, below) mirrors
59
67
  // spider-fly-viz.mjs's own side panel: every manual exchange (via
@@ -84,13 +92,14 @@
84
92
  // an edit implies run through the browser bundle's own `session.applyEdit`
85
93
  // (adventure-browser-entry.mjs), never here — this module only renders and
86
94
  // reads.
87
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
88
- import { createTicker } from "./viz-ticker.mjs";
95
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel, rowsForWorld, wordBeforeCursor } from "./viz-theme.mjs";
96
+ import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
97
+ import { directedGridLayout } from "./viz-room-graph.mjs";
89
98
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
90
99
  import { exposedFacts } from "./adventure-autoplay.mjs";
91
100
  import { relatedForTerm } from "../domain/skos-view.mjs";
92
101
  import { classAncestorChain } from "../domain/sprite-map.mjs";
93
- import { renderWorldEditorText, wordBeforeCursor } from "./adventure-editor.mjs";
102
+ import { renderWorldEditorText } from "./adventure-editor.mjs";
94
103
 
95
104
  const DEFAULT_TITLE = "tmct — the adventure";
96
105
  const PREVIEW_MAX_TICKS = 30;
@@ -168,15 +177,21 @@ export function factsForSubject(rows, subject) {
168
177
  * feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
169
178
  * keeps a not-yet-visited location unknown. Pure, self-contained — no
170
179
  * reference to adventure.mjs's own private helpers, since those aren't
171
- * exported. */
172
- export function visibleRoomOf(rows, state, subject) {
180
+ * exported. `actingSubject` is the identity the world places as the
181
+ * carrier/viewer — a parameter, not a baked-in name, the same way
182
+ * adventure.mjs's own `runWorldCommand`/`roomAffordances` take it; it
183
+ * defaults to "player" because that's the one identity Ashcombe Hall's own
184
+ * world facts actually use, not because this function only ever means
185
+ * that individual (mud-viz.mjs threads its own per-character viewer through
186
+ * the same shape, see `mudRoomSceneObjects`/`carriedItemsFor`). */
187
+ export function visibleRoomOf(rows, state, subject, actingSubject = "player") {
173
188
  const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
174
189
  if (isRoom(subject)) return subject;
175
190
  const place = state.placements.get(subject);
176
191
  if (!place || place.predicate === "mgx:hidden-in") return null;
177
192
  if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
178
193
  const holder = place.object;
179
- if (holder === "player") return null;
194
+ if (holder === actingSubject) return null;
180
195
  if (!state.openness.get(holder)?.open) return null;
181
196
  const holderPlace = state.placements.get(holder);
182
197
  return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
@@ -185,13 +200,14 @@ export function visibleRoomOf(rows, state, subject) {
185
200
  /** Every subject actually visible in `here`, sorted, each with its sprite
186
201
  * class — built over `visibleRoomOf` (one containment hop through an OPEN
187
202
  * container) so this can never draw a hidden or carried object the text
188
- * digest wouldn't also mention. `player` is excluded; the caller draws the
189
- * player's own adventurer sprite separately. Pure. */
190
- export function roomSceneObjects(rows, state, here) {
203
+ * digest wouldn't also mention. `actingSubject` is excluded (default
204
+ * "player"); the caller draws that individual's own adventurer sprite
205
+ * separately. Pure. */
206
+ export function roomSceneObjects(rows, state, here, actingSubject = "player") {
191
207
  const out = [];
192
208
  for (const subject of [...state.placements.keys()].sort()) {
193
- if (subject === "player") continue;
194
- if (visibleRoomOf(rows, state, subject) !== here) continue;
209
+ if (subject === actingSubject) continue;
210
+ if (visibleRoomOf(rows, state, subject, actingSubject) !== here) continue;
195
211
  out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
196
212
  }
197
213
  return out;
@@ -329,13 +345,16 @@ export function roomKindForRoom(rows, roomId) {
329
345
  return "indoor";
330
346
  }
331
347
 
332
- /** Every object currently `mgx:located-in` "player", sorted, each with its
333
- * sprite class — the exact placement `worldDigestRows`'/`inventoryAnswer`'s
334
- * own "carries the" branch already reads, just returned as a plain list
335
- * instead of prose. Pure. */
336
- export function carriedItems(rows, state) {
348
+ /** Every object currently `mgx:located-in` `holder` (default "player"),
349
+ * sorted, each with its sprite class — the exact placement
350
+ * `worldDigestRows`'/`inventoryAnswer`'s own "carries the" branch already
351
+ * reads, just returned as a plain list instead of prose. `holder` is a real
352
+ * parameter, not a name baked into the function mud-viz.mjs's own
353
+ * `carriedItemsFor` used to hardcode "player" instead, so a differently-
354
+ * named character's own satchel never read correctly there. Pure. */
355
+ export function carriedItems(rows, state, holder = "player") {
337
356
  return [...state.placements]
338
- .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
357
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === holder)
339
358
  .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
340
359
  .sort((a, b) => a.subject.localeCompare(b.subject));
341
360
  }
@@ -354,47 +373,13 @@ export function carriedItems(rows, state) {
354
373
  * caller can draw at most "there's an exit that way" and nothing more.
355
374
  * Disconnected visited rooms (not reachable from each other by traveled
356
375
  * edges) lay out as separate side-by-side blocks rather than overlapping.
357
- * Pure. */
358
- export function visitedRoomGraph(state, visitedRoomIds) {
359
- const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
360
- const visited = new Set(visitedRoomIds || []);
361
- const here = state.placements.get("player")?.object ?? null;
362
- const positions = new Map();
363
- const edges = [];
364
- const edgeKeys = new Set();
365
- const hints = [];
366
- let offsetX = 0;
367
- for (const start of [...visited].sort()) {
368
- if (positions.has(start)) continue;
369
- positions.set(start, { x: offsetX, y: 0 });
370
- const queue = [start];
371
- const component = [start];
372
- while (queue.length) {
373
- const room = queue.shift();
374
- const pos = positions.get(room);
375
- const dirs = state.exits.get(room);
376
- for (const direction of [...(dirs?.keys() ?? [])].sort()) {
377
- const target = dirs.get(direction);
378
- if (!visited.has(target)) { hints.push({ from: room, direction }); continue; }
379
- const key = [room, target].sort().join("\0");
380
- if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
381
- if (!positions.has(target)) {
382
- const [dx, dy] = DELTA[direction] ?? [0, 0];
383
- positions.set(target, { x: pos.x + dx, y: pos.y + dy });
384
- component.push(target);
385
- queue.push(target);
386
- }
387
- }
388
- }
389
- offsetX = Math.max(...component.map((r) => positions.get(r).x)) + 2;
390
- }
391
- const minX = Math.min(0, ...[...positions.values()].map((p) => p.x));
392
- const minY = Math.min(0, ...[...positions.values()].map((p) => p.y));
393
- const nodes = [...visited].sort().map((room) => {
394
- const p = positions.get(room) || { x: 0, y: 0 };
395
- return { id: room, x: p.x - minX, y: p.y - minY, current: room === here };
396
- });
397
- return { nodes, edges, hints };
376
+ *
377
+ * A thin wrapper over viz-room-graph.mjs's shared `directedGridLayout`: no
378
+ * `root` (Ashcombe Hall is never dug two ways into one cell, so there is no
379
+ * level/turf to track) and no collision nudging (a manor fixed at authoring
380
+ * time never collides). Pure. */
381
+ export function visitedRoomGraph(state, visitedRoomIds, actingSubject = "player") {
382
+ return directedGridLayout(state, visitedRoomIds, { actingSubject });
398
383
  }
399
384
 
400
385
  /** Every room the world DEFINES at all (every subject the fact rows type as
@@ -521,11 +506,19 @@ export function suggestionsForTerm(rows, term) {
521
506
  * itself (its own exits) or about something placed IN it — the same
522
507
  * "visible here" boundary `roomSceneObjects` draws from. The player's own
523
508
  * "is in the" row is excluded: the room frame already IS the current room,
524
- * so restating "you are here" is redundant, never informative. */
525
- export function roomCaptionText(rows, state, here) {
509
+ * so restating "you are here" is redundant, never informative.
510
+ *
511
+ * `{ caseInsensitive }` folds the room-id match to lowercase before
512
+ * comparing. Ashcombe Hall's own room ids are already lowercase, so this is
513
+ * a no-op here by default — it exists so mud-viz.mjs's own case-insensitive
514
+ * `roomCaptionFor` variant can share this one function instead of keeping a
515
+ * near-duplicate. */
516
+ export function roomCaptionText(rows, state, here, { caseInsensitive = false } = {}) {
526
517
  const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
518
+ const objectMatches = (value) => (caseInsensitive ? String(value).toLowerCase() === here.toLowerCase() : value === here);
519
+ const subjectMatches = (value) => (caseInsensitive ? String(value).toLowerCase() === here.toLowerCase() : value === hereCased);
527
520
  const lines = worldDigestRows(rows, state)
528
- .filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
521
+ .filter((row) => row.subject !== "Player" && (objectMatches(row.object) || subjectMatches(row.subject)))
529
522
  .map((row) => `${row.subject} ${row.predicate} ${row.object}.`);
530
523
  return lines.length ? lines.join(" ") : `Nothing more about the ${here} is written down yet.`;
531
524
  }
@@ -558,12 +551,16 @@ export function roomCaptionText(rows, state, here) {
558
551
  export function renderAdventureHtml({
559
552
  title = DEFAULT_TITLE,
560
553
  worldPayload = { facts: [], rules: [], opening: "" },
554
+ scenarios = [],
561
555
  spriteTemplates = [],
562
556
  largeSpriteTemplates = [],
563
557
  engineBundleJs = "",
564
558
  } = {}) {
559
+ const scenarioList = scenarios.length
560
+ ? scenarios
561
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload }];
565
562
  const pageData = embedJson({
566
- world: worldPayload,
563
+ scenarios: scenarioList,
567
564
  previewMaxTicks: PREVIEW_MAX_TICKS,
568
565
  tickWaitMs: TICK_WAIT_MS,
569
566
  spriteTemplates,
@@ -726,31 +723,56 @@ ${THEME_TOKENS_CSS}
726
723
  the frame so neither fights the plaque or the room-kind icon; west and
727
724
  east sit just inside their own walls. Stairs get a round chip instead —
728
725
  neither up nor down is a compass point, and neither hangs on a wall. */
729
- .room-stage { position: relative; padding: 1.35rem 0; }
726
+ .room-stage { position: relative; padding: 1.85rem 0; }
727
+ /* the "ways out" eyebrow — this ring is the ONLY place movement controls
728
+ exist on the page (the pill row deliberately drops every "go", see
729
+ renderPills), so a first-time player needs a named affordance here, not
730
+ just brass plates they have to guess at. */
731
+ .ways-out-label {
732
+ font-family: ${MONO_STACK}; font-size: .68rem; letter-spacing: .14em; text-transform: uppercase;
733
+ color: var(--gilt); opacity: .85; margin: 0 0 -.3rem .1rem;
734
+ }
730
735
  .dir-ring { position: absolute; inset: 0; pointer-events: none; }
731
736
  .dir-slot { position: absolute; pointer-events: auto; }
732
737
  .dir-north { top: 0; left: 50%; transform: translateX(-50%); }
733
738
  .dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
734
- .dir-west { left: .3rem; top: 50%; transform: translateY(-50%); }
735
- .dir-east { right: .3rem; top: 50%; transform: translateY(-50%); }
739
+ .dir-west { left: .4rem; top: 50%; transform: translateY(-50%); }
740
+ .dir-east { right: .4rem; top: 50%; transform: translateY(-50%); }
736
741
  /* the stairs stand at the left of each band: the frame's own corner
737
742
  flourishes sit top-left and bottom-right, and this is the one inset that
738
743
  clears both. */
739
- .dir-up { top: 0; left: 1.4rem; }
740
- .dir-down { bottom: 0; left: 1.4rem; }
744
+ .dir-up { top: 0; left: 1.5rem; }
745
+ .dir-down { bottom: 0; left: 1.5rem; }
746
+ /* a brass PUSH-plate, not a name plate: real button chrome (an outer drop
747
+ shadow, not just the inset highlight the static room/door plaques use),
748
+ a bigger touch target, and a lift on hover/press — so this reads as
749
+ clickable at a glance instead of blending into the wallpaper the way a
750
+ plain brass label would. The leading triangle is a direction-neutral
751
+ "go" cue, the same role an arrow plays on any door-push icon. */
741
752
  .dir-door {
742
- position: relative; display: inline-flex; align-items: center; justify-content: center;
743
- font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .14em; text-transform: uppercase;
744
- color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt);
745
- box-shadow: inset 0 0 0 2px var(--parchment-strong);
746
- padding: .2rem .6rem; white-space: nowrap;
753
+ position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .3rem;
754
+ font-family: ${MONO_STACK}; font-size: .74rem; font-weight: 600; letter-spacing: .08em; text-transform: uppercase;
755
+ color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt); border-radius: 3px;
756
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 2px 4px rgba(0, 0, 0, .25);
757
+ padding: .5rem .95rem; white-space: nowrap;
758
+ transition: transform .08s ease, box-shadow .08s ease, background .08s ease, color .08s ease;
759
+ }
760
+ .dir-door::before { content: "\\25B8"; font-size: .62em; opacity: .9; }
761
+ .dir-door:hover, .dir-door:focus-visible {
762
+ background: var(--gilt); color: var(--parchment);
763
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 4px 7px rgba(0, 0, 0, .32);
764
+ transform: translateY(-1.5px);
747
765
  }
748
- .dir-door:hover, .dir-door:focus-visible { background: var(--parchment-strong); color: var(--ink); }
749
- .dir-door.stair { border-radius: 50%; width: 1.5rem; height: 1.5rem; padding: 0; font-size: .72rem; letter-spacing: 0; }
766
+ .dir-door:active {
767
+ transform: translateY(0);
768
+ box-shadow: inset 0 0 0 2px var(--parchment-strong), 0 1px 2px rgba(0, 0, 0, .28);
769
+ }
770
+ .dir-door.stair { border-radius: 50%; width: 2.15rem; height: 2.15rem; padding: 0; font-size: .86rem; letter-spacing: 0; }
771
+ .dir-door.stair::before { content: ""; }
750
772
  /* a door onto a room this session has not stood in yet, marked with the
751
773
  same gilt dot the manor board prints for a direction it cannot draw a
752
774
  room for — one hint shape, two surfaces. */
753
- .dir-door .unwalked { position: absolute; top: -.16rem; right: -.16rem; width: .34rem; height: .34rem; border-radius: 50%; background: var(--gilt); }
775
+ .dir-door .unwalked { position: absolute; top: -.2rem; right: -.2rem; width: .4rem; height: .4rem; border-radius: 50%; background: var(--gilt); border: 1px solid var(--parchment); }
754
776
 
755
777
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
756
778
  /* the floor band: every non-wall-mounted stack, wrapping and pinned to the
@@ -850,7 +872,7 @@ ${THEME_TOKENS_CSS}
850
872
  this just keeps the board's own footprint stable and click-to-enlarge
851
873
  honest about what it's enlarging. */
852
874
  .map-viewport-fixed { width: 190px; margin: 0 auto; cursor: zoom-in; }
853
- /* the lights-down map lightbox — the same board, the same roomMapSvg
875
+ /* the lights-down map lightbox — the same board, the same room-graph svg
854
876
  output, just drawn bigger over a dimmed backdrop. Closes on a click
855
877
  anywhere outside the enlarged board, or Escape. */
856
878
  .map-lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; padding: 2.4rem; background: rgba(10, 8, 4, .74); }
@@ -930,6 +952,9 @@ ${THEME_TOKENS_CSS}
930
952
  .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); }
931
953
  .controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
932
954
  .controls-row button:disabled { opacity: .4; cursor: default; }
955
+ .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); }
956
+ .controls-row select:hover:not(:disabled) { background: var(--parchment-strong); }
957
+ .controls-row select:disabled { opacity: .4; cursor: default; }
933
958
  .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; }
934
959
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
935
960
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
@@ -970,11 +995,14 @@ ${THEME_TOKENS_CSS}
970
995
  <div class="eyebrow">tmct &middot; the adventure</div>
971
996
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
972
997
  </div>
973
- <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
998
+ <p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
974
999
  <div class="stage" id="playStage">
975
1000
  <div class="stage-left">
976
1001
  <div class="controls-row" id="playControls">
977
1002
  <button id="resetBtn" type="button" disabled>reset</button>
1003
+ ${scenarioList.length > 1 ? ` <select id="scenarioSelect" aria-label="which world to play" disabled>
1004
+ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " selected" : ""}>${escapeHtml(s.label || scenarioLabel(s.worldPayload?.name))}</option>`).join("\n")}
1005
+ </select>` : ""}
978
1006
  <button id="playBtn" type="button" disabled>&#9654; play</button>
979
1007
  <button id="stepBtn" type="button" disabled>step</button>
980
1008
  <span class="turn mono" id="turnLabel">turn: 0</span>
@@ -989,6 +1017,7 @@ ${THEME_TOKENS_CSS}
989
1017
  <h2>satchel</h2>
990
1018
  <div class="chips" id="carryList"></div>
991
1019
  </div>
1020
+ <div class="ways-out-label" aria-hidden="true">ways out</div>
992
1021
  <div class="room-stage">
993
1022
  <div class="room-frame" id="roomFrame">
994
1023
  <div class="room-plaque mono" id="roomName"></div>
@@ -1080,6 +1109,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1080
1109
  (function () {
1081
1110
  "use strict";
1082
1111
  const createTicker = ${createTicker.toString()};
1112
+ const createSerialQueue = ${createSerialQueue.toString()};
1083
1113
  const spriteClassForObject = ${spriteClassForObject.toString()};
1084
1114
  const visibleRoomOf = ${visibleRoomOf.toString()};
1085
1115
  const roomSceneObjects = ${roomSceneObjects.toString()};
@@ -1087,7 +1117,6 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1087
1117
  const roomSceneLayout = ${roomSceneLayout.toString()};
1088
1118
  const roomKindForRoom = ${roomKindForRoom.toString()};
1089
1119
  const carriedItems = ${carriedItems.toString()};
1090
- const visitedRoomGraph = ${visitedRoomGraph.toString()};
1091
1120
  const allRoomIds = ${allRoomIds.toString()};
1092
1121
  const groundedPlaceholder = ${groundedPlaceholder.toString()};
1093
1122
  const exitDoorways = ${exitDoorways.toString()};
@@ -1095,7 +1124,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1095
1124
  const factsForSubject = ${factsForSubject.toString()};
1096
1125
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
1097
1126
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
1127
+ const rowsForWorld = ${rowsForWorld.toString()};
1098
1128
  const esc = ${escapeHtml.toString()};
1129
+ // The identity Ashcombe Hall's own world facts place as the carrier/viewer
1130
+ // — one named constant standing in for what was six separate "player"
1131
+ // string literals scattered through the room/inventory/map drawing calls
1132
+ // below, so a future scenario shipping a differently-named single
1133
+ // character never falls out of step with the calls that use it.
1134
+ const ACTING_SUBJECT = "player";
1099
1135
  const el = (id) => document.getElementById(id);
1100
1136
  const roomFrameEl = el("roomFrame");
1101
1137
  const roomNameEl = el("roomName");
@@ -1139,6 +1175,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1139
1175
  const roomDetailSpritesEl = el("roomDetailSprites");
1140
1176
  const roomDetailCaptionEl = el("roomDetailCaption");
1141
1177
  const legendListEl = el("legendList");
1178
+ const scenarioSelectEl = el("scenarioSelect");
1179
+ const pageNoteEl = el("pageNote");
1180
+
1181
+ // Which of the shipped worlds is loaded. Every read of the world — the
1182
+ // session it is opened over, its opening line, the provenance prefix edit
1183
+ // mode filters on, the key its progress is saved under — goes through this
1184
+ // one index, so picking another world needs no second copy of any of them.
1185
+ let worldIndex = 0;
1186
+ const world = () => ADVENTURE.scenarios[worldIndex].worldPayload;
1142
1187
 
1143
1188
  const params = new URLSearchParams(location.search);
1144
1189
  const preview = params.get("preview") === "1";
@@ -1200,13 +1245,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1200
1245
 
1201
1246
  // ---- serialize every engine-touching call: the ticker, the chat dock and
1202
1247
  // the editor sync all share one in-memory store, and any overlapping pair
1203
- // could race against the same write.
1204
- let lock = Promise.resolve();
1205
- function withLock(fn) {
1206
- const run = lock.then(fn, fn);
1207
- lock = run.catch(() => {});
1208
- return run;
1209
- }
1248
+ // could race against the same write. createSerialQueue is the shared
1249
+ // primitive mud-viz.mjs's own tickChain/serializeTick and spider-fly-viz.mjs's
1250
+ // own inlined withLock each duplicated under a different name.
1251
+ const { run: withLock } = createSerialQueue();
1210
1252
 
1211
1253
  // ---- large-sprite-tier wiring — the gradient-shaded 400px tier
1212
1254
  // (data/sprites-large/*.toml) arrives embedded at build time as
@@ -1268,12 +1310,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1268
1310
  // holding it has actually been visited this session.
1269
1311
  function goalStatusLinesFor(rows, state, visitedRoomIds) {
1270
1312
  const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
1271
- const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
1313
+ const carriedIds = new Set(carriedItems(rows, state, ACTING_SUBJECT).map((o) => o.subject));
1272
1314
  const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
1273
1315
  const exposedState = tmctAdventure.foldWorldState(exposedRows);
1274
1316
  return ids.map((id) => {
1275
1317
  if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
1276
- const room = visibleRoomOf(exposedRows, exposedState, id);
1318
+ const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
1277
1319
  return room
1278
1320
  ? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
1279
1321
  : { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
@@ -1284,60 +1326,46 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1284
1326
  // the same non-interactive chip shape the room's own pills use for
1285
1327
  // actions, so the sidebar reads as one visual family.
1286
1328
  function renderCarrying(rows, state) {
1287
- const items = carriedItems(rows, state);
1329
+ const items = carriedItems(rows, state, ACTING_SUBJECT);
1288
1330
  carryListEl.innerHTML = items.length
1289
1331
  ? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
1290
1332
  : '<span class="empty-note">nothing yet</span>';
1291
1333
  }
1292
1334
 
1293
1335
  // ---- room map — ONE svg-building routine shared by play mode's visited-
1294
- // only map and edit mode's whole-map (visitedRoomGraph fed allRoomIds
1336
+ // only map and edit mode's whole-map (visitedRoomGraphFor fed allRoomIds
1295
1337
  // instead of the exposure set — a parameter, not a second layout), so the
1296
1338
  // fixed-size-viewport CSS treatment and the node layout can never drift
1297
1339
  // between the two. clickable adds a data-room attribute and a pointer
1298
1340
  // cursor per node; play mode's own map stays purely informational.
1299
- function roomMapSvg(graph, clickable) {
1300
- if (!graph.nodes.length) return null;
1301
- // Board-game footprints: each room a named rectangle (the name INSIDE
1302
- // it, the way a Cluedo board prints its rooms), corridors as wide paths
1303
- // between the footprints, direction-only hints as path stubs fading
1304
- // toward rooms not yet visited.
1305
- const cell = 64, roomW = 56, roomH = 26;
1306
- const maxX = Math.max.apply(null, graph.nodes.map((n) => n.x));
1307
- const maxY = Math.max.apply(null, graph.nodes.map((n) => n.y));
1308
- const w = (maxX + 1) * cell, h = (maxY + 1) * cell;
1309
- const cx = (n) => (n.x + 0.5) * cell;
1310
- const cy = (n) => (n.y + 0.5) * cell;
1311
- const byRoom = new Map(graph.nodes.map((n) => [n.id, n]));
1312
- const edgesSvg = graph.edges.map((e) => {
1313
- const a = byRoom.get(e.from), b = byRoom.get(e.to);
1314
- return '<line class="room-edge" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
1315
- }).join("");
1316
- const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
1317
- const hintsSvg = graph.hints.map((hi) => {
1318
- const from = byRoom.get(hi.from);
1319
- const d = HINT_DELTA[hi.direction] || [0, 0];
1320
- return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3.5"></circle>';
1321
- }).join("");
1322
- const nodesSvg = graph.nodes.map((n) => {
1323
- const cls = "room-node" + (n.current ? " current" : "") + (clickable ? " clickable" : "") + (clickable && n.id === selectedRoomId ? " selected" : "");
1324
- const attr = clickable ? ' data-room="' + esc(n.id) + '"' : "";
1325
- return '<g class="' + cls + '"' + attr + '><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2) + '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect>'
1326
- + '<text x="' + cx(n) + '" y="' + (cy(n) + 2.5) + '">' + esc(n.id) + "</text></g>";
1327
- }).join("");
1328
- return '<svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="' + (clickable ? "the whole manor \\u2014 click a room to inspect it" : "the rooms visited so far") + '">'
1329
- + edgesSvg + hintsSvg + nodesSvg + "</svg>";
1341
+ // roomGraphSvgFor mirrors the OLD inline roomMapSvg's own board-game sizing
1342
+ // (a 64px square cell, 56x26px room footprints) through the shared
1343
+ // viz-room-graph.mjs renderer, reached via the tmctAdventure global the
1344
+ // same way captionFor/pillsFor already reach their own adventure.mjs
1345
+ // calls: roomGraphSvg needs escapeHtml and a module-level exit-delta table
1346
+ // this splice-safe script carries neither of, so it runs through the
1347
+ // bundle rather than being spliced as text (see this page's own module
1348
+ // header). visitedRoomGraphFor is the same posture for the layout half.
1349
+ function roomGraphSvgFor(graph, clickable) {
1350
+ return tmctAdventure.roomGraphSvg(graph, {
1351
+ cellX: 64, cellY: 64, roomW: 56, roomH: 26,
1352
+ clickable, selectedRoomId,
1353
+ label: clickable ? "the whole manor \\u2014 click a room to inspect it" : "the rooms visited so far",
1354
+ });
1355
+ }
1356
+ function visitedRoomGraphFor(state, visitedIds) {
1357
+ return tmctAdventure.directedGridLayout(state, visitedIds, { actingSubject: ACTING_SUBJECT });
1330
1358
  }
1331
1359
  function renderRoomMap(rows, state, visitedRoomIds) {
1332
- mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
1360
+ mapWrapEl.innerHTML = roomGraphSvgFor(visitedRoomGraphFor(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
1333
1361
  }
1334
1362
 
1335
1363
  // ---- the map lightbox — clicking the fixed-square play-mode map redraws
1336
- // the SAME roomMapSvg output larger, over a dimmed backdrop; clicking the
1337
- // backdrop (not the board itself) or pressing Escape closes it.
1364
+ // the SAME roomGraphSvgFor output larger, over a dimmed backdrop; clicking
1365
+ // the backdrop (not the board itself) or pressing Escape closes it.
1338
1366
  function openMapLightbox() {
1339
1367
  if (!lastSnapshot) return;
1340
- const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1368
+ const svg = roomGraphSvgFor(visitedRoomGraphFor(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1341
1369
  if (!svg) return;
1342
1370
  mapLightboxInnerEl.innerHTML = svg;
1343
1371
  mapLightboxEl.hidden = false;
@@ -1445,7 +1473,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1445
1473
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
1446
1474
 
1447
1475
  function renderEditMap(rows, state) {
1448
- editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1476
+ editMapWrapEl.innerHTML = roomGraphSvgFor(visitedRoomGraphFor(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
1449
1477
  }
1450
1478
  editMapWrapEl.addEventListener("click", (e) => {
1451
1479
  const g = e.target.closest("[data-room]");
@@ -1643,8 +1671,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1643
1671
  // diff + write; this page only ever reads its result back.
1644
1672
 
1645
1673
  function worldOnlyRows(rows) {
1646
- const prefix = "world:" + ADVENTURE.world.name;
1647
- return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
1674
+ return rowsForWorld(rows, world().name);
1648
1675
  }
1649
1676
 
1650
1677
  function renderRoomDetail() {
@@ -1657,7 +1684,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1657
1684
  }
1658
1685
  roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
1659
1686
  roomDetailTitleEl.textContent = selectedRoomId;
1660
- const objects = roomSceneObjects(editRows, editState, selectedRoomId);
1687
+ const objects = roomSceneObjects(editRows, editState, selectedRoomId, ACTING_SUBJECT);
1661
1688
  roomDetailSpritesEl.innerHTML = objects.length
1662
1689
  ? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
1663
1690
  : '<span class="empty-note">nothing placed here yet</span>';
@@ -1674,7 +1701,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1674
1701
  function renderLegend(rows) {
1675
1702
  const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
1676
1703
  const classes = new Set(["adventurer"]);
1677
- subjects.forEach((s) => { if (s !== "player") classes.add(spriteClassForObject(rows, s)); });
1704
+ subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
1678
1705
  legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
1679
1706
  const svg = tmctAdventure.resolveSpriteAsset(
1680
1707
  cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
@@ -1792,7 +1819,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1792
1819
  async function boot({ fresh = false } = {}) {
1793
1820
  const saved = !fresh && persist ? await persist.load() : null;
1794
1821
  session = await tmctAdventure.createAdventureSession(
1795
- ADVENTURE.world,
1822
+ world(),
1796
1823
  saved && saved.payload && saved.payload.memoryPayload
1797
1824
  ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1798
1825
  : {},
@@ -1803,12 +1830,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1803
1830
  goalLineEl.textContent = "";
1804
1831
  chatlogEl.innerHTML = "";
1805
1832
  statusEl.textContent = "";
1806
- addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
1833
+ if (pageNoteEl) pageNoteEl.textContent = world().opening || "";
1834
+ addChatLine("t", esc(world().opening || "the adventure begins."));
1807
1835
  if (saved && snap.turn > 0) {
1808
1836
  addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
1809
1837
  }
1810
1838
  chatqEl.disabled = false;
1811
1839
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
1840
+ if (scenarioSelectEl) scenarioSelectEl.disabled = false;
1812
1841
  }
1813
1842
 
1814
1843
  const ticker = createTicker({
@@ -1843,11 +1872,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1843
1872
  stepBtn.addEventListener("click", () => ticker.stepOnce());
1844
1873
  resetBtn.addEventListener("click", () => ticker.reset());
1845
1874
 
1875
+ // Each world keeps its own saved progress, so the stamp carries the world's
1876
+ // name and a switch opens the store belonging to the world being switched
1877
+ // to. Without that, one world's snapshot would restore into another's graph.
1878
+ let siteVersion = "";
1879
+ async function openPersistFor(worldName) {
1880
+ if (preview || !tmctAdventure.openPersistedStore) return;
1881
+ if (!siteVersion) siteVersion = await fetchSiteVersion();
1882
+ persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + worldName });
1883
+ }
1884
+
1885
+ if (scenarioSelectEl) {
1886
+ scenarioSelectEl.addEventListener("change", () => withLock(async () => {
1887
+ const picked = Number(scenarioSelectEl.value);
1888
+ if (!ADVENTURE.scenarios[picked] || picked === worldIndex) return;
1889
+ ticker.pause();
1890
+ // The pending save belongs to the world being left, and it is about to
1891
+ // be written under the world being opened. Cancel it rather than let it
1892
+ // land in the wrong store.
1893
+ clearTimeout(persistSaveTimer);
1894
+ persistSaveTimer = null;
1895
+ worldIndex = picked;
1896
+ await openPersistFor(world().name);
1897
+ await boot();
1898
+ }));
1899
+ }
1900
+
1846
1901
  (async () => {
1847
- if (!preview && tmctAdventure.openPersistedStore) {
1848
- const siteVersion = await fetchSiteVersion();
1849
- persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
1850
- }
1902
+ await openPersistFor(world().name);
1851
1903
  await boot();
1852
1904
  if (preview) ticker.play();
1853
1905
  })();