@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.
|
|
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
|
-
//
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// in-page
|
|
38
|
-
//
|
|
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 —
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
122
|
-
// affordances the chat dock's own pills read from
|
|
123
|
-
//
|
|
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,
|
|
150
|
+
createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
|
|
151
|
+
worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
|
|
126
152
|
};
|