@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.
- package/README.md +2 -1
- package/corpus/sprites/src/sprite-facts.jsonl +375 -8
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +20 -0
- package/src/domain/ask-vocab.mjs +71 -0
- package/src/domain/ask.mjs +168 -0
- package/src/domain/game-config.mjs +11 -0
- package/src/domain/mud-facts.mjs +15 -0
- package/src/domain/router/drive.mjs +35 -9
- package/src/domain/router/registry.mjs +24 -4
- package/src/domain/router/resolver.mjs +102 -40
- package/src/domain/scene-compose.mjs +117 -0
- package/src/domain/spider-fly-world.mjs +36 -0
- package/src/domain/sprite-facts.mjs +0 -0
- package/src/domain/sprite-request.mjs +156 -0
- package/src/domain/sprite-templates.mjs +161 -14
- package/src/services/adventure-editor.mjs +8 -14
- package/src/services/adventure-viz.mjs +119 -150
- package/src/services/adventure.mjs +97 -35
- package/src/services/chat-page-viz.mjs +64 -48
- package/src/services/chat.mjs +102 -34
- package/src/services/code-explorer-viz.mjs +52 -50
- package/src/services/ingest-viz.mjs +32 -74
- package/src/services/ledger-viz.mjs +87 -70
- package/src/services/memory-panel-viz.mjs +38 -0
- package/src/services/mud-editor.mjs +10 -15
- package/src/services/mud-turn.mjs +6 -6
- package/src/services/mud-viz.mjs +119 -225
- package/src/services/p2p-room.mjs +90 -23
- package/src/services/plan-pddl.mjs +3 -1
- package/src/services/plan-viz.mjs +13 -12
- package/src/services/research-viz.mjs +25 -67
- package/src/services/spider-fly-turn.mjs +14 -22
- package/src/services/spider-fly-viz.mjs +97 -136
- package/src/services/spider-fly.mjs +69 -11
- package/src/services/sprite-catalog-viz.mjs +274 -224
- package/src/services/viz-boot.mjs +71 -0
- package/src/services/viz-room-graph.mjs +203 -0
- package/src/services/viz-theme.mjs +75 -1
- package/src/services/viz-ticker.mjs +22 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +62 -47
- package/src/surfaces/web/chat-browser-entry.mjs +51 -107
- package/src/surfaces/web/code-explorer-browser-entry.mjs +192 -35
- package/src/surfaces/web/engine-surface.mjs +82 -0
- package/src/surfaces/web/ingest-browser-entry.mjs +16 -17
- package/src/surfaces/web/ledger-browser-entry.mjs +24 -56
- package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
- package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
- package/src/surfaces/web/memory-stats.mjs +11 -0
- package/src/surfaces/web/mud-browser-entry.mjs +70 -49
- package/src/surfaces/web/plan-browser-entry.mjs +39 -50
- package/src/surfaces/web/research-browser-entry.mjs +48 -46
- package/src/surfaces/web/spider-fly-browser-entry.mjs +76 -40
- package/src/surfaces/web/sprites-browser-entry.mjs +28 -32
- package/src/surfaces/web/tmct-surface.mjs +147 -0
- package/src/surfaces/web/turn-session.mjs +124 -0
- package/src/tools/definitions.mjs +30 -0
- package/src/tools/handlers/index.mjs +6 -3
- package/src/tools/handlers/kit.mjs +19 -2
- package/src/tools/handlers/tmct-ask.mjs +11 -6
- package/src/tools/handlers/tmct-ingest.mjs +5 -1
- package/src/tools/handlers/tmct-related.mjs +4 -4
- package/src/tools/handlers/tmct-sprite.mjs +147 -0
- package/src/tools/memory-fallthrough.mjs +9 -2
- 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
|
-
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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 {
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// affordances the chat
|
|
189
|
-
//
|
|
190
|
-
// the
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
});
|