@polycode-projects/the-mechanical-code-talker 2.7.26 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.7.26",
3
+ "version": "2.8.0",
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; no codebase index of its own.",
@@ -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
@@ -36,7 +36,7 @@ import {
36
36
  import { parseEntities } from "../../domain/codegraph.mjs";
37
37
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
38
38
  import { foldWorldState, worldDigestRows, roomAffordances } from "../../services/adventure.mjs";
39
- import { runAdventureAutoplayTick } from "../../services/adventure-autoplay.mjs";
39
+ import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
40
40
  import { resolveSpriteForClass, SPRITE_REGISTRY } from "../../domain/sprite-map.mjs";
41
41
  import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
42
42
 
@@ -59,10 +59,26 @@ export async function createAdventureSession(worldPayload) {
59
59
 
60
60
  const planHolder = { state: { adventure: { world: worldPayload.name } } };
61
61
  const sessionId = globalThis.crypto?.randomUUID?.() ?? String(Date.now());
62
- let exposedRoomIds = new Set();
62
+ // visitedRoomIds is the ONE exposure set both `turn()` and `autoplayTick()`
63
+ // grow — a deliberate merge, not two separate histories. Both entry points
64
+ // move the SAME player through the SAME shared memoryDir: `here` is always
65
+ // read fresh off the live fact store (state.placements.get("player")), not
66
+ // off whichever path last ran, so by the time either call finishes, the
67
+ // room the player is standing in is genuinely known to a visitor looking
68
+ // at the page regardless of which control moved them there. Keeping two
69
+ // separate sets would make the map/goal panels UNDER-report a room a
70
+ // visitor plainly just walked autoplay through (or vice versa) — a false
71
+ // conservatism, not real honesty, since nothing about the OTHER path's
72
+ // moves is hidden from this same session. runAdventureAutoplayTick's own
73
+ // returned `exposedRoomIds` already folds forward whatever it was handed,
74
+ // so feeding it this merged set on every tick, and folding its result back
75
+ // into the same variable, is enough: manual moves feed autoplay's own
76
+ // reasoning, autoplay's moves feed the panels, with no separate
77
+ // bookkeeping either way.
78
+ let visitedRoomIds = new Set();
63
79
  const openingRows = readFactRows(await loadMemory(memoryDir));
64
80
  const openingHere = foldWorldState(openingRows).placements.get("player")?.object ?? null;
65
- if (openingHere) exposedRoomIds = new Set([openingHere]);
81
+ if (openingHere) visitedRoomIds = new Set([openingHere]);
66
82
 
67
83
  const graph = parseEntities({ individuals: [], objectProperties: [] });
68
84
  const lexicon = loadLexicon();
@@ -78,16 +94,18 @@ export async function createAdventureSession(worldPayload) {
78
94
  * `{ turn, goal, plan, done, stalled }` unmodified. */
79
95
  async autoplayTick() {
80
96
  const result = await runAdventureAutoplayTick(memoryDir, {
81
- exposedRoomIds, planHolder, sessionId, env: {},
97
+ exposedRoomIds: visitedRoomIds, planHolder, sessionId, env: {},
82
98
  });
83
- exposedRoomIds = result.exposedRoomIds;
99
+ visitedRoomIds = result.exposedRoomIds;
84
100
  return result;
85
101
  },
86
102
 
87
103
  /** One dispatched chat turn — the SAME runTurn the CLI and every other
88
104
  * viz page's own chat dock run, over this session's own memoryDir. A
89
105
  * throwing runTurn must never kill the session — the page has no other
90
- * chance to show this turn's answer. */
106
+ * chance to show this turn's answer. Grows `visitedRoomIds` with the
107
+ * player's post-turn room (a no-op add when the command didn't move
108
+ * anyone) — the manual-play half of the merged exposure set. */
91
109
  async turn(line) {
92
110
  let result;
93
111
  try {
@@ -102,25 +120,33 @@ export async function createAdventureSession(worldPayload) {
102
120
  focus = result.focus;
103
121
  last = result.last;
104
122
  if ("planState" in result) planHolder.state = result.planState;
123
+ const here = foldWorldState(readFactRows(await loadMemory(memoryDir))).placements.get("player")?.object ?? null;
124
+ if (here) visitedRoomIds.add(here);
105
125
  return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
106
126
  },
107
127
 
108
128
  /** A read-only fold of the current room — no engine advance — for the
109
129
  * page's own redraw after boot, after every tick, and after every
110
- * manual chat turn. */
130
+ * manual chat turn. `visitedRoomIds` travels as a plain array (the same
131
+ * merged exposure set `turn()`/`autoplayTick()` both grow) so the
132
+ * carrying/map/goal panels can read it without holding a live
133
+ * reference into this closure's own Set. */
111
134
  async snapshot() {
112
135
  const rows = readFactRows(await loadMemory(memoryDir));
113
136
  const state = foldWorldState(rows);
114
137
  const here = state.placements.get("player")?.object ?? null;
115
- return { rows, state, here, turn: state.turnCount };
138
+ return { rows, state, here, turn: state.turnCount, visitedRoomIds: [...visitedRoomIds] };
116
139
  },
117
140
  };
118
141
  }
119
142
 
120
143
  // Re-exported so the page's own rendering script (adventure-viz.mjs) never
121
- // has to duplicate sprite resolution, the digest reader, or the room
122
- // affordances the chat dock's own pills read from the same posture
123
- // spider-fly-browser-entry.mjs's own globalThis.tmctSpiderFly re-export takes.
144
+ // has to duplicate sprite resolution, the digest reader, the room
145
+ // affordances the chat dock's own pills read from, or (foldWorldState,
146
+ // exposedFacts) the exposure-filtered fold the goal-status panel mirrors —
147
+ // the same posture spider-fly-browser-entry.mjs's own
148
+ // globalThis.tmctSpiderFly re-export takes.
124
149
  globalThis.tmctAdventure = {
125
- createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset, worldDigestRows, roomAffordances,
150
+ createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
151
+ worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
126
152
  };