@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.8.1",
3
+ "version": "2.8.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -137,6 +137,7 @@
137
137
  "build:ask-bundle": "node scripts/build-ask-bundle.mjs",
138
138
  "build:chat-bundle": "node scripts/build-chat-bundle.mjs",
139
139
  "build:spider-fly-bundle": "node scripts/build-spider-fly-bundle.mjs",
140
+ "build:plan-bundle": "node scripts/build-plan-bundle.mjs",
140
141
  "build:chat-seed": "node scripts/build-chat-seed.mjs",
141
142
  "build:demo-graph": "node scripts/build-demo-graph.mjs",
142
143
  "build:demo-pack": "node scripts/build-demo-pack.mjs",
@@ -18,10 +18,13 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
18
18
  spiderMassDecrementPerTurn: 0.5,
19
19
  flyInitialMass: 10,
20
20
  flyMassDecrementPerTurn: 1,
21
- visionRadius: 4,
21
+ spiderVisionRadius: 4,
22
+ flyVisionRadius: 4,
22
23
  eggHatchDelayTurns: 3,
23
24
  flySpawnIntervalTurns: 3,
24
- eggsEatenThreshold: 2,
25
+ eggLayMassThreshold: 25,
26
+ eggHatchCount: 2,
27
+ minHatchlingMass: 3,
25
28
  webDurationTurns: 10,
26
29
  }),
27
30
  guessNumber: Object.freeze({
@@ -42,10 +45,13 @@ const SPIDER_FLY_KEY_MAP = Object.freeze({
42
45
  spider_mass_decrement_per_turn: "spiderMassDecrementPerTurn",
43
46
  fly_initial_mass: "flyInitialMass",
44
47
  fly_mass_decrement_per_turn: "flyMassDecrementPerTurn",
45
- vision_radius: "visionRadius",
48
+ spider_vision_radius: "spiderVisionRadius",
49
+ fly_vision_radius: "flyVisionRadius",
46
50
  egg_hatch_delay_turns: "eggHatchDelayTurns",
47
51
  fly_spawn_interval_turns: "flySpawnIntervalTurns",
48
- eggs_eaten_threshold: "eggsEatenThreshold",
52
+ egg_lay_mass_threshold: "eggLayMassThreshold",
53
+ egg_hatch_count: "eggHatchCount",
54
+ min_hatchling_mass: "minHatchlingMass",
49
55
  web_duration_turns: "webDurationTurns",
50
56
  });
51
57
 
@@ -0,0 +1,53 @@
1
+ // hanoi-lesson.mjs — the one pure generator behind the taught towers-of-hanoi
2
+ // lesson: data/games/hanoi-3.txt's own content, generalized from a fixed
3
+ // 3-disk puzzle to any disk count. Pure, no imports — both
4
+ // scripts/build-demo-site.mjs (the static initial embed) and
5
+ // src/surfaces/web/plan-browser-entry.mjs (the live re-solve a visitor's
6
+ // disk-count control triggers) read the SAME sentence sequence from here, so
7
+ // neither can drift from data/games/hanoi-3.txt's own taught shape.
8
+ //
9
+ // Every sentence is emitted the way `tmct import --file` actually teaches
10
+ // one — split down to ONE fact per sentence, the exact granularity
11
+ // import-file.mjs's own splitSentencesPreservingPaths would produce from the
12
+ // committed file's body — not grouped multi-sentence lines, so each array
13
+ // entry maps 1:1 onto a single runTurn() call.
14
+ //
15
+ // Always 3 pegs (peg-a/b/c); only the disk count varies. The pairwise
16
+ // "smaller than" facts are EVERY pair, not just adjacent ones — the taught
17
+ // relation is stored as given and never chased transitively (see the
18
+ // "scale" variation documented in hanoi-3.txt itself), so a partial pairing
19
+ // would leave some disks with no legal move.
20
+
21
+ /** The taught lesson for an N-disk puzzle: the class/individual/ordering
22
+ * facts, the action rule, the render hints, the starting stack (largest at
23
+ * the bottom of peg-a, smallest on top), and the goal + solve trigger —
24
+ * one sentence per array entry, in teaching order. `diskCount` is floored
25
+ * and clamped to at least 1. */
26
+ export function hanoiLessonSentences(diskCount = 3, { goalPeg = "peg-c" } = {}) {
27
+ const n = Math.max(1, Math.floor(Number(diskCount) || 1));
28
+ const disks = Array.from({ length: n }, (_, i) => `disk-${i + 1}`);
29
+ const pegs = ["peg-a", "peg-b", "peg-c"];
30
+ const sentences = [];
31
+
32
+ sentences.push("a disk is a kind of game piece.");
33
+ sentences.push("a peg is a kind of place.");
34
+ for (const d of disks) sentences.push(`${d} is a disk.`);
35
+ for (const p of pegs) sentences.push(`${p} is a peg.`);
36
+ for (let i = 0; i < n; i += 1) {
37
+ for (let j = i + 1; j < n; j += 1) sentences.push(`${disks[i]} is smaller than ${disks[j]}.`);
38
+ }
39
+ sentences.push("you can move a disk onto a peg.");
40
+ sentences.push("you can move a disk onto a disk.");
41
+ sentences.push("to move a disk onto a target, nothing may rest on the disk.");
42
+ sentences.push("to move a disk onto a target, nothing may rest on the target.");
43
+ sentences.push("to move a disk onto a disk, the disk must be smaller than the target.");
44
+ sentences.push("moving a disk onto a target makes the disk rest on the target.");
45
+ sentences.push("a disk renders as a block.");
46
+ sentences.push("a peg renders as a slot.");
47
+ for (let i = 0; i < n - 1; i += 1) sentences.push(`${disks[i]} rests on ${disks[i + 1]}.`);
48
+ sentences.push(`${disks[n - 1]} rests on peg-a.`);
49
+ sentences.push(`the goal is that every disk rests on ${goalPeg}.`);
50
+ sentences.push("solve it.");
51
+
52
+ return sentences;
53
+ }
@@ -86,6 +86,22 @@ export const DIRECTION_DELTA = Object.freeze({
86
86
  west: Object.freeze({ dx: -1, dy: 0 }),
87
87
  });
88
88
 
89
+ /** The single compass direction from `fromCell` to `toCell` when `toCell`
90
+ * sits EXACTLY one cardinal step away (DIRECTION_DELTA) — null for the same
91
+ * cell, a diagonal, or any multi-step gap, so a caller never overstates
92
+ * "adjacent". The one shared primitive both the engine's own plan-driven
93
+ * facing (spider-fly.mjs) and the chat dock's deception pills
94
+ * (spider-fly-turn.mjs's pillsForSpiderFly) need — defined once here so
95
+ * neither has to re-derive it, and so the engine layer never has to import
96
+ * the chat-turn layer to get it (spider-fly-turn.mjs already imports
97
+ * spider-fly.mjs; the reverse would cycle). */
98
+ export function oneStepDirectionBetween(fromCell, toCell) {
99
+ for (const [direction, { dx, dy }] of Object.entries(DIRECTION_DELTA)) {
100
+ if (fromCell.x + dx === toCell.x && fromCell.y + dy === toCell.y) return direction;
101
+ }
102
+ return null;
103
+ }
104
+
89
105
  /** The world's seed taxonomy (PLAN_SPIDER_FLY.md §7): enough for the
90
106
  * ontology-to-sprite worked example (a poodle sprite, a sheepdog falling
91
107
  * back to the generic dog sprite) to run on the default persona, no
@@ -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
+ }