@polycode-projects/the-mechanical-code-talker 2.8.0 → 2.8.3

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.
@@ -0,0 +1,361 @@
1
+ // adventure-editor.mjs — the world editor's text<->fact bridge
2
+ // (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 4's operator addendum): a small,
3
+ // closed-vocabulary sentence renderer/parser purpose-built for the adventure
4
+ // world's own predicate vocabulary, invertible by construction — every
5
+ // phrase this module renders has exactly one parser rule that reads it back
6
+ // to the same triple. This is deliberately NOT chat.mjs's teachLane/TEACH_RE
7
+ // (open-domain natural-language teaching, guarded against questions/typos/
8
+ // discourse markers that would fight a structured per-line textarea parse).
9
+ // A purpose-built parser mirroring worldDigestRows' own small phrase table
10
+ // (adventure.mjs) is the right size for this job — extended to also
11
+ // round-trip the puzzle/openness facts worldDigestRows itself deliberately
12
+ // hides from the player-facing "look" digest (mgx:is-open, mgx:hidden-in,
13
+ // mgx:is-container, mgx:unlocks-with, mgx:is-npc, mgx:acts-on-turn,
14
+ // mgx:acts-toward, mgx:is-objective) — an editor has to show and change
15
+ // exactly the facts a player is never told.
16
+ //
17
+ // No imports: every export here is .toString()-splice-safe, the same
18
+ // discipline adventure-viz.mjs's own render-glue functions hold (see that
19
+ // module's header) — this module's functions get spliced directly into the
20
+ // adventure page's inline script the same way.
21
+ //
22
+ // Two predicate families get different sync strategies, on purpose:
23
+ // - PLACEMENT/OPENNESS (mgx:currently-in/located-in/fixed-in/stands-
24
+ // locked-in/hidden-in, mgx:is-open) are fold-versioned: foldWorldState
25
+ // already treats the newest write as the current truth (adventure.mjs's
26
+ // own "turn >= prior.turn" rule — the same mechanism every in-game
27
+ // action's commit() already writes through). Editing one of these facts
28
+ // is handled as a plain new write superseding the old one, never a
29
+ // retraction, so planWorldEditorSync can never touch memory/core.mjs's
30
+ // own removeFacts for this family, and can never race the fold logic the
31
+ // rest of the engine depends on. One consequence, stated plainly: this
32
+ // editor can move an object or reopen/close a container, but it cannot
33
+ // make a placed object vanish outright — the append-only truth model has
34
+ // no way to write "nowhere" — a scope choice, not a defect.
35
+ // - Everything else (rdf:type, mgx:has-exit-*, and the container/puzzle
36
+ // family) is NOT fold-versioned: the engine reads these as raw first-
37
+ // or any-match facts (isTyped, factObjects, the exits Map), so changing
38
+ // one genuinely needs the OLD fact retracted, not just a newer one
39
+ // appended alongside it. planWorldEditorSync computes a real add/remove
40
+ // diff for this family — but the caller only ever applies the removals
41
+ // once parseWorldEditorText's own unrecognized-line count is zero: a
42
+ // line that fails to parse this keystroke must never be read as "this
43
+ // fact is gone", so every removal stays pending until the whole
44
+ // document parses cleanly again. Additions are never gated this way —
45
+ // they are non-destructive by construction.
46
+
47
+ const PLACEMENT_KIND = "placement";
48
+ const OPENNESS_KIND = "openness";
49
+ const OTHER_KIND = "other";
50
+
51
+ const LOW = (s) => String(s || "").trim().toLowerCase();
52
+
53
+ /** parseWorldEditorText's own type map: which subject is typed person/
54
+ * adventurer, the "mover" class the generic "X is in the Y." parse needs to
55
+ * pick currently-in vs located-in — renderWorldEditorText keeps its own
56
+ * local copy of this same lookup (see that function's own header for why:
57
+ * it has to stay fully self-contained for `.toString()` splicing). Built
58
+ * once per parse call from whatever rdf:type facts are visible (the raw
59
+ * rows the caller hands in, unioned with any "is a/an <class>." lines the
60
+ * SAME text also asserts, so a freshly-typed-in-this-edit class still
61
+ * disambiguates its own placement line in the same pass). */
62
+ function typeMapFrom(rows) {
63
+ const m = new Map();
64
+ for (const r of rows || []) {
65
+ if (r?.predicate === "rdf:type" && r.subject && r.object) m.set(LOW(r.subject), LOW(r.object));
66
+ }
67
+ return m;
68
+ }
69
+
70
+ const MOVER_CLASSES = new Set(["person", "adventurer"]);
71
+
72
+ // ---- rendering ---------------------------------------------------------------
73
+
74
+ const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
75
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
76
+
77
+ /** The whole world's editable facts as plain sentences, one per line, sorted
78
+ * by (subject, predicate, object) for a deterministic, reviewable diff
79
+ * between edits. Placement/openness come from the FOLDED state (`state`);
80
+ * everything else (type, exits, container/puzzle) comes from the raw rows,
81
+ * skipping @turnN snapshot subjects — those never carry their own type/exit/
82
+ * puzzle facts, only placement/openness overrides the fold already reads.
83
+ *
84
+ * Deliberately self-contained (its own local CAP/LOW/typePhrase/
85
+ * MOVER_CLASSES/regex copies, duplicating this module's own top-level
86
+ * ones) rather than calling the module's private renderPlacementLine/
87
+ * typeMapFrom helpers: this function gets spliced into the adventure page's
88
+ * inline script via `.toString()` (adventure-viz.mjs's own render-glue
89
+ * discipline — see that module's header), which captures only the
90
+ * function's own source text, never its closure over sibling module-level
91
+ * bindings. A splice-safe function's ENTIRE dependency graph has to live
92
+ * inside its own body. */
93
+ export function renderWorldEditorText(rows, state) {
94
+ const cap = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
95
+ const low = (s) => String(s || "").trim().toLowerCase();
96
+ const typePhraseFor = (object) => (/^[aeiou]/i.test(object) ? "is an" : "is a");
97
+ const moverClasses = new Set(["person", "adventurer"]);
98
+ const exitRe = /^mgx:has-exit-([a-z]+)$/;
99
+ const snapshotRe = /^(.+)@turn(\d+)$/;
100
+ const types = new Map();
101
+ for (const r of rows || []) {
102
+ if (r?.predicate === "rdf:type" && r.subject && r.object) types.set(low(r.subject), low(r.object));
103
+ }
104
+ const placementLine = (subject, place) => {
105
+ if (place.predicate === "mgx:located-in") {
106
+ if (place.object === "player") return `Player carries the ${subject}.`;
107
+ const holderType = types.get(low(place.object));
108
+ if (holderType && moverClasses.has(holderType)) return `${cap(place.object)} carries the ${subject}.`;
109
+ return `${cap(subject)} is in the ${place.object}.`;
110
+ }
111
+ const verb = {
112
+ "mgx:currently-in": "is currently in the",
113
+ "mgx:fixed-in": "is fixed in the",
114
+ "mgx:stands-locked-in": "stands locked in the",
115
+ "mgx:hidden-in": "is hidden in the",
116
+ }[place.predicate];
117
+ return verb ? `${cap(subject)} ${verb} ${place.object}.` : null;
118
+ };
119
+
120
+ const lines = [];
121
+ for (const [subject, place] of state.placements || new Map()) {
122
+ const line = placementLine(subject, place);
123
+ if (line) lines.push({ key: [subject, place.predicate, place.object].join(" "), text: line });
124
+ }
125
+ for (const [subject, openness] of state.openness || new Map()) {
126
+ const text = openness.open ? `${cap(subject)} is open.` : `${cap(subject)} is closed.`;
127
+ lines.push({ key: [subject, "mgx:is-open", String(openness.open)].join(" "), text });
128
+ }
129
+ for (const row of rows || []) {
130
+ if (snapshotRe.test(row.subject)) continue; // a turn snapshot never carries its own type/exit/puzzle fact
131
+ const exit = exitRe.exec(row.predicate);
132
+ if (exit) {
133
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} has an exit ${exit[1]} to the ${row.object}.` });
134
+ continue;
135
+ }
136
+ if (row.predicate === "rdf:type") {
137
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} ${typePhraseFor(row.object)} ${row.object}.` });
138
+ continue;
139
+ }
140
+ if (row.predicate === "mgx:is-container" && row.object === "true") {
141
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} is a container.` });
142
+ continue;
143
+ }
144
+ if (row.predicate === "mgx:is-npc" && row.object === "true") {
145
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} is a character in the story.` });
146
+ continue;
147
+ }
148
+ if (row.predicate === "mgx:is-objective" && row.object === "true") {
149
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} is the objective.` });
150
+ continue;
151
+ }
152
+ if (row.predicate === "mgx:unlocks-with") {
153
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} unlocks with the ${row.object}.` });
154
+ continue;
155
+ }
156
+ if (row.predicate === "mgx:acts-on-turn") {
157
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} acts on turn ${row.object}.` });
158
+ continue;
159
+ }
160
+ if (row.predicate === "mgx:acts-toward") {
161
+ lines.push({ key: [row.subject, row.predicate, row.object].join(" "), text: `${cap(row.subject)} acts toward the ${row.object}.` });
162
+ continue;
163
+ }
164
+ }
165
+ const seen = new Set();
166
+ const deduped = lines.filter((l) => (seen.has(l.key) ? false : (seen.add(l.key), true)));
167
+ deduped.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
168
+ return deduped.map((l) => l.text).join("\n");
169
+ }
170
+
171
+ // ---- parsing -------------------------------------------------------------
172
+
173
+ // Checked in this exact order, first match wins — every entry's phrase text
174
+ // is unambiguous against every OTHER entry's (verified in
175
+ // test/services/adventure-editor.test.mjs), so ordering only matters against
176
+ // the two generic fallbacks at the very end (`is in the` / `is a/an <class>`,
177
+ // which a specific phrase like "is a container." would otherwise also match).
178
+ const LINE_PATTERNS = [
179
+ { kind: OTHER_KIND, re: /^(.+?)\s+carries\s+the\s+(.+?)\.?$/i,
180
+ build: (m) => ({ subject: LOW(m[2]), predicate: "mgx:located-in", object: LOW(m[1]) }) },
181
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+hidden\s+in\s+the\s+(.+?)\.?$/i,
182
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:hidden-in", object: LOW(m[2]) }) },
183
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+fixed\s+in\s+the\s+(.+?)\.?$/i,
184
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:fixed-in", object: LOW(m[2]) }) },
185
+ { kind: OTHER_KIND, re: /^(.+?)\s+stands\s+locked\s+in\s+the\s+(.+?)\.?$/i,
186
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:stands-locked-in", object: LOW(m[2]) }) },
187
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+currently\s+in\s+the\s+(.+?)\.?$/i,
188
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:currently-in", object: LOW(m[2]) }) },
189
+ { kind: OTHER_KIND, re: /^(.+?)\s+has\s+an\s+exit\s+(\w+)\s+to\s+the\s+(.+?)\.?$/i,
190
+ build: (m) => ({ subject: LOW(m[1]), predicate: `mgx:has-exit-${LOW(m[2])}`, object: LOW(m[3]) }) },
191
+ { kind: OPENNESS_KIND, re: /^(.+?)\s+is\s+open\.?$/i,
192
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-open", object: "true" }) },
193
+ { kind: OPENNESS_KIND, re: /^(.+?)\s+is\s+closed\.?$/i,
194
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-open", object: "false" }) },
195
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+a\s+container\.?$/i,
196
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-container", object: "true" }) },
197
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+a\s+character\s+in\s+the\s+story\.?$/i,
198
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-npc", object: "true" }) },
199
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+the\s+objective\.?$/i,
200
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-objective", object: "true" }) },
201
+ { kind: OTHER_KIND, re: /^(.+?)\s+unlocks\s+with\s+the\s+(.+?)\.?$/i,
202
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:unlocks-with", object: LOW(m[2]) }) },
203
+ { kind: OTHER_KIND, re: /^(.+?)\s+acts\s+on\s+turn\s+(\d+)\.?$/i,
204
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:acts-on-turn", object: m[2] }) },
205
+ { kind: OTHER_KIND, re: /^(.+?)\s+acts\s+toward\s+the\s+(.+?)\.?$/i,
206
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:acts-toward", object: LOW(m[2]) }) },
207
+ // The two generic fallbacks — tried last, on purpose: every phrase above is
208
+ // a literal-text special case one of these two patterns would ALSO match
209
+ // ("is a container." is a valid "is a <class>." sentence too), so only
210
+ // once nothing more specific matched does a bare placement/type reading win.
211
+ { kind: PLACEMENT_KIND, re: /^(.+?)\s+is\s+in\s+the\s+(.+?)\.?$/i,
212
+ build: (m, types) => {
213
+ const t = types.get(LOW(m[1]));
214
+ return { subject: LOW(m[1]), predicate: t && MOVER_CLASSES.has(t) ? "mgx:currently-in" : "mgx:located-in", object: LOW(m[2]) };
215
+ } },
216
+ { kind: OTHER_KIND, re: /^(.+?)\s+is\s+an?\s+(.+?)\.?$/i,
217
+ build: (m) => ({ subject: LOW(m[1]), predicate: "rdf:type", object: LOW(m[2]) }) },
218
+ ];
219
+
220
+ // A parsed placement/openness triple carries its own predicate family tag so
221
+ // planWorldEditorSync can route it to the "append, never retract" path
222
+ // without re-deriving the family from the predicate string a second time.
223
+ const PLACEMENT_PREDICATES = new Set([
224
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
225
+ ]);
226
+
227
+ /** One line -> `{ subject, predicate, object, kind }` or null when nothing in
228
+ * LINE_PATTERNS recognizes it — an honest miss, never a guessed shape.
229
+ * `types` (typeMapFrom's own map) disambiguates the one truly generic
230
+ * placement phrase ("X is in the Y.") between mgx:currently-in (a person/
231
+ * adventurer standing in a room) and mgx:located-in (a portable resting or
232
+ * carried) the exact way adventure.mjs's own world data always distinguishes
233
+ * them. */
234
+ export function parseEditorLine(line, types) {
235
+ const trimmed = String(line || "").trim();
236
+ if (!trimmed) return null;
237
+ for (const pattern of LINE_PATTERNS) {
238
+ const m = trimmed.match(pattern.re);
239
+ if (!m) continue;
240
+ const triple = pattern.build(m, types);
241
+ if (!triple.subject || !triple.object) continue;
242
+ const kind = triple.predicate === "mgx:is-open" ? OPENNESS_KIND
243
+ : PLACEMENT_PREDICATES.has(triple.predicate) ? PLACEMENT_KIND : OTHER_KIND;
244
+ return { ...triple, kind };
245
+ }
246
+ return null;
247
+ }
248
+
249
+ /** Parse the whole editor textarea: every non-blank line either becomes a
250
+ * triple or lands in `unrecognized` (1-based line numbers, honest — the
251
+ * original text, never silently dropped). `contextRows` seeds the type map
252
+ * parseEditorLine's placement disambiguation reads; the text's OWN "is a/an
253
+ * <class>." lines are folded in too, so a class freshly typed in this same
254
+ * edit still disambiguates a placement line elsewhere in the same pass. */
255
+ export function parseWorldEditorText(text, contextRows = []) {
256
+ const rawLines = String(text || "").split("\n");
257
+ const firstPassTypes = typeMapFrom(contextRows);
258
+ // Pass 1: collect every "is a/an <class>." this text itself asserts, so a
259
+ // type declared and used in the SAME edit still resolves correctly.
260
+ for (const raw of rawLines) {
261
+ const trimmed = raw.trim();
262
+ if (!trimmed) continue;
263
+ const m = trimmed.match(/^(.+?)\s+is\s+an?\s+(.+?)\.?$/i);
264
+ if (m && !/\bcontainer\.?$/i.test(trimmed) && !/\bcharacter in the story\.?$/i.test(trimmed)) {
265
+ firstPassTypes.set(LOW(m[1]), LOW(m[2]));
266
+ }
267
+ }
268
+ const triples = [];
269
+ const unrecognized = [];
270
+ rawLines.forEach((raw, i) => {
271
+ const trimmed = raw.trim();
272
+ if (!trimmed) return;
273
+ const parsed = parseEditorLine(trimmed, firstPassTypes);
274
+ if (parsed) triples.push(parsed);
275
+ else unrecognized.push({ line: i + 1, text: raw });
276
+ });
277
+ return { triples, unrecognized };
278
+ }
279
+
280
+ // ---- sync planning ---------------------------------------------------------
281
+
282
+ const tripleKey = (t) => `${t.subject} ${t.predicate} ${t.object}`;
283
+
284
+ /** Every raw fact row this editor's "other" family (type/exits/container/
285
+ * puzzle — never placement/openness) is allowed to touch — the closed set
286
+ * planWorldEditorSync diffs against and the only rows a removal can ever
287
+ * target. Skips @turnN snapshot subjects, mirroring the renderer. */
288
+ export function editableOtherRows(rows) {
289
+ return (rows || []).filter((r) => {
290
+ if (SNAPSHOT_RE.test(r.subject)) return false;
291
+ if (r.predicate === "rdf:type") return true;
292
+ if (EXIT_PREDICATE_RE.test(r.predicate)) return true;
293
+ return ["mgx:is-container", "mgx:is-npc", "mgx:is-objective", "mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward"]
294
+ .includes(r.predicate);
295
+ });
296
+ }
297
+
298
+ /** Plan the fact-store writes one parsed edit implies, from already-parsed
299
+ * `triples` (parseWorldEditorText's own output) against the world's current
300
+ * `rows`/`state`. Returns `{ toAppend, toRemoveIds }` — pure, no I/O.
301
+ *
302
+ * Placement/openness triples are NEVER retracted (see this module's own
303
+ * header): a triple only joins `toAppend` when it actually differs from the
304
+ * subject's current folded value (or the subject has none yet) — otherwise
305
+ * re-asserting an unchanged line would append a no-op duplicate on every
306
+ * keystroke.
307
+ *
308
+ * "Other"-family triples (type/exits/container/puzzle) get a real add/
309
+ * remove diff against `editableOtherRows(rows)` — but the CALLER decides
310
+ * whether `toRemoveIds` is safe to apply (skip it whenever
311
+ * parseWorldEditorText reported any unrecognized line — see this module's
312
+ * header for why). */
313
+ export function planWorldEditorSync(rows, state, triples) {
314
+ const toAppend = [];
315
+ const seenPlacementSubjects = new Set();
316
+ const otherTriples = [];
317
+ // Placement/openness: last occurrence per subject wins (mirrors how a
318
+ // human reads a document top-to-bottom and treats a later line as the
319
+ // correction of an earlier one about the same thing).
320
+ for (const t of [...(triples || [])].reverse()) {
321
+ if (t.kind === OTHER_KIND) { otherTriples.push(t); continue; }
322
+ if (seenPlacementSubjects.has(t.subject)) continue;
323
+ seenPlacementSubjects.add(t.subject);
324
+ if (t.kind === PLACEMENT_KIND) {
325
+ const current = state.placements?.get(t.subject);
326
+ if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(t);
327
+ } else if (t.kind === OPENNESS_KIND) {
328
+ const current = state.openness?.get(t.subject);
329
+ const wantOpen = t.object === "true";
330
+ if (!current || current.open !== wantOpen) toAppend.push(t);
331
+ }
332
+ }
333
+
334
+ const currentOther = editableOtherRows(rows);
335
+ const currentKeys = new Map(currentOther.map((r) => [tripleKey(r), r.id]));
336
+ const newOtherKeys = new Set();
337
+ for (const t of otherTriples) {
338
+ const key = tripleKey(t);
339
+ if (newOtherKeys.has(key)) continue;
340
+ newOtherKeys.add(key);
341
+ if (!currentKeys.has(key)) toAppend.push(t);
342
+ }
343
+ const toRemoveIds = [];
344
+ for (const [key, id] of currentKeys) {
345
+ if (!newOtherKeys.has(key)) toRemoveIds.push(id);
346
+ }
347
+ return { toAppend, toRemoveIds };
348
+ }
349
+
350
+ // ---- cursor-driven suggestions ---------------------------------------------
351
+
352
+ /** The word immediately before `cursorPos` in `text` — a run of letters/
353
+ * digits/hyphens, the same token shape this vocabulary's own terms use
354
+ * (kebab-case room names like "drawing-room"). Empty string when the
355
+ * cursor sits after whitespace/punctuation with no word directly behind
356
+ * it. Pure. */
357
+ export function wordBeforeCursor(text, cursorPos) {
358
+ const head = String(text || "").slice(0, cursorPos);
359
+ const m = head.match(/[A-Za-z][A-Za-z0-9-]*$/);
360
+ return m ? m[0].toLowerCase() : "";
361
+ }