@polycode-projects/the-mechanical-code-talker 4.0.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 (68) 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 +34 -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/domain/grammar/ace.mjs +40 -4
  46. package/src/domain/grammar/lexicon-core.json +2 -1
  47. package/src/domain/grammar/lexicon.mjs +18 -0
  48. package/src/domain/reference-pack.mjs +31 -7
  49. package/src/domain/spider-fly-world.mjs +1 -1
  50. package/src/domain/sprite-templates.mjs +8 -6
  51. package/src/services/adventure-viz.mjs +130 -47
  52. package/src/services/adventure.mjs +440 -367
  53. package/src/services/chat-page-viz.mjs +37 -9
  54. package/src/services/chat.mjs +99 -18
  55. package/src/services/code-explorer-viz.mjs +52 -14
  56. package/src/services/extract-facts.mjs +4 -7
  57. package/src/services/ingest-viz.mjs +54 -26
  58. package/src/services/ledger-viz.mjs +105 -56
  59. package/src/services/memory-panel-viz.mjs +24 -0
  60. package/src/services/mud-viz.mjs +940 -21
  61. package/src/services/plan-viz.mjs +119 -61
  62. package/src/services/research-viz.mjs +151 -57
  63. package/src/services/spider-fly-turn.mjs +1 -1
  64. package/src/services/spider-fly-viz.mjs +67 -43
  65. package/src/services/sprite-catalog-viz.mjs +175 -51
  66. package/src/services/viz-theme.mjs +15 -0
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  68. package/src/surfaces/web/mud-browser-entry.mjs +44 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -111,11 +111,12 @@
111
111
  "chat:repo": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --repo",
112
112
  "chat:plain": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --plain",
113
113
  "chat:narrate": "node --disable-warning=ExperimentalWarning bin/tmct.mjs chat --narrate",
114
- "init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init",
115
- "init:sqlite": "npm run init",
114
+ "init": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java",
115
+ "init:small": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init",
116
+ "init:sqlite": "npm run init:small",
116
117
  "init:persona:human": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona human",
117
118
  "init:persona:empty": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --with-persona empty",
118
- "init:large": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java",
119
+ "init:large": "npm run init",
119
120
  "init:xl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-xl",
120
121
  "init:xxl": "node --disable-warning=ExperimentalWarning bin/tmct.mjs init --persona-size large && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus seon && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus conceptnet && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus aws && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus python && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus java && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus wordnet-full && node --disable-warning=ExperimentalWarning bin/tmct.mjs import --corpus namenet",
121
122
  "memory": "node --disable-warning=ExperimentalWarning bin/tmct.mjs memory",
@@ -28,7 +28,7 @@
28
28
 
29
29
  import {
30
30
  loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName,
31
- predicateOf, numberOf, classify,
31
+ predicateOf, numberOf, classify, OF_CLASSIFIER_HEADS, OF_PARTITIVE_HEADS,
32
32
  } from "./lexicon.mjs";
33
33
  import { fuzzyMatchInSet } from "../interpret/fuzzy.mjs";
34
34
 
@@ -85,7 +85,15 @@ function local(lexicon, term) {
85
85
  const stripDet = (tokens) =>
86
86
  tokens.length > 1 && DET.has(tokens[0].toLowerCase()) ? tokens.slice(1) : tokens;
87
87
 
88
- /** Resolve a 1–2 word noun phrase: PROPERNAME | code-ref | NOUN | ADJ NOUN.
88
+ /** A token a compound noun phrase may be built out of: a plain word (never a
89
+ * code-shaped ref, a number or a CURIE) that is not a DECLARED proper name —
90
+ * a name in a compound slot ("GitLab pipeline") is a structural miss, not a
91
+ * compound. Shared by both compound arms of resolveNP below. */
92
+ const compoundableWord = (lexicon, token) =>
93
+ /^[a-z][a-z'-]*$/i.test(token) && !lookupProperName(lexicon, token);
94
+
95
+ /** Resolve a noun phrase: PROPERNAME | code-ref | NOUN | ADJ NOUN | NOUN NOUN
96
+ * | NOUN of NOUN (the last two opt-in, see `allowCompound`).
89
97
  * Returns { term, individual, extras, unknown } — `term` null on a miss with
90
98
  * the undeclared tokens in `unknown` (empty `unknown` = structurally
91
99
  * unparseable phrase → the caller returns a hard null). `extras` carries the
@@ -149,8 +157,7 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
149
157
  // DECLARED proper name in either slot ("GitLab pipeline") keeps the
150
158
  // structural miss below — a name in the wrong slot, not a compound.
151
159
  if (allowCompound
152
- && !lookupProperName(lexicon, tokens[0]) && !lookupProperName(lexicon, tokens[1])
153
- && /^[a-z][a-z'-]*$/i.test(tokens[0]) && /^[a-z][a-z'-]*$/i.test(tokens[1])) {
160
+ && compoundableWord(lexicon, tokens[0]) && compoundableWord(lexicon, tokens[1])) {
154
161
  const n0 = lookupNoun(lexicon, tokens[0], { singularOnly: false });
155
162
  const n1 = lookupNoun(lexicon, tokens[1], { singularOnly });
156
163
  if (n0 && n1) {
@@ -162,6 +169,35 @@ function resolveNP(lexicon, tokensIn, { allowCompound = false } = {}) {
162
169
  const unknown = tokens.filter((t) => !classify(t, lexicon));
163
170
  return { term: null, individual: false, extras: [], unknown };
164
171
  }
172
+ // "NOUN of NOUN" is ONE compound noun ("unit of work", "chain of command",
173
+ // "conflict of interest"), joined with its own "of" the same way two plain
174
+ // nouns are joined with a space, so the taught fact and the query side
175
+ // unify on one term. Opt-in through the same `allowCompound` gate and the
176
+ // same compoundableWord shape test as the two-noun arm above; the head noun
177
+ // leads, so an "a"/"an" agrees with IT.
178
+ //
179
+ // Which "of" this claims is decided by the head, against the two closed
180
+ // of-frame vocabularies in lexicon.mjs — the same ones the fact extractor
181
+ // reads. A CLASSIFIER head is a rewrite of the inner noun rather than a
182
+ // compound ("a kind of dog" is a dog), and a PARTITIVE head states quantity
183
+ // or composition rather than a class ("a piece of cake", "a lot of dogs");
184
+ // both decline here and land on the miss below. The relational reading is
185
+ // pattern 7's and is claimed before any of this: parseAce routes "the N of N
186
+ // is VALUE" to parseOfForm, so "the version of tmct is 0.2.0" never reaches
187
+ // a copula split at all. An inner determiner is likewise out of the shape —
188
+ // "a piece of the cake" is four tokens and no compound noun carries one.
189
+ if (tokens.length === 3 && allowCompound && tokens[1].toLowerCase() === "of"
190
+ && compoundableWord(lexicon, tokens[0]) && compoundableWord(lexicon, tokens[2])) {
191
+ const head = tokens[0].toLowerCase();
192
+ const inner = tokens[2].toLowerCase();
193
+ if (!OF_CLASSIFIER_HEADS.has(head) && !OF_PARTITIVE_HEADS.has(head)) {
194
+ const headNoun = lookupNoun(lexicon, head, { singularOnly });
195
+ const innerNoun = lookupNoun(lexicon, inner, { singularOnly: false });
196
+ if (headNoun && innerNoun) {
197
+ return { term: `${ns}${head} of ${inner}`, individual: false, noun: headNoun, extras: [], unknown: [] };
198
+ }
199
+ }
200
+ }
165
201
  // 0 or 3+ tokens: not a fragment NP. Name the undeclared words if any.
166
202
  return { term: null, individual: false, extras: [], unknown: tokens.filter((t) => !classify(t, lexicon)) };
167
203
  }
@@ -9448,7 +9448,8 @@
9448
9448
  "danger": {},
9449
9449
  "push": {},
9450
9450
  "refusal": {},
9451
- "tooling": {}
9451
+ "tooling": {},
9452
+ "trelvox": {}
9452
9453
  },
9453
9454
  "verbs": {
9454
9455
  "import": {},
@@ -43,6 +43,24 @@ export const QUANTIFIERS = Object.freeze({
43
43
  exactly: "owl:cardinality",
44
44
  });
45
45
 
46
+ // The two closed head vocabularies that say what an "of" between two nouns is
47
+ // DOING, so every reader of the of-frame decides it the same way instead of
48
+ // each growing its own list. A CLASSIFIER head reads THROUGH to the inner noun
49
+ // — "a kind of dog" is a dog, "a type of mammal" is a mammal — so the phrase
50
+ // is a rewrite of the inner noun, never a term of its own. A PARTITIVE head
51
+ // states quantity or composition — "a piece of cake", "a lot of dogs", "a body
52
+ // of ice" — so the phrase names no class at all. Anything else ("unit of
53
+ // work", "chain of command") is a compound noun the of belongs to.
54
+ /** Of-frame heads that read through to the inner noun. */
55
+ export const OF_CLASSIFIER_HEADS = Object.freeze(new Set([
56
+ "type", "kind", "sort", "form", "class", "variety", "species", "breed", "genus",
57
+ ]));
58
+ /** Of-frame heads that state quantity or composition, not a class. */
59
+ export const OF_PARTITIVE_HEADS = Object.freeze(new Set([
60
+ "body", "mass", "group", "collection", "set", "series", "number", "amount",
61
+ "piece", "part", "lot", "pair", "bunch", "pile",
62
+ ]));
63
+
46
64
  const NUMBER_WORDS = Object.freeze({
47
65
  one: 1, two: 2, three: 3, four: 4, five: 5,
48
66
  six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
@@ -35,25 +35,49 @@ export function isReferenceIndexEntry(e) {
35
35
  && Number.isInteger(e.r) && e.r > 0;
36
36
  }
37
37
 
38
- /** A shard row: {term, title, text, summary, url, revid, isa?}. */
38
+ /** A shard row: {term, title, text, summary, url, revid, isa?, source?,
39
+ * licence?}. `source`/`licence` are optional per-entry overrides of the
40
+ * citation's source name and licence line (renderReferenceAnswer, below) —
41
+ * absent on every real Simple English Wikipedia row the build pipeline
42
+ * emits, present only on a hand-added synthetic entry (e.g. a demo/test
43
+ * term) whose citation must name its OWN real source, not Wikipedia's. */
39
44
  export function isReferenceArticleRow(row) {
40
45
  if (!row || typeof row !== "object") return false;
41
46
  for (const field of ["term", "title", "text", "summary", "url"]) {
42
47
  if (typeof row[field] !== "string" || !row[field]) return false;
43
48
  }
44
49
  if (!Number.isInteger(row.revid) || row.revid <= 0) return false;
45
- if (row.isa !== undefined && (typeof row.isa !== "string" || !row.isa)) return false;
50
+ for (const field of ["isa", "source", "licence"]) {
51
+ if (row[field] !== undefined && (typeof row[field] !== "string" || !row[field])) return false;
52
+ }
46
53
  return true;
47
54
  }
48
55
 
56
+ /** The default citation source/licence — every article the build pipeline
57
+ * (scripts/fetch-reference-pack.mjs) emits from the pinned Simple English
58
+ * Wikipedia dump carries neither field, so this is what renderReferenceAnswer
59
+ * falls back to for the overwhelming majority of rows. */
60
+ export const DEFAULT_REFERENCE_SOURCE = "Simple English Wikipedia";
61
+ export const DEFAULT_REFERENCE_LICENCE = "CC BY-SA 4.0";
62
+
49
63
  /** The cited answer for a clean miss the pack could ground: the article's
50
- * summary with its title, licence and revision-pinned URL always visible,
51
- * then the grain cue. Without the cue a reader has to recognize the source
52
- * name to tell this apart from a fact read out of their own graph — both
53
- * answer the same "what is X" question in the same voice. */
64
+ * summary with its title, licence and source always visible, then the grain
65
+ * cue. Without the cue a reader has to recognize the source name to tell
66
+ * this apart from a fact read out of their own graph — both answer the same
67
+ * "what is X" question in the same voice.
68
+ *
69
+ * The source/licence line reads the article's OWN `source`/`licence` fields
70
+ * when present, falling back to the Wikipedia defaults otherwise — every
71
+ * real pack row renders byte-identically to before this existed. Wikipedia's
72
+ * revision-pinned `?oldid=` query is a convention of that source alone, so it
73
+ * is only appended when the article is citing the default source; a
74
+ * non-Wikipedia entry's own `url` is shown bare. */
54
75
  export function renderReferenceAnswer(term, article) {
76
+ const source = article.source ?? DEFAULT_REFERENCE_SOURCE;
77
+ const licence = article.licence ?? DEFAULT_REFERENCE_LICENCE;
78
+ const url = source === DEFAULT_REFERENCE_SOURCE ? `${article.url}?oldid=${article.revid}` : article.url;
55
79
  return `${term} — ${article.summary} (source: reference article "${article.title}", `
56
- + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`
80
+ + `${source}, ${licence} — ${url})`
57
81
  + ` ${GENERAL_VOCABULARY_CUE}`;
58
82
  }
59
83
 
@@ -122,7 +122,7 @@ export const SEED_TAXONOMY = Object.freeze([
122
122
  ]);
123
123
 
124
124
  export const WORLD_OPENING =
125
- "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move — watch, or address one by name in chat.";
125
+ "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
126
126
 
127
127
  /** Every fact row the shipped world source carries: cell typing, grid
128
128
  * adjacency (mgx:has-exit-<direction>), the web block (mgx:in-web) and the
@@ -21,12 +21,14 @@
21
21
  // substitution (e.g. `black = "#22201d"`). A value with no entry in that
22
22
  // map is not a match for this template at all — it falls through to a
23
23
  // less specific one, never a guessed/invented substitution.
24
- // - a fully-specific hand-authored VARIANT — `{class}-with-{property}-
25
- // {value}.toml` (e.g. a hypothetical `dog-with-colour-black.toml`, not
26
- // authored this pass) carries the same `classes` as the class it
27
- // specializes, plus a `[match]` table (`property`, `value`) naming the
28
- // exact fact it requires, so it outranks the parameterized template
29
- // when both would otherwise apply.
24
+ // - a fully-specific hand-authored VARIANT — carries the same `classes`
25
+ // as the class it specializes, plus a `[match]` table (`property`,
26
+ // `value`) naming the exact fact it requires, so it outranks the
27
+ // parameterized template when both would otherwise apply. The filename
28
+ // is free-form and reads `{class}-{what it shows}` in practice
29
+ // (`portrait-round.toml` for mgx:hasProperty = round,
30
+ // `bear-facing-left.toml` for mgx:faces = left) — the `[match]` table,
31
+ // never the name, is what selects it.
30
32
  //
31
33
  // A parameterized template's `[parameters.<name>]` table comes in two
32
34
  // shapes, picked by which of `placeholder`/`placeholders` it declares:
@@ -84,7 +84,7 @@
84
84
  // an edit implies run through the browser bundle's own `session.applyEdit`
85
85
  // (adventure-browser-entry.mjs), never here — this module only renders and
86
86
  // reads.
87
- 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";
88
88
  import { createTicker } from "./viz-ticker.mjs";
89
89
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
90
90
  import { exposedFacts } from "./adventure-autoplay.mjs";
@@ -168,15 +168,21 @@ export function factsForSubject(rows, subject) {
168
168
  * feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
169
169
  * keeps a not-yet-visited location unknown. Pure, self-contained — no
170
170
  * reference to adventure.mjs's own private helpers, since those aren't
171
- * exported. */
172
- 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") {
173
179
  const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
174
180
  if (isRoom(subject)) return subject;
175
181
  const place = state.placements.get(subject);
176
182
  if (!place || place.predicate === "mgx:hidden-in") return null;
177
183
  if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
178
184
  const holder = place.object;
179
- if (holder === "player") return null;
185
+ if (holder === actingSubject) return null;
180
186
  if (!state.openness.get(holder)?.open) return null;
181
187
  const holderPlace = state.placements.get(holder);
182
188
  return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
@@ -185,13 +191,14 @@ export function visibleRoomOf(rows, state, subject) {
185
191
  /** Every subject actually visible in `here`, sorted, each with its sprite
186
192
  * class — built over `visibleRoomOf` (one containment hop through an OPEN
187
193
  * 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) {
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") {
191
198
  const out = [];
192
199
  for (const subject of [...state.placements.keys()].sort()) {
193
- if (subject === "player") continue;
194
- if (visibleRoomOf(rows, state, subject) !== here) continue;
200
+ if (subject === actingSubject) continue;
201
+ if (visibleRoomOf(rows, state, subject, actingSubject) !== here) continue;
195
202
  out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
196
203
  }
197
204
  return out;
@@ -329,13 +336,13 @@ export function roomKindForRoom(rows, roomId) {
329
336
  return "indoor";
330
337
  }
331
338
 
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) {
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") {
337
344
  return [...state.placements]
338
- .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
345
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === actingSubject)
339
346
  .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
340
347
  .sort((a, b) => a.subject.localeCompare(b.subject));
341
348
  }
@@ -355,10 +362,10 @@ export function carriedItems(rows, state) {
355
362
  * Disconnected visited rooms (not reachable from each other by traveled
356
363
  * edges) lay out as separate side-by-side blocks rather than overlapping.
357
364
  * Pure. */
358
- export function visitedRoomGraph(state, visitedRoomIds) {
365
+ export function visitedRoomGraph(state, visitedRoomIds, actingSubject = "player") {
359
366
  const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
360
367
  const visited = new Set(visitedRoomIds || []);
361
- const here = state.placements.get("player")?.object ?? null;
368
+ const here = state.placements.get(actingSubject)?.object ?? null;
362
369
  const positions = new Map();
363
370
  const edges = [];
364
371
  const edgeKeys = new Set();
@@ -558,12 +565,16 @@ export function roomCaptionText(rows, state, here) {
558
565
  export function renderAdventureHtml({
559
566
  title = DEFAULT_TITLE,
560
567
  worldPayload = { facts: [], rules: [], opening: "" },
568
+ scenarios = [],
561
569
  spriteTemplates = [],
562
570
  largeSpriteTemplates = [],
563
571
  engineBundleJs = "",
564
572
  } = {}) {
573
+ const scenarioList = scenarios.length
574
+ ? scenarios
575
+ : [{ label: scenarioLabel(worldPayload?.name), worldPayload }];
565
576
  const pageData = embedJson({
566
- world: worldPayload,
577
+ scenarios: scenarioList,
567
578
  previewMaxTicks: PREVIEW_MAX_TICKS,
568
579
  tickWaitMs: TICK_WAIT_MS,
569
580
  spriteTemplates,
@@ -726,31 +737,56 @@ ${THEME_TOKENS_CSS}
726
737
  the frame so neither fights the plaque or the room-kind icon; west and
727
738
  east sit just inside their own walls. Stairs get a round chip instead —
728
739
  neither up nor down is a compass point, and neither hangs on a wall. */
729
- .room-stage { position: relative; padding: 1.35rem 0; }
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
+ }
730
749
  .dir-ring { position: absolute; inset: 0; pointer-events: none; }
731
750
  .dir-slot { position: absolute; pointer-events: auto; }
732
751
  .dir-north { top: 0; left: 50%; transform: translateX(-50%); }
733
752
  .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%); }
753
+ .dir-west { left: .4rem; top: 50%; transform: translateY(-50%); }
754
+ .dir-east { right: .4rem; top: 50%; transform: translateY(-50%); }
736
755
  /* the stairs stand at the left of each band: the frame's own corner
737
756
  flourishes sit top-left and bottom-right, and this is the one inset that
738
757
  clears both. */
739
- .dir-up { top: 0; left: 1.4rem; }
740
- .dir-down { bottom: 0; left: 1.4rem; }
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. */
741
766
  .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;
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);
747
779
  }
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; }
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: ""; }
750
786
  /* a door onto a room this session has not stood in yet, marked with the
751
787
  same gilt dot the manor board prints for a direction it cannot draw a
752
788
  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); }
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); }
754
790
 
755
791
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
756
792
  /* the floor band: every non-wall-mounted stack, wrapping and pinned to the
@@ -930,6 +966,9 @@ ${THEME_TOKENS_CSS}
930
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); }
931
967
  .controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
932
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; }
933
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; }
934
973
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
935
974
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
@@ -970,11 +1009,14 @@ ${THEME_TOKENS_CSS}
970
1009
  <div class="eyebrow">tmct &middot; the adventure</div>
971
1010
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
972
1011
  </div>
973
- <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
1012
+ <p class="page-note" id="pageNote">${escapeHtml(scenarioList[0].worldPayload.opening || "")}</p>
974
1013
  <div class="stage" id="playStage">
975
1014
  <div class="stage-left">
976
1015
  <div class="controls-row" id="playControls">
977
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>` : ""}
978
1020
  <button id="playBtn" type="button" disabled>&#9654; play</button>
979
1021
  <button id="stepBtn" type="button" disabled>step</button>
980
1022
  <span class="turn mono" id="turnLabel">turn: 0</span>
@@ -989,6 +1031,7 @@ ${THEME_TOKENS_CSS}
989
1031
  <h2>satchel</h2>
990
1032
  <div class="chips" id="carryList"></div>
991
1033
  </div>
1034
+ <div class="ways-out-label" aria-hidden="true">ways out</div>
992
1035
  <div class="room-stage">
993
1036
  <div class="room-frame" id="roomFrame">
994
1037
  <div class="room-plaque mono" id="roomName"></div>
@@ -1096,6 +1139,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1096
1139
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
1097
1140
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
1098
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";
1099
1148
  const el = (id) => document.getElementById(id);
1100
1149
  const roomFrameEl = el("roomFrame");
1101
1150
  const roomNameEl = el("roomName");
@@ -1139,6 +1188,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1139
1188
  const roomDetailSpritesEl = el("roomDetailSprites");
1140
1189
  const roomDetailCaptionEl = el("roomDetailCaption");
1141
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;
1142
1200
 
1143
1201
  const params = new URLSearchParams(location.search);
1144
1202
  const preview = params.get("preview") === "1";
@@ -1268,12 +1326,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1268
1326
  // holding it has actually been visited this session.
1269
1327
  function goalStatusLinesFor(rows, state, visitedRoomIds) {
1270
1328
  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));
1329
+ const carriedIds = new Set(carriedItems(rows, state, ACTING_SUBJECT).map((o) => o.subject));
1272
1330
  const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
1273
1331
  const exposedState = tmctAdventure.foldWorldState(exposedRows);
1274
1332
  return ids.map((id) => {
1275
1333
  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);
1334
+ const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
1277
1335
  return room
1278
1336
  ? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
1279
1337
  : { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
@@ -1284,7 +1342,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1284
1342
  // the same non-interactive chip shape the room's own pills use for
1285
1343
  // actions, so the sidebar reads as one visual family.
1286
1344
  function renderCarrying(rows, state) {
1287
- const items = carriedItems(rows, state);
1345
+ const items = carriedItems(rows, state, ACTING_SUBJECT);
1288
1346
  carryListEl.innerHTML = items.length
1289
1347
  ? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
1290
1348
  : '<span class="empty-note">nothing yet</span>';
@@ -1329,7 +1387,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1329
1387
  + edgesSvg + hintsSvg + nodesSvg + "</svg>";
1330
1388
  }
1331
1389
  function renderRoomMap(rows, state, visitedRoomIds) {
1332
- 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>';
1333
1391
  }
1334
1392
 
1335
1393
  // ---- the map lightbox — clicking the fixed-square play-mode map redraws
@@ -1337,7 +1395,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1337
1395
  // backdrop (not the board itself) or pressing Escape closes it.
1338
1396
  function openMapLightbox() {
1339
1397
  if (!lastSnapshot) return;
1340
- const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds), false);
1398
+ const svg = roomMapSvg(visitedRoomGraph(lastSnapshot.state, lastSnapshot.visitedRoomIds, ACTING_SUBJECT), false);
1341
1399
  if (!svg) return;
1342
1400
  mapLightboxInnerEl.innerHTML = svg;
1343
1401
  mapLightboxEl.hidden = false;
@@ -1445,7 +1503,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1445
1503
  document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !objLightboxEl.hidden) closeObjectLightbox(); });
1446
1504
 
1447
1505
  function renderEditMap(rows, state) {
1448
- 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>';
1449
1507
  }
1450
1508
  editMapWrapEl.addEventListener("click", (e) => {
1451
1509
  const g = e.target.closest("[data-room]");
@@ -1643,7 +1701,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1643
1701
  // diff + write; this page only ever reads its result back.
1644
1702
 
1645
1703
  function worldOnlyRows(rows) {
1646
- const prefix = "world:" + ADVENTURE.world.name;
1704
+ const prefix = "world:" + world().name;
1647
1705
  return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
1648
1706
  }
1649
1707
 
@@ -1657,7 +1715,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1657
1715
  }
1658
1716
  roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
1659
1717
  roomDetailTitleEl.textContent = selectedRoomId;
1660
- const objects = roomSceneObjects(editRows, editState, selectedRoomId);
1718
+ const objects = roomSceneObjects(editRows, editState, selectedRoomId, ACTING_SUBJECT);
1661
1719
  roomDetailSpritesEl.innerHTML = objects.length
1662
1720
  ? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
1663
1721
  : '<span class="empty-note">nothing placed here yet</span>';
@@ -1674,7 +1732,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1674
1732
  function renderLegend(rows) {
1675
1733
  const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
1676
1734
  const classes = new Set(["adventurer"]);
1677
- subjects.forEach((s) => { if (s !== "player") classes.add(spriteClassForObject(rows, s)); });
1735
+ subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
1678
1736
  legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
1679
1737
  const svg = tmctAdventure.resolveSpriteAsset(
1680
1738
  cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
@@ -1792,7 +1850,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1792
1850
  async function boot({ fresh = false } = {}) {
1793
1851
  const saved = !fresh && persist ? await persist.load() : null;
1794
1852
  session = await tmctAdventure.createAdventureSession(
1795
- ADVENTURE.world,
1853
+ world(),
1796
1854
  saved && saved.payload && saved.payload.memoryPayload
1797
1855
  ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1798
1856
  : {},
@@ -1803,12 +1861,14 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1803
1861
  goalLineEl.textContent = "";
1804
1862
  chatlogEl.innerHTML = "";
1805
1863
  statusEl.textContent = "";
1806
- 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."));
1807
1866
  if (saved && snap.turn > 0) {
1808
1867
  addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
1809
1868
  }
1810
1869
  chatqEl.disabled = false;
1811
1870
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
1871
+ if (scenarioSelectEl) scenarioSelectEl.disabled = false;
1812
1872
  }
1813
1873
 
1814
1874
  const ticker = createTicker({
@@ -1843,11 +1903,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1843
1903
  stepBtn.addEventListener("click", () => ticker.stepOnce());
1844
1904
  resetBtn.addEventListener("click", () => ticker.reset());
1845
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
+
1846
1932
  (async () => {
1847
- if (!preview && tmctAdventure.openPersistedStore) {
1848
- const siteVersion = await fetchSiteVersion();
1849
- persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
1850
- }
1933
+ await openPersistFor(world().name);
1851
1934
  await boot();
1852
1935
  if (preview) ticker.play();
1853
1936
  })();