@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +119 -150
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +64 -48
  21. package/src/services/chat.mjs +102 -34
  22. package/src/services/code-explorer-viz.mjs +52 -50
  23. package/src/services/ingest-viz.mjs +32 -74
  24. package/src/services/ledger-viz.mjs +87 -70
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +119 -225
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +13 -12
  32. package/src/services/research-viz.mjs +25 -67
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +97 -136
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +274 -224
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
  42. package/src/surfaces/web/chat-browser-entry.mjs +51 -107
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
  44. package/src/surfaces/web/engine-surface.mjs +82 -0
  45. package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
  46. package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
  47. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  48. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  49. package/src/surfaces/web/memory-stats.mjs +11 -0
  50. package/src/surfaces/web/mud-browser-entry.mjs +70 -49
  51. package/src/surfaces/web/plan-browser-entry.mjs +39 -50
  52. package/src/surfaces/web/research-browser-entry.mjs +48 -46
  53. package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
  54. package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
  55. package/src/surfaces/web/tmct-surface.mjs +147 -0
  56. package/src/surfaces/web/turn-session.mjs +124 -0
  57. package/src/tools/definitions.mjs +30 -0
  58. package/src/tools/handlers/index.mjs +6 -3
  59. package/src/tools/handlers/kit.mjs +19 -2
  60. package/src/tools/handlers/tmct-ask.mjs +11 -6
  61. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  62. package/src/tools/handlers/tmct-related.mjs +4 -4
  63. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  64. package/src/tools/memory-fallthrough.mjs +9 -2
  65. package/src/tools/server.mjs +37 -6
@@ -0,0 +1,71 @@
1
+ // viz-boot.mjs — the shared wink-nlp vendor loader six-plus-one viz modules
2
+ // each hand-rolled with the same bounded-race shape (`research-viz.mjs`,
3
+ // `ingest-viz.mjs`, `plan-viz.mjs`, `ledger-viz.mjs`, `sprite-catalog-viz.mjs`,
4
+ // `chat-page-viz.mjs`, each commenting that it uses "the same pattern" as a
5
+ // sibling; `code-explorer-viz.mjs` is a seventh with no timeout at all).
6
+ // `import()` can fail in a way that neither resolves nor rejects (a stalled
7
+ // network request, an old cached asset), so every copy already raced it
8
+ // against a timeout rather than awaiting it unbounded.
9
+ //
10
+ // This converges the seven onto ONE failure behaviour: a fixed timer from
11
+ // the call, memoized so a second boot path awaiting the same load (every
12
+ // caller here has at least two — an eager fire-and-forget at page load, then
13
+ // an `await` inside its own session-ensuring function) resolves immediately
14
+ // rather than racing the import a second time. That is the shape five of the
15
+ // seven already shared; `chat-page-viz.mjs`'s own richer variant measures the
16
+ // timeout from the last downloaded byte instead of the call, so a slow-but-
17
+ // progressing multi-megabyte download is never abandoned mid-stream — a real
18
+ // improvement this shared version does not attempt to fold in, so that page
19
+ // keeps its own version rather than losing the nuance to this one.
20
+ //
21
+ // Self-contained (no closure over page state beyond its own options),
22
+ // `.toString()`-splice safe: `importVendor`'s default is a literal dynamic
23
+ // `import()` expression, so when this factory's returned function is spliced
24
+ // into a page's own inline `<script>` via `Function.prototype.toString()`,
25
+ // the import path resolves relative to that page, exactly like every one of
26
+ // the seven originals.
27
+
28
+ /**
29
+ * `loadWinkVendor({ timeoutMs, register, importVendor })` returns a memoized
30
+ * loader function — call it with no arguments, as many times as needed (an
31
+ * eager fire at boot, then an awaited call before the first session needs
32
+ * it); every call after the first resolves the SAME promise rather than
33
+ * importing again.
34
+ *
35
+ * `register(factory)` is called once, only on a successful load, with a
36
+ * factory returning `{ winkNLP, model }` — the shape every one of the seven
37
+ * originals hands to their own page's `registerWinkModel`. `importVendor`
38
+ * (default: `() => import("./vendor/wink.js")`) is the dynamic import call
39
+ * itself, injectable so a test can supply a resolved/rejected/never-settling
40
+ * promise without a real vendor asset on disk.
41
+ *
42
+ * The loader never throws: a timeout or a load failure logs a warning and
43
+ * resolves `"unavailable"` — the lemma/POS tier is optional everywhere it is
44
+ * used, so a caller that awaits this is never blocked by its own boot.
45
+ * Resolves `"loaded"` on success.
46
+ */
47
+ export function loadWinkVendor({
48
+ timeoutMs = 8000,
49
+ register,
50
+ importVendor = () => import("./vendor/wink.js"),
51
+ } = {}) {
52
+ let ready = null;
53
+ return function loadWinkVendorOnce() {
54
+ if (ready) return ready;
55
+ ready = (async () => {
56
+ const timeout = new Promise((_, reject) => {
57
+ setTimeout(() => reject(new Error("wink vendor asset load timed out")), timeoutMs);
58
+ });
59
+ try {
60
+ const mod = await Promise.race([importVendor(), timeout]);
61
+ if (register) register(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
62
+ return "loaded";
63
+ } catch (err) {
64
+ // eslint-disable-next-line no-console
65
+ console.warn("tmct: the wink vendor asset failed to load, continuing without the lemma/POS tier", err);
66
+ return "unavailable";
67
+ }
68
+ })();
69
+ return ready;
70
+ };
71
+ }
@@ -0,0 +1,203 @@
1
+ // viz-room-graph.mjs — the room-graph layout and SVG renderer mud-viz.mjs
2
+ // (`burrowGraph`/`burrowSvg`) and adventure-viz.mjs (`visitedRoomGraph`/its
3
+ // own inline `roomMapSvg`) each wrote separately, unified into one
4
+ // parametrized pair. mud-viz.mjs's own header already admits the copy.
5
+ //
6
+ // The two originals genuinely differ, not just in name: mud's burrow can be
7
+ // dug two ways into the same cell (down AND south from one room), so it
8
+ // nudges a colliding placement right rather than overlapping it, and it
9
+ // tracks each room's surface/soil `level` for the turf line; adventure's
10
+ // manor is fixed at authoring time and never collides, and marks the
11
+ // player's current room instead of a level. `opts` below select each
12
+ // behaviour rather than assuming one world's shape:
13
+ // - `opts.root`, if given, is placed before every other room (a stable
14
+ // anchor for one entry point) and turns on `level` (an "up"/"down" exit
15
+ // moves the level by ∓1, tracked via a BFS from `root`).
16
+ // - `opts.actingSubject`, if given, attaches `.current` (whether that
17
+ // subject's own room is this node) instead of `.level`.
18
+ // - `opts.nudgeCollisions` (default false) pushes a colliding placement
19
+ // right instead of overlapping it.
20
+ // An edge is drawn only between two rooms BOTH in `roomIds`; `hints` names
21
+ // every exit from an included room toward one that is not — the direction
22
+ // only, never the excluded room's own name, so a fog-of-war caller can draw
23
+ // "there's a way on" and nothing more. Disconnected components lay out as
24
+ // separate side-by-side blocks. Pure.
25
+
26
+ import { escapeHtml } from "./viz-theme.mjs";
27
+
28
+ // Exported (not just used internally) so a caller splicing directedGridLayout/
29
+ // roomGraphSvg into its own inline page script — as data, via JSON.stringify,
30
+ // never `.toString()` — can carry this table along too: a spliced function's
31
+ // source text is its own body only, never a module-level const it closes over.
32
+ export const EXIT_DELTA = { north: [0, -1], south: [0, 1], east: [1, 0], west: [-1, 0], up: [0, -1], down: [0, 1] };
33
+
34
+ /** Every room's depth level, BFS from `root` at level 0: an "up"/"down" exit
35
+ * moves the level by ∓1, any other direction keeps it unchanged. A room
36
+ * unreachable from `root` is absent from the returned map. Pure. */
37
+ export function levelsOf(state, root) {
38
+ const levels = new Map([[root, 0]]);
39
+ const queue = [root];
40
+ while (queue.length) {
41
+ const room = queue.shift();
42
+ const level = levels.get(room);
43
+ for (const [direction, target] of state.exits.get(room) ?? []) {
44
+ if (levels.has(target)) continue;
45
+ const delta = direction === "down" ? -1 : direction === "up" ? 1 : 0;
46
+ levels.set(target, level + delta);
47
+ queue.push(target);
48
+ }
49
+ }
50
+ return levels;
51
+ }
52
+
53
+ /** Lay `roomIds` out on an integer grid FROM the world's own has-exit-*
54
+ * directions — never a force-directed guess, so a room north of another
55
+ * sits one row above it and a room dug down sits one row below. See the
56
+ * file header for what `opts.root`/`opts.actingSubject`/
57
+ * `opts.nudgeCollisions` each turn on. Pure. */
58
+ export function directedGridLayout(state, roomIds, opts = {}) {
59
+ const { root = null, actingSubject = null, nudgeCollisions = false } = opts;
60
+ const known = new Set(roomIds || []);
61
+ const here = actingSubject ? (state.placements.get(actingSubject)?.object ?? null) : null;
62
+ const positions = new Map();
63
+ const taken = new Set();
64
+ const edges = [];
65
+ const edgeKeys = new Set();
66
+ const hints = [];
67
+ const place = (room, x, y) => {
68
+ let px = x;
69
+ if (nudgeCollisions) {
70
+ for (let guard = 0; taken.has(px + "," + y) && guard < 64; guard += 1) px += 1;
71
+ }
72
+ positions.set(room, { x: px, y });
73
+ taken.add(px + "," + y);
74
+ };
75
+ const seeds = root ? [root, ...[...known].sort()] : [...known].sort();
76
+ let offsetX = 0;
77
+ for (const start of seeds) {
78
+ if (!known.has(start) || positions.has(start)) continue;
79
+ place(start, offsetX, 0);
80
+ const queue = [start];
81
+ const component = [start];
82
+ while (queue.length) {
83
+ const room = queue.shift();
84
+ const pos = positions.get(room);
85
+ const dirs = state.exits.get(room);
86
+ for (const direction of [...(dirs?.keys() ?? [])].sort()) {
87
+ const target = dirs.get(direction);
88
+ if (!known.has(target)) { hints.push({ from: room, direction }); continue; }
89
+ const key = [room, target].sort().join("\0");
90
+ if (!edgeKeys.has(key)) { edgeKeys.add(key); edges.push({ from: room, to: target, direction }); }
91
+ if (positions.has(target)) continue;
92
+ const [dx, dy] = EXIT_DELTA[direction] ?? [0, 0];
93
+ place(target, pos.x + dx, pos.y + dy);
94
+ component.push(target);
95
+ queue.push(target);
96
+ }
97
+ }
98
+ offsetX = Math.max(...component.map((r) => positions.get(r).x)) + 2;
99
+ }
100
+ const levels = root ? levelsOf(state, root) : null;
101
+ const xs = [...positions.values()].map((p) => p.x);
102
+ const ys = [...positions.values()].map((p) => p.y);
103
+ const minX = Math.min(0, ...xs);
104
+ const minY = Math.min(0, ...ys);
105
+ const nodes = [...known].filter((room) => positions.has(room)).sort().map((room) => {
106
+ const p = positions.get(room);
107
+ const node = { id: room, x: p.x - minX, y: p.y - minY };
108
+ if (levels) node.level = levels.has(room) ? levels.get(room) : null;
109
+ if (actingSubject) node.current = room === here;
110
+ return node;
111
+ });
112
+ return { nodes, edges, hints };
113
+ }
114
+
115
+ /** Render a `directedGridLayout` graph as an inline SVG: one rect+label per
116
+ * room, a line per edge, a small dot per hint (an exit toward a room not in
117
+ * the graph). Returns `""` for an empty graph, so `roomGraphSvg(...) ||
118
+ * fallbackHtml` reads naturally either way.
119
+ *
120
+ * `opts.compact` picks the smaller of two built-in cell/room size tiers
121
+ * (each overridable via `opts.cellX`/`cellY`/`roomW`/`roomH`).
122
+ * `opts.turf` switches on the burrow-flavoured rendering: a ground line
123
+ * between the surface and soil levels (wherever the graph's nodes carry
124
+ * `.level` and the layout genuinely splits level 0 from every other level),
125
+ * a "shaft" class on up/down edges, and `opts.occupants` (a `Map` or plain
126
+ * object keyed by room id to an array of `{ character, color }`) drawing a
127
+ * coloured, titled dot per occupant inside its room. Without `opts.turf`,
128
+ * rendering is the manor-board style instead: `opts.clickable` adds a
129
+ * `data-room` attribute and a `clickable` class, plus `selected` on
130
+ * `opts.selectedRoomId`. `opts.here`, if given, marks that room id as the
131
+ * current one (falling back to a node's own `.current` field from
132
+ * `directedGridLayout`'s `actingSubject` option); `opts.fresh` marks one
133
+ * room id as freshly created. `opts.wrapClass`, if given, wraps the `<svg>`
134
+ * in a `<div class="...">`; omit it for a bare `<svg>`. `opts.label` is the
135
+ * SVG's `aria-label`. Pure. */
136
+ export function roomGraphSvg(graph, opts = {}) {
137
+ if (!graph.nodes.length) return "";
138
+ const compact = !!opts.compact;
139
+ const turf = !!opts.turf;
140
+ const cellX = opts.cellX ?? (compact ? 44 : 76);
141
+ const cellY = opts.cellY ?? (compact ? 26 : 54);
142
+ const roomW = opts.roomW ?? (compact ? 34 : 62);
143
+ const roomH = opts.roomH ?? (compact ? 14 : 26);
144
+ const maxX = Math.max(...graph.nodes.map((n) => n.x));
145
+ const maxY = Math.max(...graph.nodes.map((n) => n.y));
146
+ const w = (maxX + 1) * cellX, h = (maxY + 1) * cellY;
147
+ const byRoom = new Map(graph.nodes.map((n) => [n.id, n]));
148
+ const cx = (n) => (n.x + 0.5) * cellX;
149
+ const cy = (n) => (n.y + 0.5) * cellY;
150
+ const occupants = opts.occupants instanceof Map ? opts.occupants : new Map(Object.entries(opts.occupants || {}));
151
+
152
+ let groundBand = "";
153
+ if (turf) {
154
+ const surfaceRows = graph.nodes.filter((n) => n.level === 0).map((n) => n.y);
155
+ const soilRows = graph.nodes.filter((n) => n.level != null && n.level !== 0).map((n) => n.y);
156
+ if (surfaceRows.length && soilRows.length && Math.max(...surfaceRows) < Math.min(...soilRows)) {
157
+ const groundY = (Math.max(...surfaceRows) + 0.5) * cellY + roomH / 2 + (compact ? 3 : 7);
158
+ groundBand = `<rect class="turf" x="0" y="0" width="${w}" height="${groundY}"></rect>`
159
+ + `<line class="ground-line" x1="0" y1="${groundY}" x2="${w}" y2="${groundY}"></line>`;
160
+ }
161
+ }
162
+
163
+ const edgesSvg = graph.edges.map((e) => {
164
+ const a = byRoom.get(e.from), b = byRoom.get(e.to);
165
+ if (!a || !b) return "";
166
+ const cls = turf ? "tunnel" + (e.direction === "up" || e.direction === "down" ? " shaft" : "") : "room-edge";
167
+ return `<line class="${cls}" x1="${cx(a)}" y1="${cy(a)}" x2="${cx(b)}" y2="${cy(b)}"></line>`;
168
+ }).join("");
169
+
170
+ const hintsSvg = graph.hints.map((hi) => {
171
+ const from = byRoom.get(hi.from);
172
+ if (!from) return "";
173
+ const [dx, dy] = EXIT_DELTA[hi.direction] || [0, 0];
174
+ const reach = turf ? 0.36 : 0.42;
175
+ const radius = compact ? 2 : turf ? 3.2 : 3.5;
176
+ return `<circle class="${turf ? "hint" : "room-hint"}" cx="${cx(from) + dx * cellX * reach}" cy="${cy(from) + dy * cellY * reach}" r="${radius}"></circle>`;
177
+ }).join("");
178
+
179
+ const nodesSvg = graph.nodes.map((n) => {
180
+ const label = escapeHtml(n.id);
181
+ const isHere = opts.here !== undefined ? opts.here === n.id : !!n.current;
182
+ if (turf) {
183
+ const cast = occupants.get(n.id) || [];
184
+ const step = Math.min(compact ? 6 : 9, (roomW - 6) / Math.max(1, cast.length));
185
+ const dots = cast.map((c, i) => {
186
+ const spread = (i - (cast.length - 1) / 2) * step;
187
+ return `<circle class="occupant" cx="${cx(n) + spread}" cy="${cy(n) + roomH / 2}" r="${compact ? 2.4 : 3.6}" fill="${c.color}"><title>${escapeHtml(c.character)}</title></circle>`;
188
+ }).join("");
189
+ const fit = label.length * (compact ? 3.6 : 4.3) > roomW - 6
190
+ ? ` textLength="${roomW - 6}" lengthAdjust="spacingAndGlyphs"` : "";
191
+ const cls = "room" + (n.level === 0 ? " surface" : "") + (isHere ? " here" : "") + (opts.fresh === n.id ? " freshly-dug" : "");
192
+ return `<g class="${cls}" data-room="${label}"><rect x="${cx(n) - roomW / 2}" y="${cy(n) - roomH / 2}" width="${roomW}" height="${roomH}" rx="3"></rect>`
193
+ + `<text x="${cx(n)}" y="${cy(n) + (compact ? 2 : 2.5)}"${fit}>${label}</text>${dots}</g>`;
194
+ }
195
+ const cls = "room-node" + (isHere ? " current" : "") + (opts.clickable ? " clickable" : "") + (opts.clickable && n.id === opts.selectedRoomId ? " selected" : "");
196
+ const attr = opts.clickable ? ` data-room="${label}"` : "";
197
+ return `<g class="${cls}"${attr}><rect x="${cx(n) - roomW / 2}" y="${cy(n) - roomH / 2}" width="${roomW}" height="${roomH}" rx="3"></rect>`
198
+ + `<text x="${cx(n)}" y="${cy(n) + 2.5}">${label}</text></g>`;
199
+ }).join("");
200
+
201
+ const svg = `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escapeHtml(opts.label || "the room graph")}">${groundBand}${edgesSvg}${hintsSvg}${nodesSvg}</svg>`;
202
+ return opts.wrapClass ? `<div class="${opts.wrapClass}">${svg}</div>` : svg;
203
+ }
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // Trust tiers are precomputed rgba() values per provenance color so pages
6
6
  // render identically on browsers without color-mix() support.
7
+ import { pluralOf } from "../domain/inflect.mjs";
7
8
 
8
9
  export const SERIF_STACK = `"Charter", "Bitstream Charter", Georgia, "Times New Roman", serif`;
9
10
  export const MONO_STACK = `ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace`;
@@ -45,7 +46,10 @@ function rgba(hex, alpha) {
45
46
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
46
47
  }
47
48
 
48
- const TOKENS = Object.freeze({
49
+ /** The light/dark token table — the raw hex/alpha values `THEME_TOKENS_CSS`
50
+ * compiles into CSS custom properties. Exported so a page's own inlined
51
+ * dark-mode CSS can read a value directly instead of restating it. */
52
+ export const TOKENS = Object.freeze({
49
53
  light: Object.freeze({
50
54
  bg: "#F7F6F2", ink: "#23272B", muted: "#6E7168", line: "#DDD9D0", card: "#FFFFFF",
51
55
  taught: "#2E7D4F", corpus: "#5A80AC", entail: "#B07C2E", alert: "#B0503F",
@@ -86,3 +90,73 @@ export const THEME_TOKENS_CSS = `
86
90
  :root[data-theme="dark"] { ${tokenBlock(TOKENS.dark)} }
87
91
  :root[data-theme="light"] { ${tokenBlock(TOKENS.light)} }
88
92
  `;
93
+
94
+ /** "N word"/"N words" — every current hand-pluralized count in the estate
95
+ * (`n === 1 ? "" : "s"`) is a regular plural, so this is a clean swap: pass
96
+ * `plural` only for the rare irregular noun. Pure. */
97
+ export function countLabel(n, singular, plural = pluralOf(singular)) {
98
+ return `${n} ${n === 1 ? singular : plural}`;
99
+ }
100
+
101
+ /** `rows` narrowed to the ones a world's own provenance tag ("world:<name>")
102
+ * wrote — the same prefix filter mud.html's and adventure.html's edit modes
103
+ * each apply to keep a world's own facts apart from whatever background
104
+ * corpus shares the same live store. Pure. */
105
+ export function rowsForWorld(rows, worldName) {
106
+ const prefix = "world:" + worldName;
107
+ return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
108
+ }
109
+
110
+ /** A generic clamped-percentage meter: `<div class="meter-track"><div
111
+ * class="meter-fill <cls>" style="width:N%"></div></div>`, empty when
112
+ * `value` isn't a number or `max` is falsy (nothing to show, not a 0%
113
+ * bar). `cls` rides on the inner fill for per-kind styling (e.g. a colour
114
+ * per creature class). Pure. */
115
+ export function meterBarHtml(cls, value, max) {
116
+ if (typeof value !== "number" || !max) return "";
117
+ const pct = Math.max(0, Math.min(100, (value / max) * 100));
118
+ return `<div class="meter-track"><div class="meter-fill ${escapeHtml(cls)}" style="width:${pct}%"></div></div>`;
119
+ }
120
+
121
+ /** The identifier-shaped word immediately before `cursorPos` in `text`,
122
+ * lowercased — a cursor-suggestion pill's own lookup key. Empty when the
123
+ * cursor sits after whitespace/punctuation rather than a word. Pure,
124
+ * self-contained (no closure state), `.toString()`-splice safe. */
125
+ export function wordBeforeCursor(text, cursorPos) {
126
+ const head = String(text || "").slice(0, cursorPos);
127
+ const m = head.match(/[A-Za-z][A-Za-z0-9-]*$/);
128
+ return m ? m[0].toLowerCase() : "";
129
+ }
130
+
131
+ /** Append one line to a chat/event log element: a plain-text line, dropped
132
+ * in with `textContent` (never `innerHTML` — the caller's text is untrusted
133
+ * chat/answer text, not markup) and the log scrolled to show it. `clip`
134
+ * (default false) turns on mud.html's own "read more" affordance: once the
135
+ * line is in the DOM, measure its full (unclamped) height against its
136
+ * actually-rendered one, and if a CSS line-clamp on `cls` is cutting it off,
137
+ * mark the element `clipped` (with a button role/tabindex/title) so a click
138
+ * or Enter can open the whole text elsewhere. `clip` is a no-op on a class
139
+ * with no clamping rule — nothing to detect, nothing gets marked. Returns
140
+ * the created element, so a caller with more to wire (a click handler) can.
141
+ * Self-contained, `.toString()`-splice safe. */
142
+ export function appendLogLine(el, cls, text, { clip = false } = {}) {
143
+ const d = document.createElement("div");
144
+ d.className = cls;
145
+ d.textContent = text;
146
+ el.appendChild(d);
147
+ if (clip) {
148
+ // A line-clamped box reports scrollHeight EQUAL to its clamped height, so
149
+ // the overflow has to be read against the same box with the clamp lifted.
150
+ d.classList.add("unclamped");
151
+ const wholeHeight = d.scrollHeight;
152
+ d.classList.remove("unclamped");
153
+ if (wholeHeight - d.clientHeight > 2) {
154
+ d.classList.add("clipped");
155
+ d.setAttribute("role", "button");
156
+ d.setAttribute("tabindex", "0");
157
+ d.setAttribute("title", "read the whole line");
158
+ }
159
+ }
160
+ el.scrollTop = el.scrollHeight;
161
+ return d;
162
+ }
@@ -117,3 +117,25 @@ export function prefersReducedMotion() {
117
117
  ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
118
118
  : false;
119
119
  }
120
+
121
+ /** A FIFO queue of async jobs that never overlap — the same primitive
122
+ * mud-viz.mjs (`tickChain`/`serializeTick`) and adventure-viz.mjs/
123
+ * spider-fly-viz.mjs (`lock`/`withLock`) each wrote under a different name,
124
+ * for the same reason in all three: a ticker, a chat dock and (mud's case)
125
+ * an editor sync all touch the same in-memory store, and any two calls
126
+ * overlapping could race the same write.
127
+ *
128
+ * Returns `{ run }`. `run(fn)` chains `fn` onto the queue and returns a
129
+ * promise for `fn`'s own settlement — a rejection propagates to THAT
130
+ * caller, but never poisons the queue for the jobs queued after it (the
131
+ * internal chain swallows the rejection once it has been handed back).
132
+ * Self-contained, `.toString()`-splice safe. */
133
+ export function createSerialQueue() {
134
+ let chain = Promise.resolve();
135
+ function run(fn) {
136
+ const settled = chain.then(fn, fn);
137
+ chain = settled.catch(() => {});
138
+ return settled;
139
+ }
140
+ return { run };
141
+ }
@@ -18,18 +18,25 @@
18
18
  // entry point: `turn(line)` runs the exact same runTurn the CLI and every
19
19
  // other viz page's chat dock run, over this session's own memoryDir/graph/
20
20
  // lexicon, threading `focus`/`last`/`planState` across calls the same way a
21
- // real chat session does. `planState` and `autoplayTick`'s own `planHolder`
22
- // share ONE mutable holder here, so a manual chat command and an auto-play
23
- // tick can never disagree about whether the adventure is still open, mid a
24
- // number game, etc. whichever ran last leaves the holder as the other's
25
- // starting point. `planHolder.state` starts as adventureTurn's own opened-
21
+ // real chat session does via turn-session.mjs's shared `createTurnSession`,
22
+ // the wrapper every browser chat dock now hands its own turns through.
23
+ // `planState` and `autoplayTick`'s own `planHolder` share ONE mutable holder
24
+ // here, so a manual chat command and an auto-play tick can never disagree
25
+ // about whether the adventure is still open, mid a number game, etc. —
26
+ // whichever ran last leaves the holder as the other's starting point.
27
+ // `createTurnSession`'s own `buildExtraOptions`/`captureExtraState` seams are
28
+ // what keep the turn session's internal planState and this module's own
29
+ // `planHolder` in sync on every manual turn (see the session factory's own
30
+ // comment below). `planHolder.state` starts as adventureTurn's own opened-
26
31
  // world shape, so BOTH entry points treat every call as a live, already-open
27
32
  // world rather than a fresh opening line: ordinary in-game commands (look/
28
33
  // go/take/open/talk/examine/...) dispatch through adventure.mjs's own
29
34
  // adventureTurn exactly as autoplayTick's calls already do, and anything not
30
35
  // game-shaped falls through to the ordinary conversational layer, exactly
31
36
  // like a real CLI session.
32
- import { runTurn } from "../../services/chat.mjs";
37
+ import { createTurnSession } from "./turn-session.mjs";
38
+ import { publishTmctSurface } from "./tmct-surface.mjs";
39
+ import { graphAsk, enginePlan } from "./engine-surface.mjs";
33
40
  import {
34
41
  createInMemoryStore, appendFacts, appendRule, loadMemory, readFactRows, removeFacts,
35
42
  } from "../../adapters/memory/core.mjs";
@@ -41,6 +48,7 @@ import { parseWorldEditorText, planWorldEditorSync } from "../../services/advent
41
48
  import { resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain } from "../../domain/sprite-map.mjs";
42
49
  import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
43
50
  import { relatedForTerm } from "../../domain/skos-view.mjs";
51
+ import { directedGridLayout, roomGraphSvg } from "../../services/viz-room-graph.mjs";
44
52
  import { openPersistedStore } from "./idb-persist.mjs";
45
53
 
46
54
  /** A live in-memory adventure this page's ticker AND chat dock can both
@@ -97,11 +105,31 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
97
105
 
98
106
  const graph = parseEntities({ individuals: [], objectProperties: [] });
99
107
  const lexicon = loadLexicon();
100
- let focus = null;
101
- let last = null;
108
+
109
+ // createTurnSession owns focus/last and the catch fallback; planHolder
110
+ // stays a SEPARATE object because autoplayTick's own
111
+ // runAdventureAutoplayTick needs to mutate it directly (see this module's
112
+ // own header for why the two entry points share one holder).
113
+ // buildExtraOptions/captureExtraState are the seam that keeps the turn
114
+ // session's own planState reading from — and writing back to — that same
115
+ // holder on every manual turn, and captureExtraState is also where
116
+ // `visitedRoomIds` grows with the player's post-turn room (a no-op add
117
+ // when the command didn't move anyone) — the manual-play half of the
118
+ // merged exposure set.
119
+ const turnSession = createTurnSession({
120
+ memoryDir, graph, lexicon, sessionId,
121
+ vocabHint: 'Try a world question ("where is the key"), or teach me: "remember: the moat is a ditch".',
122
+ buildExtraOptions: () => ({ uiContext: "browser", planState: planHolder.state }),
123
+ captureExtraState: async (result) => {
124
+ if ("planState" in result) planHolder.state = result.planState;
125
+ const here = foldWorldState(worldActionRows(readFactRows(await loadMemory(memoryDir)))).placements.get("player")?.object ?? null;
126
+ if (here) visitedRoomIds.add(here);
127
+ },
128
+ });
102
129
 
103
130
  return {
104
131
  memoryDir,
132
+ graph,
105
133
 
106
134
  /** One auto-play tick: infer the goal, execute exactly one move through
107
135
  * adventureTurn (adventure-autoplay.mjs's own contract), thread the
@@ -115,31 +143,15 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
115
143
  return result;
116
144
  },
117
145
 
118
- /** One dispatched chat turn the SAME runTurn the CLI and every other
119
- * viz page's own chat dock run, over this session's own memoryDir. A
120
- * throwing runTurn must never kill the session the page has no other
121
- * chance to show this turn's answer. Grows `visitedRoomIds` with the
122
- * player's post-turn room (a no-op add when the command didn't move
123
- * anyone) the manual-play half of the merged exposure set. */
146
+ /** One dispatched chat turn, through createTurnSession's own shared
147
+ * wrapper around the SAME runTurn the CLI and every other viz page's
148
+ * chat dock run, over this session's own memoryDir. A throwing runTurn
149
+ * must never kill the session the page has no other chance to show
150
+ * this turn's answer; createTurnSession's own catch fallback covers
151
+ * that, and its captureExtraState hook above covers everything this
152
+ * method used to do by hand. */
124
153
  async turn(line) {
125
- let result;
126
- try {
127
- result = await runTurn(line, {
128
- config: null, source: null, graph, focus, last, memoryDir, sessionId,
129
- env: {}, lexicon, uiContext: "browser",
130
- vocabHint: 'Try a world question ("where is the key"), or teach me: "remember: the moat is a ditch".',
131
- planState: planHolder.state,
132
- });
133
- } catch (e) {
134
- const message = e instanceof Error ? e.message : String(e);
135
- return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null, plan: null };
136
- }
137
- focus = result.focus;
138
- last = result.last;
139
- if ("planState" in result) planHolder.state = result.planState;
140
- const here = foldWorldState(worldActionRows(readFactRows(await loadMemory(memoryDir)))).placements.get("player")?.object ?? null;
141
- if (here) visitedRoomIds.add(here);
142
- return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
154
+ return turnSession.turn(line);
143
155
  },
144
156
 
145
157
  /** A read-only fold of the current room — no engine advance — for the
@@ -183,18 +195,21 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
183
195
  };
184
196
  }
185
197
 
186
- // Re-exported so the page's own rendering script (adventure-viz.mjs) never
187
- // has to duplicate sprite resolution, the digest reader, the room
188
- // affordances the chat dock's own pills read from, or (foldWorldState,
189
- // exposedFacts) the exposure-filtered fold the goal-status panel mirrors
190
- // the same posture spider-fly-browser-entry.mjs's own
191
- // globalThis.tmctSpiderFly re-export takes. `relatedForTerm`/
192
- // `classAncestorChain` back the edit mode's own cursor-suggestion pills
193
- // (adventure-viz.mjs's suggestionsForTerm mirrors this same pairing against
194
- // the global, the same reach-through-the-global pattern captionFor/pillsFor
195
- // already use for their own adventure.mjs calls).
196
- globalThis.tmctAdventure = {
197
- createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
198
- worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
199
- relatedForTerm, classAncestorChain, openPersistedStore,
200
- };
198
+ // `tmct.page` keeps what the page draws with and the engine has no
199
+ // plain-English form for: sprite resolution, the digest reader, the room
200
+ // affordances the chat pills read from, the exposure-filtered fold the
201
+ // goal-status panel mirrors, the SKOS neighbourhood and is-a chain behind the
202
+ // edit mode's cursor pills, the persisted-store wrapper, and the manor map's
203
+ // own BFS-grid layout and SVG renderer (neither is `.toString()`-splice-safe:
204
+ // roomGraphSvg needs escapeHtml, directedGridLayout its own exit-delta table).
205
+ publishTmctSurface({
206
+ open: createAdventureSession,
207
+ ask: graphAsk,
208
+ plan: enginePlan,
209
+ page: {
210
+ resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset,
211
+ worldDigestRows, roomAffordances, foldWorldState, exposedFacts,
212
+ relatedForTerm, classAncestorChain, openPersistedStore,
213
+ directedGridLayout, roomGraphSvg,
214
+ },
215
+ });