@polycode-projects/the-mechanical-code-talker 2.7.26 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -16
- package/corpus/tier2/generate.mjs +1 -0
- package/corpus/tier2/human.jsonl +1 -0
- package/corpus/tier2/manifest.json +3 -3
- package/package.json +1 -1
- package/src/adapters/corpus/sprite-large-template-files.mjs +6 -2
- package/src/domain/sprite-expressions.mjs +120 -0
- package/src/domain/sprite-templates.mjs +31 -9
- package/src/services/adventure-viz.mjs +266 -36
- package/src/services/chat-page-viz.mjs +398 -0
- package/src/services/sprite-catalog-viz.mjs +395 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +38 -12
|
@@ -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
|