@polycode-projects/the-mechanical-code-talker 2.7.25 → 2.8.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 (53) hide show
  1. package/data/sprites/adventurer-icon.toml +13 -0
  2. package/data/sprites/animal-icon.toml +15 -0
  3. package/data/sprites/butler-icon.toml +16 -0
  4. package/data/sprites/cabinet-icon.toml +13 -0
  5. package/data/sprites/container-icon.toml +12 -0
  6. package/data/sprites/{cook.toml → cook-icon.toml} +8 -1
  7. package/data/sprites/desk-icon.toml +14 -0
  8. package/data/sprites/dog-icon.toml +17 -0
  9. package/data/sprites/dog-with-colour-icon.toml +31 -0
  10. package/data/sprites/egg-icon.toml +9 -0
  11. package/data/sprites/fly-icon.toml +19 -0
  12. package/data/sprites/furniture-icon.toml +12 -0
  13. package/data/sprites/gardener-icon.toml +11 -0
  14. package/data/sprites/housekeeper-icon.toml +14 -0
  15. package/data/sprites/key-icon.toml +12 -0
  16. package/data/sprites/lamp-icon.toml +11 -0
  17. package/data/sprites/letter-icon.toml +11 -0
  18. package/data/sprites/person-icon.toml +10 -0
  19. package/data/sprites/poodle-icon.toml +22 -0
  20. package/data/sprites/portable-icon.toml +12 -0
  21. package/data/sprites/portrait-icon.toml +12 -0
  22. package/data/sprites/room-icon.toml +11 -0
  23. package/data/sprites/spider-icon.toml +21 -0
  24. package/package.json +4 -2
  25. package/src/adapters/corpus/sprite-large-template-files.mjs +62 -0
  26. package/src/domain/sprite-materials.mjs +73 -0
  27. package/src/domain/sprite-size.mjs +34 -0
  28. package/src/domain/sprite-templates.mjs +106 -15
  29. package/src/domain/svg-instance-ids.mjs +40 -0
  30. package/src/services/adventure-viz.mjs +266 -36
  31. package/src/surfaces/web/adventure-browser-entry.mjs +38 -12
  32. package/data/sprites/adventurer.toml +0 -5
  33. package/data/sprites/animal.toml +0 -6
  34. package/data/sprites/butler.toml +0 -8
  35. package/data/sprites/cabinet.toml +0 -5
  36. package/data/sprites/container.toml +0 -5
  37. package/data/sprites/desk.toml +0 -4
  38. package/data/sprites/dog-with-colour.toml +0 -20
  39. package/data/sprites/dog.toml +0 -5
  40. package/data/sprites/egg.toml +0 -4
  41. package/data/sprites/fly.toml +0 -5
  42. package/data/sprites/furniture.toml +0 -5
  43. package/data/sprites/gardener.toml +0 -4
  44. package/data/sprites/housekeeper.toml +0 -6
  45. package/data/sprites/key.toml +0 -5
  46. package/data/sprites/lamp.toml +0 -4
  47. package/data/sprites/letter.toml +0 -5
  48. package/data/sprites/person.toml +0 -4
  49. package/data/sprites/poodle.toml +0 -5
  50. package/data/sprites/portable.toml +0 -6
  51. package/data/sprites/portrait.toml +0 -5
  52. package/data/sprites/room.toml +0 -5
  53. package/data/sprites/spider.toml +0 -5
@@ -28,6 +28,33 @@
28
28
  // exact fact it requires, so it outranks the parameterized template
29
29
  // when both would otherwise apply.
30
30
  //
31
+ // A parameterized template's `[parameters.<name>]` table comes in two
32
+ // shapes, picked by which of `placeholder`/`placeholders` it declares:
33
+ // - single-placeholder (the shape above): one `placeholder` token, and
34
+ // every `[parameters.<name>.values]` entry is a plain string substituted
35
+ // for it directly.
36
+ // - multi-placeholder (data/sprites-large/*.toml's gradient-shaded
37
+ // materials): a `placeholders` table instead, naming several tokens at
38
+ // once (e.g. `{ light = "{{FILL_LIGHT}}", base = "{{FILL}}", dark =
39
+ // "{{FILL_DARK}}" }`), and every `[parameters.<name>.values]` entry is a
40
+ // table with the SAME sub-keys (e.g. `{ light = "#f0dfa0", base =
41
+ // "#c9a24b", dark = "#8a6a1e" }`), one substituted per token. A value
42
+ // missing even one of the declared sub-keys is never a partial match —
43
+ // the same never-guess posture as an unmapped value in the single shape.
44
+ // sprite-materials.mjs's `expandMaterialReferences` is what lets a
45
+ // sprite-large file write a short by-name reference (`gold = "metal"`)
46
+ // instead of hand-copying the triple — this module never has to know
47
+ // that indirection exists, it only ever sees the expanded table shape.
48
+ //
49
+ // An object with no taught material still gets a real gradient at the
50
+ // sprite tier (data/sprites-large/*.toml's own non-material files), built
51
+ // from currentColor via `color-mix(in srgb, currentColor N%, white/black)`
52
+ // rather than `stop-opacity` — opacity blends toward whatever sits BEHIND
53
+ // the shape, so its light/dark direction silently flips between a light
54
+ // theme (currentColor dark-on-light) and a dark one (currentColor
55
+ // light-on-dark); color-mix lightens/darkens currentColor itself, so the
56
+ // same corner of the shape reads as the lit one on either theme.
57
+ //
31
58
  // Specificity order, checked at EACH term of the class's ancestor chain
32
59
  // (nearest first, sprite-map.mjs's own classAncestorChain) before moving to
33
60
  // the next ancestor: an exact fully-specific variant whose [match] is
@@ -39,6 +66,7 @@
39
66
  // against `rootFallback`, falling back to spriteRegistry's own root entry
40
67
  // only if nothing there matches either.
41
68
  import { classAncestorChain } from "./sprite-map.mjs";
69
+ import { namespaceSvgIds } from "./svg-instance-ids.mjs";
42
70
 
43
71
  /** Every template in `templates` whose `classes` list names `term`. */
44
72
  function templatesForClass(term, templates) {
@@ -50,6 +78,24 @@ function matchSatisfied(match, propertyFacts) {
50
78
  return (propertyFacts || []).some((f) => f.predicate === match.property && f.object === match.value);
51
79
  }
52
80
 
81
+ /** Substitute one matched `[parameters.*.values]` entry into `svg`: a plain
82
+ * string fills the parameter's single `placeholder` token; an object fills
83
+ * every token `param.placeholders` names from the SAME sub-key — returning
84
+ * null (never a partial gradient) if the object is missing even one of the
85
+ * sub-keys the parameter declares. */
86
+ function fillFromValue(svg, param, value) {
87
+ if (typeof value === "string" && param.placeholder) return svg.split(param.placeholder).join(value);
88
+ if (value && typeof value === "object" && param.placeholders) {
89
+ let out = svg;
90
+ for (const [sub, token] of Object.entries(param.placeholders)) {
91
+ if (!Object.prototype.hasOwnProperty.call(value, sub)) return null;
92
+ out = out.split(token).join(value[sub]);
93
+ }
94
+ return out;
95
+ }
96
+ return null;
97
+ }
98
+
53
99
  /** Fill a parameterized template's `svg` from the first of its own
54
100
  * `[parameters.*]` whose observed property value maps to a substitution —
55
101
  * or null when no property fact names a mapped value (never a guess). */
@@ -59,7 +105,9 @@ function parameterizedFill(template, propertyFacts) {
59
105
  const hit = (propertyFacts || []).find(
60
106
  (f) => f.predicate === param.property && Object.prototype.hasOwnProperty.call(values, f.object),
61
107
  );
62
- if (hit) return template.svg.split(param.placeholder).join(values[hit.object]);
108
+ if (!hit) continue;
109
+ const filled = fillFromValue(template.svg, param, values[hit.object]);
110
+ if (filled) return filled;
63
111
  }
64
112
  return null;
65
113
  }
@@ -82,6 +130,17 @@ function resolveAtTerm(term, propertyFacts, templates) {
82
130
  return plain ? plain.svg : null;
83
131
  }
84
132
 
133
+ function resolveSpriteAssetRaw(className, factRows, propertyFacts, templates, spriteRegistry, rootFallback) {
134
+ for (const term of classAncestorChain(className, factRows)) {
135
+ const hit = resolveAtTerm(term, propertyFacts, templates);
136
+ if (hit) return hit;
137
+ if (Object.prototype.hasOwnProperty.call(spriteRegistry, term)) return spriteRegistry[term];
138
+ }
139
+ const rootHit = resolveAtTerm(rootFallback, propertyFacts, templates);
140
+ if (rootHit) return rootHit;
141
+ return spriteRegistry[rootFallback];
142
+ }
143
+
85
144
  /**
86
145
  * Resolve `className` to sprite SVG markup, property-aware: the ancestor
87
146
  * chain (from `factRows`, sprite-map.mjs's own walk) is checked nearest-
@@ -91,16 +150,19 @@ function resolveAtTerm(term, propertyFacts, templates) {
91
150
  * as resolveSpriteForClass already does. `propertyFacts` is the instance's
92
151
  * own small `{predicate, object}` fact set (e.g. its mgx:hasProperty rows) —
93
152
  * read only, never required to be non-empty. Pure.
153
+ *
154
+ * `instanceKey`, when given, namespaces every gradient id the resolved svg
155
+ * declares (svg-instance-ids.mjs's own `namespaceSvgIds`) — pass the
156
+ * instance's own identity (e.g. its subject name) whenever more than one
157
+ * resolved sprite can appear in the same document at once, so two
158
+ * differently-valued instances of the SAME template (a gold lamp and a
159
+ * ceramic lamp both on screen) never share one `<linearGradient id>` and
160
+ * silently render each other's colours. Omit it for a single-instance
161
+ * caller (a template with no ids at all is untouched either way).
94
162
  */
95
- export function resolveSpriteAsset(className, factRows, propertyFacts, templates, spriteRegistry, { rootFallback = "animal" } = {}) {
96
- for (const term of classAncestorChain(className, factRows)) {
97
- const hit = resolveAtTerm(term, propertyFacts, templates);
98
- if (hit) return hit;
99
- if (Object.prototype.hasOwnProperty.call(spriteRegistry, term)) return spriteRegistry[term];
100
- }
101
- const rootHit = resolveAtTerm(rootFallback, propertyFacts, templates);
102
- if (rootHit) return rootHit;
103
- return spriteRegistry[rootFallback];
163
+ export function resolveSpriteAsset(className, factRows, propertyFacts, templates, spriteRegistry, { rootFallback = "animal", instanceKey } = {}) {
164
+ const svg = resolveSpriteAssetRaw(className, factRows, propertyFacts, templates, spriteRegistry, rootFallback);
165
+ return instanceKey ? namespaceSvgIds(svg, instanceKey) : svg;
104
166
  }
105
167
 
106
168
  /** Every internal-consistency problem with one parsed template, as plain
@@ -109,8 +171,10 @@ export function resolveSpriteAsset(className, factRows, propertyFacts, templates
109
171
  * data/sprites/ directory) and available to any future loader that wants to
110
172
  * warn rather than silently drop a broken file. Checks: `classes` is a
111
173
  * non-empty array, `svg` is a real `<svg` string, a `[parameters.*]` table
112
- * names a `property` and a non-empty `values` map, and a `[match]` table
113
- * names both `property` and `value`. */
174
+ * names a `property` and exactly one of `placeholder`/`placeholders` (every
175
+ * token named appears in `svg`), its `values` map is non-empty and every
176
+ * entry matches the shape its own `placeholder`/`placeholders` choice
177
+ * expects, and a `[match]` table names both `property` and `value`. */
114
178
  export function spriteTemplateProblems(template) {
115
179
  const problems = [];
116
180
  const t = template || {};
@@ -118,11 +182,38 @@ export function spriteTemplateProblems(template) {
118
182
  if (typeof t.svg !== "string" || !t.svg.trim().startsWith("<svg")) problems.push("svg is missing or not an <svg> string");
119
183
  for (const [name, param] of Object.entries(t.parameters || {})) {
120
184
  if (!param?.property) problems.push(`parameters.${name}.property is missing`);
121
- if (!param?.placeholder) problems.push(`parameters.${name}.placeholder is missing`);
122
- else if (typeof t.svg === "string" && !t.svg.includes(param.placeholder)) {
185
+ if (param?.placeholder && param?.placeholders) {
186
+ problems.push(`parameters.${name} sets both placeholder and placeholders pick one`);
187
+ } else if (param?.placeholders) {
188
+ const tokens = Object.entries(param.placeholders);
189
+ if (tokens.length === 0) problems.push(`parameters.${name}.placeholders is empty`);
190
+ for (const [sub, token] of tokens) {
191
+ if (typeof t.svg === "string" && !t.svg.includes(token)) {
192
+ problems.push(`parameters.${name}.placeholders.${sub} ${JSON.stringify(token)} does not appear in svg`);
193
+ }
194
+ }
195
+ } else if (!param?.placeholder) {
196
+ problems.push(`parameters.${name}.placeholder is missing`);
197
+ } else if (typeof t.svg === "string" && !t.svg.includes(param.placeholder)) {
123
198
  problems.push(`parameters.${name}.placeholder ${JSON.stringify(param.placeholder)} does not appear in svg`);
124
199
  }
125
- if (!param?.values || Object.keys(param.values).length === 0) problems.push(`parameters.${name}.values is empty`);
200
+ if (!param?.values || Object.keys(param.values).length === 0) {
201
+ problems.push(`parameters.${name}.values is empty`);
202
+ } else {
203
+ for (const [key, value] of Object.entries(param.values)) {
204
+ if (param.placeholders) {
205
+ if (!value || typeof value !== "object") {
206
+ problems.push(`parameters.${name}.values.${key} is not an expanded {${Object.keys(param.placeholders).join("/")}} object — an unresolved material reference?`);
207
+ } else {
208
+ for (const sub of Object.keys(param.placeholders)) {
209
+ if (!Object.prototype.hasOwnProperty.call(value, sub)) problems.push(`parameters.${name}.values.${key} is missing "${sub}"`);
210
+ }
211
+ }
212
+ } else if (typeof value !== "string") {
213
+ problems.push(`parameters.${name}.values.${key} must be a plain string for a single-placeholder parameter`);
214
+ }
215
+ }
216
+ }
126
217
  }
127
218
  if (t.match && (!t.match.property || t.match.value === undefined)) {
128
219
  problems.push("match is missing property or value");
@@ -0,0 +1,40 @@
1
+ // svg-instance-ids.mjs — a resolved sprite's own `id="…"`/`url(#…)` pairs
2
+ // (a gradient def and its fill/stroke reference) made unique per rendered
3
+ // instance. It exists because two DIFFERENT resolved values of the SAME
4
+ // class's SAME template — e.g. a gold lamp and a ceramic lamp, both on
5
+ // screen at once — share the identical `id="lamp-fill"` string (the id lives
6
+ // in the TEMPLATE, not the substituted colour), and duplicate ids are a real
7
+ // bug, not a cosmetic one: a browser's `url(#id)` reference resolves to
8
+ // whichever element with that id it saw FIRST in the document, so every
9
+ // later instance silently renders the FIRST instance's colours. Namespacing
10
+ // each id (and every reference to it) by the caller's own instanceKey before
11
+ // two instances share a DOM keeps each gradient its own.
12
+ //
13
+ // A resolved sprite with no ids at all (most non-material templates carry
14
+ // none) is untouched — this is a no-op unless there's actually a collision
15
+ // to prevent.
16
+
17
+ /** Every id `svg` declares, in first-appearance order. */
18
+ function idsIn(svg) {
19
+ return [...new Set([...svg.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]))];
20
+ }
21
+
22
+ /**
23
+ * Suffix every `id="X"` and every `url(#X)` reference to it in `svg` with
24
+ * `-{instanceKey}` (sanitized to id-safe characters) — so the SAME template
25
+ * resolved for two different instances never collides once both are in the
26
+ * same document. `instanceKey` falsy (undefined/""/0) returns `svg`
27
+ * unchanged, the same never-touch-it-unless-asked posture as any other
28
+ * optional pass-through in this pack. Pure.
29
+ */
30
+ export function namespaceSvgIds(svg, instanceKey) {
31
+ if (!instanceKey || typeof svg !== "string") return svg;
32
+ const safe = String(instanceKey).replace(/[^a-zA-Z0-9_-]/g, "-");
33
+ let out = svg;
34
+ for (const id of idsIn(svg)) {
35
+ const namespaced = `${id}-${safe}`;
36
+ out = out.split(`id="${id}"`).join(`id="${namespaced}"`);
37
+ out = out.split(`url(#${id})`).join(`url(#${namespaced})`);
38
+ }
39
+ return out;
40
+ }
@@ -19,27 +19,33 @@
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
- // Two pure, `.toString()`-splice-safe pieces are exported as real functions
22
+ // Five 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
26
- // class, from its own rdf:type or mgx:is-container fact — NOT from
27
- // adventure.mjs's private isContainer/isTyped, which this module cannot
28
- // import without duplicating adventure.mjs's own closed vocabulary reading)
29
- // and `roomSceneObjects` (every subject actually visible in a room, mirrored
30
- // from adventure.mjs's private `visibleRoomOf` the same way
31
- // adventure-autoplay.mjs's own `roomOfSubject` already has to). Two further
32
- // pure helpers are exported for testing but NOT spliced, because each calls
33
- // an adventure.mjs export the in-page script instead reaches through the
34
- // browser bundle's own `tmctAdventure` global (mirroring how the inline
35
- // script calls `tmctSpiderFly.*` rather than re-importing
36
- // spider-fly-world.mjs): `roomCaptionText` (calls `worldDigestRows`; the
37
- // in-page `captionFor` mirrors it against `tmctAdventure.worldDigestRows`)
38
- // and `pillsForRoom` (the room's clickable command suggestions — a thin
26
+ // class, from its own rdf:type or mgx:is-container fact), `visibleRoomOf`
27
+ // (the room a subject's placement resolves into, for visibility mirroring
28
+ // adventure.mjs's own private `visibleRoomOf` the same way
29
+ // adventure-autoplay.mjs's own `roomOfSubject` already has to), `roomSceneObjects`
30
+ // (every subject actually visible in a room, built over `visibleRoomOf`),
31
+ // `carriedItems` (every object placed with the player), and `visitedRoomGraph`
32
+ // (a directions-only layout of the rooms a session has actually visited
33
+ // see its own header for the exposure discipline). None of these import
34
+ // anything beyond this module's own exports, which is what keeps a raw
35
+ // `.toString()` splice safe. Three further pure helpers are exported for
36
+ // testing but NOT spliced, because each calls another module's export the
37
+ // in-page script instead reaches through the browser bundle's own
38
+ // `tmctAdventure` global (mirroring how the inline script calls
39
+ // `tmctSpiderFly.*` rather than re-importing spider-fly-world.mjs):
40
+ // `roomCaptionText` (calls `worldDigestRows`; the in-page `captionFor`
41
+ // mirrors it against `tmctAdventure.worldDigestRows`), `pillsForRoom` (a thin
39
42
  // wrapper over adventure.mjs's own exported `roomAffordances`, whose header
40
43
  // explains why its list can never promise an action one of take/open/talk/
41
44
  // examine would then refuse; the in-page `pillsFor` mirrors it against
42
- // `tmctAdventure.roomAffordances`).
45
+ // `tmctAdventure.roomAffordances`), and `goalStatusLines` (calls
46
+ // `foldWorldState` and adventure-autoplay.mjs's own `exposedFacts`; the
47
+ // in-page `goalStatusLinesFor` mirrors both against the `tmctAdventure`
48
+ // global too).
43
49
  //
44
50
  // The chat dock (chatlog/chatform/chatq/pills, below) mirrors
45
51
  // spider-fly-viz.mjs's own side panel: every manual exchange (via
@@ -48,9 +54,17 @@
48
54
  // a visitor sees one continuous history rather than a line overwritten every
49
55
  // tick. `#caption`/`#goalLine` keep their existing job as the current-state
50
56
  // summary; the chat log is the persistent addition.
57
+ //
58
+ // Three further panels sit alongside the chat dock in `.side`: carrying
59
+ // (`carriedItems`), the visited-room map (`visitedRoomGraph`), and goal
60
+ // status (`goalStatusLines`) — all three read `session.snapshot()`'s own
61
+ // `visitedRoomIds`, the manual+auto-play exposure set
62
+ // adventure-browser-entry.mjs threads forward (see that module's header for
63
+ // why the two paths share one set rather than two).
51
64
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
52
65
  import { createTicker } from "./viz-ticker.mjs";
53
- import { worldDigestRows, roomAffordances } from "./adventure.mjs";
66
+ import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
67
+ import { exposedFacts } from "./adventure-autoplay.mjs";
54
68
 
55
69
  const DEFAULT_TITLE = "tmct — the adventure";
56
70
  const PREVIEW_MAX_TICKS = 30;
@@ -97,34 +111,142 @@ export function factsForSubject(rows, subject) {
97
111
  return (rows || []).filter((r) => r.subject === subject);
98
112
  }
99
113
 
114
+ /** The room a subject's placement resolves into, for VISIBILITY — a room
115
+ * resolves to itself; an object placed directly in a room resolves to that
116
+ * room; an object one containment hop inside an OPEN container resolves to
117
+ * the container's own room; anything hidden, carried, or inside a closed
118
+ * container resolves to null. Mirrors adventure.mjs's own private
119
+ * `visibleRoomOf` / adventure-autoplay.mjs's own private `roomOfSubject`
120
+ * exactly, against whatever rows/state pair the caller hands it: feeding it
121
+ * the full fold (as `roomSceneObjects` does, since the player is already
122
+ * standing in the room being drawn) lets an open container's contents show;
123
+ * feeding it an exposure-filtered pair (as `goalStatusLines` does) is what
124
+ * keeps a not-yet-visited location unknown. Pure, self-contained — no
125
+ * reference to adventure.mjs's own private helpers, since those aren't
126
+ * exported. */
127
+ export function visibleRoomOf(rows, state, subject) {
128
+ const isRoom = (id) => (rows || []).some((r) => r.subject === id && r.predicate === "rdf:type" && r.object === "room");
129
+ if (isRoom(subject)) return subject;
130
+ const place = state.placements.get(subject);
131
+ if (!place || place.predicate === "mgx:hidden-in") return null;
132
+ if (place.predicate === "mgx:currently-in" || isRoom(place.object)) return place.object;
133
+ const holder = place.object;
134
+ if (holder === "player") return null;
135
+ if (!state.openness.get(holder)?.open) return null;
136
+ const holderPlace = state.placements.get(holder);
137
+ return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
138
+ }
139
+
100
140
  /** Every subject actually visible in `here`, sorted, each with its sprite
101
- * class — mirroring adventure.mjs's own private `visibleRoomOf` walk (one
102
- * containment hop through an OPEN container) so this can never draw a
103
- * hidden or carried object the text digest wouldn't also mention. `player`
104
- * is excluded; the caller draws the player's own adventurer sprite
105
- * separately. Pure. */
141
+ * class — built over `visibleRoomOf` (one containment hop through an OPEN
142
+ * container) so this can never draw a hidden or carried object the text
143
+ * digest wouldn't also mention. `player` is excluded; the caller draws the
144
+ * player's own adventurer sprite separately. Pure. */
106
145
  export function roomSceneObjects(rows, state, here) {
107
- const isTypedRoom = (subject) =>
108
- (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === "room");
109
- const visibleRoomOf = (subject) => {
110
- const place = state.placements.get(subject);
111
- if (!place || place.predicate === "mgx:hidden-in") return null;
112
- if (place.predicate === "mgx:currently-in" || isTypedRoom(place.object)) return place.object;
113
- const holder = place.object;
114
- if (holder === "player") return null;
115
- if (!state.openness.get(holder)?.open) return null;
116
- const holderPlace = state.placements.get(holder);
117
- return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
118
- };
119
146
  const out = [];
120
147
  for (const subject of [...state.placements.keys()].sort()) {
121
148
  if (subject === "player") continue;
122
- if (visibleRoomOf(subject) !== here) continue;
149
+ if (visibleRoomOf(rows, state, subject) !== here) continue;
123
150
  out.push({ subject, spriteClass: spriteClassForObject(rows, subject) });
124
151
  }
125
152
  return out;
126
153
  }
127
154
 
155
+ /** Every object currently `mgx:located-in` "player", sorted, each with its
156
+ * sprite class — the exact placement `worldDigestRows`'/`inventoryAnswer`'s
157
+ * own "carries the" branch already reads, just returned as a plain list
158
+ * instead of prose. Pure. */
159
+ export function carriedItems(rows, state) {
160
+ return [...state.placements]
161
+ .filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
162
+ .map(([subject]) => ({ subject, spriteClass: spriteClassForObject(rows, subject) }))
163
+ .sort((a, b) => a.subject.localeCompare(b.subject));
164
+ }
165
+
166
+ /** A visited-rooms-only map: one node per room `visitedRoomIds` actually
167
+ * names, laid out on an integer grid FROM the world's own has-exit-*
168
+ * directions — never a force-directed guess, so a room with a north exit to
169
+ * another visited room sits one row above it (up/down read the same way,
170
+ * since neither world this project ships gives a vertical exit its own
171
+ * lateral position). An edge is drawn only between two rooms BOTH already
172
+ * visited, mirroring adventure-autoplay.mjs's own exposedExitApplyActions:
173
+ * an exit fact belongs to the room whose subject it is, so it is only ever
174
+ * read off a VISITED room's own state.exits entry, never an unvisited
175
+ * room's. `hints` names every exit a visited room has toward a room not yet
176
+ * visited — the direction only, never the unvisited room's own name, so a
177
+ * caller can draw at most "there's an exit that way" and nothing more.
178
+ * Disconnected visited rooms (not reachable from each other by traveled
179
+ * edges) lay out as separate side-by-side blocks rather than overlapping.
180
+ * Pure. */
181
+ export function visitedRoomGraph(state, visitedRoomIds) {
182
+ const DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
183
+ const visited = new Set(visitedRoomIds || []);
184
+ const here = state.placements.get("player")?.object ?? null;
185
+ const positions = new Map();
186
+ const edges = [];
187
+ const edgeKeys = new Set();
188
+ const hints = [];
189
+ let offsetX = 0;
190
+ for (const start of [...visited].sort()) {
191
+ if (positions.has(start)) continue;
192
+ positions.set(start, { x: offsetX, y: 0 });
193
+ const queue = [start];
194
+ const component = [start];
195
+ while (queue.length) {
196
+ const room = queue.shift();
197
+ const pos = positions.get(room);
198
+ const dirs = state.exits.get(room);
199
+ for (const direction of [...(dirs?.keys() ?? [])].sort()) {
200
+ const target = dirs.get(direction);
201
+ if (!visited.has(target)) { hints.push({ from: room, direction }); continue; }
202
+ const key = [room, target].sort().join("\0");
203
+ if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
204
+ if (!positions.has(target)) {
205
+ const [dx, dy] = DELTA[direction] ?? [0, 0];
206
+ positions.set(target, { x: pos.x + dx, y: pos.y + dy });
207
+ component.push(target);
208
+ queue.push(target);
209
+ }
210
+ }
211
+ }
212
+ offsetX = Math.max(...component.map((r) => positions.get(r).x)) + 2;
213
+ }
214
+ const minX = Math.min(0, ...[...positions.values()].map((p) => p.x));
215
+ const minY = Math.min(0, ...[...positions.values()].map((p) => p.y));
216
+ const nodes = [...visited].sort().map((room) => {
217
+ const p = positions.get(room) || { x: 0, y: 0 };
218
+ return { id: room, x: p.x - minX, y: p.y - minY, current: room === here };
219
+ });
220
+ return { nodes, edges, hints };
221
+ }
222
+
223
+ /** One status line per exposed `mgx:is-objective` fact, using the SAME
224
+ * three-state logic adventure-autoplay.mjs's own goal inference already
225
+ * distinguishes, restricted to what `visitedRoomIds` actually supports:
226
+ * carried (the full, unfiltered state — carrying is always self-evidently
227
+ * known, the same unconditional exposure the marker fact itself gets);
228
+ * exposed with a known room (an `exposedFacts` filter, mirroring
229
+ * runAdventureAutoplayTick's own objectiveRoom lookup, so a location is
230
+ * never claimed before the room holding it has actually been visited); or
231
+ * not yet exposed at all (the opening-line-level knowledge every session
232
+ * gets unconditionally — the marker fact names the sought thing, nothing
233
+ * more). Pure. */
234
+ export function goalStatusLines(rows, state, visitedRoomIds) {
235
+ const ids = [...new Set((rows || [])
236
+ .filter((r) => r.predicate === "mgx:is-objective" && r.object === "true")
237
+ .map((r) => r.subject))];
238
+ const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
239
+ const exposedRows = exposedFacts(rows, visitedRoomIds);
240
+ const exposedState = foldWorldState(exposedRows);
241
+ return ids.map((id) => {
242
+ if (carriedIds.has(id)) return { subject: id, status: "carried", text: `carrying the ${id} — the adventure is won.` };
243
+ const room = visibleRoomOf(exposedRows, exposedState, id);
244
+ return room
245
+ ? { subject: id, status: "known", text: `last known: the ${id} is in the ${room}.` }
246
+ : { subject: id, status: "unknown", text: `there's a sought-after ${id} somewhere.` };
247
+ });
248
+ }
249
+
128
250
  /** The clickable command suggestions for the room the player is CURRENTLY
129
251
  * in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
130
252
  * (the exact same data take/open/talk/examine already check), so a pill can
@@ -210,8 +332,22 @@ ${THEME_TOKENS_CSS}
210
332
  .sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
211
333
  .caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
212
334
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
213
- .chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
214
- .chat h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
335
+ .chat, .panel { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
336
+ .chat h2, .panel h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
337
+ .empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
338
+ .chips { display: flex; flex-wrap: wrap; gap: .35rem; }
339
+ .chip { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 999px; }
340
+ .roommap svg { width: 100%; height: auto; display: block; }
341
+ .roommap .room-edge { stroke: var(--line); stroke-width: 1.5; }
342
+ .roommap .room-hint { fill: var(--muted); opacity: .45; }
343
+ .roommap .room-node circle { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
344
+ .roommap .room-node.current circle { stroke: var(--taught); fill: var(--taught-soft); stroke-width: 2; }
345
+ .roommap .room-node text { font-family: ${MONO_STACK}; font-size: 6.5px; fill: var(--ink); text-anchor: middle; }
346
+ .goal-status { display: flex; flex-direction: column; gap: .4rem; }
347
+ .goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
348
+ .goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
349
+ .goal-status .g.known .dot { background: var(--corpus); }
350
+ .goal-status .g.carried .dot { background: var(--taught); }
215
351
  .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 320px; overflow-y: auto; margin-bottom: .5rem; }
216
352
  .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
217
353
  .chatlog .u::before { content: "tmct> "; color: var(--taught); }
@@ -257,6 +393,18 @@ ${THEME_TOKENS_CSS}
257
393
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
258
394
  </form>
259
395
  </div>
396
+ <div class="panel carrying">
397
+ <h2>carrying</h2>
398
+ <div class="chips" id="carryList"></div>
399
+ </div>
400
+ <div class="panel roommap">
401
+ <h2>rooms visited</h2>
402
+ <div id="mapWrap"></div>
403
+ </div>
404
+ <div class="panel goals">
405
+ <h2>goal</h2>
406
+ <div id="goalList"></div>
407
+ </div>
260
408
  </aside>
261
409
  </div>
262
410
  <div class="caption" id="caption"></div>
@@ -278,7 +426,10 @@ const ADVENTURE = ${pageData};
278
426
  "use strict";
279
427
  const createTicker = ${createTicker.toString()};
280
428
  const spriteClassForObject = ${spriteClassForObject.toString()};
429
+ const visibleRoomOf = ${visibleRoomOf.toString()};
281
430
  const roomSceneObjects = ${roomSceneObjects.toString()};
431
+ const carriedItems = ${carriedItems.toString()};
432
+ const visitedRoomGraph = ${visitedRoomGraph.toString()};
282
433
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
283
434
  const factsForSubject = ${factsForSubject.toString()};
284
435
  const esc = ${escapeHtml.toString()};
@@ -295,6 +446,9 @@ const ADVENTURE = ${pageData};
295
446
  const pillsEl = el("pills");
296
447
  const chatformEl = el("chatform");
297
448
  const chatqEl = el("chatq");
449
+ const carryListEl = el("carryList");
450
+ const mapWrapEl = el("mapWrap");
451
+ const goalListEl = el("goalList");
298
452
 
299
453
  const params = new URLSearchParams(location.search);
300
454
  const preview = params.get("preview") === "1";
@@ -311,6 +465,79 @@ const ADVENTURE = ${pageData};
311
465
  return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
312
466
  }
313
467
 
468
+ // ---- goal status — mirrors adventure-viz.mjs's own goalStatusLines
469
+ // against tmctAdventure.foldWorldState/tmctAdventure.exposedFacts (the
470
+ // same mirroring captionFor/pillsFor already do against their own
471
+ // adventure.mjs exports), so a location is never claimed before the room
472
+ // holding it has actually been visited this session.
473
+ function goalStatusLinesFor(rows, state, visitedRoomIds) {
474
+ const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
475
+ const carriedIds = new Set(carriedItems(rows, state).map((o) => o.subject));
476
+ const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
477
+ const exposedState = tmctAdventure.foldWorldState(exposedRows);
478
+ return ids.map((id) => {
479
+ if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
480
+ const room = visibleRoomOf(exposedRows, exposedState, id);
481
+ return room
482
+ ? { subject: id, status: "known", text: "last known: the " + id + " is in the " + room + "." }
483
+ : { subject: id, status: "unknown", text: "there's a sought-after " + id + " somewhere." };
484
+ });
485
+ }
486
+
487
+ // ---- carrying panel — every object placed with the player, rendered as
488
+ // the same non-interactive chip shape the room's own pills use for
489
+ // actions, so the sidebar reads as one visual family.
490
+ function renderCarrying(rows, state) {
491
+ const items = carriedItems(rows, state);
492
+ carryListEl.innerHTML = items.length
493
+ ? items.map((o) => '<span class="chip">' + esc(o.subject) + "</span>").join("")
494
+ : '<span class="empty-note">nothing yet</span>';
495
+ }
496
+
497
+ // ---- visited-room map — an SVG laid out directly from visitedRoomGraph's
498
+ // own directional grid, never a second layout. Edges only ever join two
499
+ // ALREADY-visited rooms; hints mark a known exit's direction from a
500
+ // visited room without naming or drawing the unvisited room itself.
501
+ function renderRoomMap(rows, state, visitedRoomIds) {
502
+ const graph = visitedRoomGraph(state, visitedRoomIds);
503
+ if (!graph.nodes.length) { mapWrapEl.innerHTML = '<span class="empty-note">nowhere yet</span>'; return; }
504
+ const cell = 56, radius = 15;
505
+ const maxX = Math.max.apply(null, graph.nodes.map((n) => n.x));
506
+ const maxY = Math.max.apply(null, graph.nodes.map((n) => n.y));
507
+ const w = (maxX + 1) * cell, h = (maxY + 1) * cell + 14;
508
+ const cx = (n) => (n.x + 0.5) * cell;
509
+ const cy = (n) => (n.y + 0.5) * cell;
510
+ const byRoom = new Map(graph.nodes.map((n) => [n.id, n]));
511
+ const edgesSvg = graph.edges.map((e) => {
512
+ const a = byRoom.get(e.from), b = byRoom.get(e.to);
513
+ return '<line class="room-edge" x1="' + cx(a) + '" y1="' + cy(a) + '" x2="' + cx(b) + '" y2="' + cy(b) + '"></line>';
514
+ }).join("");
515
+ const HINT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
516
+ const hintsSvg = graph.hints.map((hi) => {
517
+ const from = byRoom.get(hi.from);
518
+ const d = HINT_DELTA[hi.direction] || [0, 0];
519
+ return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3"></circle>';
520
+ }).join("");
521
+ const nodesSvg = graph.nodes.map((n) => {
522
+ const cls = "room-node" + (n.current ? " current" : "");
523
+ return '<g class="' + cls + '"><circle cx="' + cx(n) + '" cy="' + cy(n) + '" r="' + radius + '"></circle>'
524
+ + '<text x="' + cx(n) + '" y="' + (cy(n) + radius + 9) + '">' + esc(n.id) + "</text></g>";
525
+ }).join("");
526
+ mapWrapEl.innerHTML = '<svg viewBox="0 0 ' + w + " " + h + '" role="img" aria-label="the rooms visited so far">'
527
+ + edgesSvg + hintsSvg + nodesSvg + "</svg>";
528
+ }
529
+
530
+ // ---- goal panel — one line per exposed objective, a colored dot carrying
531
+ // the same three-state read goalStatusLinesFor's own status field names.
532
+ function renderGoals(rows, state, visitedRoomIds) {
533
+ const lines = goalStatusLinesFor(rows, state, visitedRoomIds);
534
+ goalListEl.innerHTML = lines.length
535
+ ? '<div class="goal-status">' + lines.map((l) =>
536
+ '<div class="g ' + l.status + '"><span class="dot"></span><span>' + esc(l.text) + "</span></div>").join("")
537
+ + "</div>"
538
+ : '<span class="empty-note">this world names no goal.</span>';
539
+ }
540
+
314
541
  // ---- chat/event log — every manual exchange AND every auto-play tick's
315
542
  // own narration append here, in order, so it reads as one continuous
316
543
  // history rather than a line overwritten every tick.
@@ -370,6 +597,9 @@ const ADVENTURE = ${pageData};
370
597
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
371
598
  turnLabelEl.textContent = "turn: " + snap.turn;
372
599
  renderPills(snap.rows, snap.state, snap.here);
600
+ renderCarrying(snap.rows, snap.state);
601
+ renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
602
+ renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
373
603
  }
374
604
 
375
605
  // ---- serialize every engine-touching call: the ticker and the chat dock