@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.0

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 (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -134,6 +134,26 @@ export async function createSession({
134
134
  // tmct-context.mjs) render hints under the host's own names. Omitted (the
135
135
  // default) preserves today's "tmct_" behavior exactly.
136
136
  toolNamePrefix,
137
+ // Which individual the adventure lane's world-mutating verbs act as —
138
+ // adventure.mjs's own actingSubject parameter, threaded up to session level
139
+ // so one session can play a single mud character rather than always
140
+ // "player". Omitted (the default) preserves today's single-player behavior
141
+ // exactly; a session speaks for exactly one character for its whole
142
+ // lifetime (four mud.html characters means four sessions, not one session
143
+ // switching identity turn to turn).
144
+ actingSubject,
145
+ // Marks a world already LIVE for this session, bypassing openAdventure's
146
+ // "play <world>" opener — that opener requires a shipped "player"
147
+ // individual (its own protection against mis-firing on a non-adventure
148
+ // world like spider-fly's board), which a multi-character world such as
149
+ // mud-garden deliberately never ships. The caller is responsible for
150
+ // seeding that world's facts/rules into this repo's store BEFORE opening
151
+ // any session against it (see test/services/adventure*.test.mjs's
152
+ // loadShippedWorldInto for the pattern) — one shared world, several
153
+ // sessions (one per actingSubject) pointed at the same repoPath. Omitted
154
+ // (the default) preserves today's behavior: a world only goes live via its
155
+ // own "play <world>" opener line.
156
+ adventureWorld,
137
157
  } = {}) {
138
158
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
139
159
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -360,7 +380,11 @@ export async function createSession({
360
380
  let turns = 0;
361
381
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
362
382
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
363
- let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
383
+ // The in-progress plan (goals/moves/cursor) — cleared by completion or a
384
+ // fresh goal, never by an aside. `adventureWorld` seeds it as already
385
+ // playing that world (see the option's own doc comment above) instead of
386
+ // starting null and waiting for a "play <world>" opener line.
387
+ let planState = adventureWorld ? { adventure: { world: adventureWorld } } : null;
364
388
  let researchState = null; // the in-progress research queue — advanced by "research next", cleared by completion or "research stop"
365
389
  // The typed discourse record ([discourse] max_referents caps it) — session-scoped
366
390
  // like the focus, threaded turn to turn, never persisted.
@@ -394,7 +418,7 @@ export async function createSession({
394
418
  async turn(line) {
395
419
  let result;
396
420
  try {
397
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord });
421
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord, actingSubject });
398
422
  } catch (e) {
399
423
  const ts = new Date().toISOString();
400
424
  const message = e instanceof Error ? e.message : String(e);
@@ -476,11 +500,12 @@ export async function runChat({
476
500
  liveReference = false,
477
501
  memoryBackend = null,
478
502
  toolNamePrefix,
503
+ actingSubject,
479
504
  } = {}) {
480
505
  // createSession's first-run seed (~2-3s) produces ZERO output until it fully
481
506
  // resolves, which otherwise reads as `npm run chat` hanging with total silence.
482
507
  output.write("tmct — starting…\n");
483
- const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, liveReference, memoryBackend, toolNamePrefix });
508
+ const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, liveReference, memoryBackend, toolNamePrefix, actingSubject });
484
509
 
485
510
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
486
511
  for (const line of session.bannerLines) output.write(dim(line) + "\n");
@@ -14957,7 +14957,7 @@ export async function runTurn(input, options = {}) {
14957
14957
  return { ...result, factsTouched: await factsTouchedSince(memoryDir, before) };
14958
14958
  }
14959
14959
 
14960
- async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false } = {}) {
14960
+ async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false, actingSubject = "player" } = {}) {
14961
14961
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
14962
14962
  // bounds, the shared plan lane's search-depth cap) — a caller's own
14963
14963
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -15118,7 +15118,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15118
15118
  // otherwise read as a declarative or an orientation ask.
15119
15119
  {
15120
15120
  const advTurn = await adventureTurn(workingLine, {
15121
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder,
15121
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject,
15122
15122
  });
15123
15123
  if (advTurn) {
15124
15124
  note(trace, `lane: ${advTurn.note}`);
@@ -0,0 +1,313 @@
1
+ // mud-editor.mjs — the burrow's text<->fact bridge, the mud world's own
2
+ // counterpart to adventure-editor.mjs. Same discipline, different vocabulary:
3
+ // a small closed sentence table, invertible by construction, where every phrase
4
+ // the renderer writes has exactly one parser rule that reads it back to the same
5
+ // triple.
6
+ //
7
+ // It is a separate table rather than an extension of adventure-editor.mjs's
8
+ // because the two worlds are built out of different facts. Ashcombe Hall is a
9
+ // puzzle — locks, hidden things, an objective, an NPC schedule. mud-garden is an
10
+ // ecology — a class hierarchy, masses, a predator, and the dig knobs that say how
11
+ // far a burrow reaches and what digging one out turns up. An editor whose whole
12
+ // point is "change what this world is made of" has to speak the vocabulary the
13
+ // world is actually written in, and neither table is a superset of the other.
14
+ //
15
+ // No imports: every export here is `.toString()`-splice-safe, the same discipline
16
+ // adventure-editor.mjs and the *-viz render-glue functions hold — mud-viz.mjs
17
+ // splices these straight into mud.html's inline script, which captures a
18
+ // function's own source text and nothing it closes over. A splice-safe function
19
+ // carries its whole dependency graph inside its own body.
20
+ //
21
+ // Two predicate families sync differently, for the reason adventure-editor.mjs's
22
+ // own header sets out:
23
+ // - PLACEMENT (currently-in/located-in/fixed-in/hidden-in), OPENNESS and MASS
24
+ // are fold-versioned — foldWorldState already reads the newest write as the
25
+ // current truth, which is how every turn's own move and mass drain lands — so
26
+ // an edit to one is a plain new write superseding the old, never a
27
+ // retraction. Mass belongs here and not below precisely because it MOVES: a
28
+ // playing animal's weight is a fold over turn snapshots, so an editor
29
+ // diffing raw mass rows would show the seed and fight the drain.
30
+ // - Everything else (typing, the class hierarchy, exits, the predator flag,
31
+ // the dig knobs) is read raw by the engine, so changing one genuinely needs
32
+ // the old row retracted. planMudEditorSync computes that diff, and the caller
33
+ // only applies its removals when the whole document parsed cleanly: a line
34
+ // that fails to parse this keystroke must never be read as "this fact is
35
+ // gone".
36
+
37
+ const PLACEMENT_KIND = "placement";
38
+ const OPENNESS_KIND = "openness";
39
+ const MASS_KIND = "mass";
40
+ const OTHER_KIND = "other";
41
+
42
+ const LOW = (s) => String(s || "").trim().toLowerCase();
43
+
44
+ const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
45
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
46
+
47
+ // Every predicate this editor renders and parses outside the placement/openness
48
+ // families — the closed set planMudEditorSync diffs against, and the only rows a
49
+ // removal can ever target. A predicate the engine reads but this table cannot
50
+ // say is deliberately absent: unrenderable and undiffable together, so an edit
51
+ // elsewhere in the document can never silently retract it.
52
+ const EDITABLE_OTHER_PREDICATES = [
53
+ "rdf:type", "rdfs:subClassOf", "mgx:display-name",
54
+ "mgx:is-container", "mgx:is-predator", "mgx:is-origin",
55
+ "mgx:dig-spawns", "mgx:den-spawns", "mgx:den-resident",
56
+ "mgx:dig-reach", "mgx:dig-spawn-max", "mgx:den-chance-in", "mgx:den-resident-chance-in",
57
+ "mgx:mass-drain-per-turn",
58
+ ];
59
+
60
+ // The two flags that only ever have a sentence in the "true" direction. A row
61
+ // saying "false" is unrenderable here, so it must not be diffable either.
62
+ const TRUE_ONLY_FLAG_PREDICATES = ["mgx:is-container", "mgx:is-predator", "mgx:is-origin"];
63
+
64
+ // ---- rendering ---------------------------------------------------------------
65
+
66
+ /** The whole burrow's editable facts as plain sentences, one per line, sorted by
67
+ * (subject, predicate, object) so two edits apart produce a reviewable diff
68
+ * rather than a reshuffle. Placement and openness come from the FOLDED state, so
69
+ * what shows is where things actually are now; everything else comes from the
70
+ * raw rows, skipping @turnN snapshot subjects — those carry only the placement
71
+ * overrides the fold has already read.
72
+ *
73
+ * Deliberately self-contained (its own local casing/phrase helpers, duplicating
74
+ * this module's top-level ones) rather than calling private siblings: this
75
+ * function is spliced into mud.html's inline script by `.toString()`, which
76
+ * captures the body and nothing else. Pure. */
77
+ export function renderMudEditorText(rows, state) {
78
+ const cap = (s) => (s ? String(s).charAt(0).toUpperCase() + String(s).slice(1) : s);
79
+ const low = (s) => String(s || "").trim().toLowerCase();
80
+ const typePhraseFor = (object) => (/^[aeiou]/i.test(object) ? "is an" : "is a");
81
+ const exitRe = /^mgx:has-exit-([a-z]+)$/;
82
+ const snapshotRe = /^(.+)@turn(\d+)$/;
83
+
84
+ // A character is placed with currently-in; that is the whole test the engine
85
+ // itself applies, so "carries" versus "lies in" is read off the holder's own
86
+ // placement predicate rather than off a class list.
87
+ const isCharacter = (subject) => state.placements?.get(subject)?.predicate === "mgx:currently-in";
88
+
89
+ const lines = [];
90
+ const push = (subject, predicate, object, text) =>
91
+ lines.push({ key: [subject, predicate, object].join(" "), text });
92
+
93
+ const placementLine = (subject, place) => {
94
+ if (place.predicate === "mgx:currently-in") return `${cap(subject)} stands in the ${place.object}.`;
95
+ if (place.predicate === "mgx:fixed-in") return `${cap(subject)} is fixed in the ${place.object}.`;
96
+ if (place.predicate === "mgx:hidden-in") return `${cap(subject)} is hidden in the ${place.object}.`;
97
+ if (place.predicate !== "mgx:located-in") return null;
98
+ return isCharacter(place.object)
99
+ ? `${cap(place.object)} carries the ${subject}.`
100
+ : `${cap(subject)} lies in the ${place.object}.`;
101
+ };
102
+
103
+ for (const [subject, place] of state.placements || new Map()) {
104
+ const text = placementLine(subject, place);
105
+ if (text) push(subject, place.predicate, place.object, text);
106
+ }
107
+ for (const [subject, openness] of state.openness || new Map()) {
108
+ push(subject, "mgx:is-open", String(openness.open),
109
+ openness.open ? `${cap(subject)} is open.` : `${cap(subject)} is closed.`);
110
+ }
111
+ for (const [subject, mass] of state.masses || new Map()) {
112
+ push(subject, "mgx:hasMass", String(mass.value), `${cap(subject)} weighs ${mass.value}.`);
113
+ }
114
+
115
+ for (const row of rows || []) {
116
+ if (snapshotRe.test(row.subject)) continue;
117
+ const s = row.subject, p = row.predicate, o = row.object;
118
+ const exit = exitRe.exec(p);
119
+ if (exit) { push(s, p, o, `${cap(s)} has an exit ${exit[1]} to the ${o}.`); continue; }
120
+ if (p === "rdf:type") { push(s, p, o, `${cap(s)} ${typePhraseFor(o)} ${o}.`); continue; }
121
+ if (p === "rdfs:subClassOf") { push(s, p, o, `${cap(s)} is a kind of ${o}.`); continue; }
122
+ if (p === "mgx:display-name") { push(s, p, o, `${cap(s)} is shown as ${o}.`); continue; }
123
+ if (p === "mgx:is-container" && o === "true") { push(s, p, o, `${cap(s)} is a container.`); continue; }
124
+ if (p === "mgx:is-predator" && o === "true") { push(s, p, o, `${cap(s)} hunts the other animals.`); continue; }
125
+ if (p === "mgx:is-origin" && o === "true") { push(s, p, o, `${cap(s)} is where the burrow starts.`); continue; }
126
+ if (p === "mgx:dig-spawns") { push(s, p, o, `Digging in ${s} turns up ${o}.`); continue; }
127
+ if (p === "mgx:den-spawns") { push(s, p, o, `A den in ${s} stores ${o}.`); continue; }
128
+ if (p === "mgx:den-resident") { push(s, p, o, `A den in ${s} is lived in by ${o}.`); continue; }
129
+ if (p === "mgx:dig-reach") { push(s, p, o, `The burrow digs ${o} rooms out from the ${s}.`); continue; }
130
+ if (p === "mgx:dig-spawn-max") { push(s, p, o, `Digging in ${s} turns up at most ${o} things.`); continue; }
131
+ if (p === "mgx:den-chance-in") { push(s, p, o, `One dig in ${o} in ${s} opens a den.`); continue; }
132
+ if (p === "mgx:den-resident-chance-in") { push(s, p, o, `One den in ${o} in ${s} is lived in.`); continue; }
133
+ if (p === "mgx:mass-drain-per-turn") { push(s, p, o, `${cap(s)} loses ${o} mass a turn.`); continue; }
134
+ }
135
+
136
+ const seen = new Set();
137
+ const deduped = lines.filter((l) => (seen.has(l.key) ? false : (seen.add(l.key), true)));
138
+ deduped.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
139
+ return deduped.map((l) => l.text).join("\n");
140
+ }
141
+
142
+ // ---- parsing -----------------------------------------------------------------
143
+
144
+ // Checked in this order, first match wins. Every phrase above the two generic
145
+ // fallbacks is a literal-text special case one of those two would also match
146
+ // ("is a container." is a valid "is a <class>." sentence), so the specific
147
+ // readings have to be tried first and the bare ones last.
148
+ const LINE_PATTERNS = [
149
+ { re: /^(.+?)\s+carries\s+the\s+(.+?)\.?$/i,
150
+ build: (m) => ({ subject: LOW(m[2]), predicate: "mgx:located-in", object: LOW(m[1]) }) },
151
+ { re: /^(.+?)\s+stands\s+in\s+the\s+(.+?)\.?$/i,
152
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:currently-in", object: LOW(m[2]) }) },
153
+ { re: /^(.+?)\s+lies\s+in\s+the\s+(.+?)\.?$/i,
154
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:located-in", object: LOW(m[2]) }) },
155
+ { re: /^(.+?)\s+is\s+fixed\s+in\s+the\s+(.+?)\.?$/i,
156
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:fixed-in", object: LOW(m[2]) }) },
157
+ { re: /^(.+?)\s+is\s+hidden\s+in\s+the\s+(.+?)\.?$/i,
158
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:hidden-in", object: LOW(m[2]) }) },
159
+ { re: /^(.+?)\s+has\s+an\s+exit\s+(\w+)\s+to\s+the\s+(.+?)\.?$/i,
160
+ build: (m) => ({ subject: LOW(m[1]), predicate: `mgx:has-exit-${LOW(m[2])}`, object: LOW(m[3]) }) },
161
+ { re: /^(.+?)\s+is\s+open\.?$/i,
162
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-open", object: "true" }) },
163
+ { re: /^(.+?)\s+is\s+closed\.?$/i,
164
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-open", object: "false" }) },
165
+ { re: /^(.+?)\s+is\s+a\s+container\.?$/i,
166
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-container", object: "true" }) },
167
+ { re: /^(.+?)\s+hunts\s+the\s+other\s+animals\.?$/i,
168
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-predator", object: "true" }) },
169
+ { re: /^(.+?)\s+is\s+where\s+the\s+burrow\s+starts\.?$/i,
170
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:is-origin", object: "true" }) },
171
+ { re: /^(.+?)\s+is\s+a\s+kind\s+of\s+(.+?)\.?$/i,
172
+ build: (m) => ({ subject: LOW(m[1]), predicate: "rdfs:subClassOf", object: LOW(m[2]) }) },
173
+ { re: /^(.+?)\s+weighs\s+([\d.]+)\.?$/i,
174
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:hasMass", object: m[2] }) },
175
+ { re: /^(.+?)\s+is\s+shown\s+as\s+(.+?)\.?$/i,
176
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:display-name", object: LOW(m[2]) }) },
177
+ { re: /^(.+?)\s+loses\s+([\d.]+)\s+mass\s+a\s+turn\.?$/i,
178
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:mass-drain-per-turn", object: m[2] }) },
179
+ // The dig knobs. "at most N things" is tried before the plain "turns up X" so
180
+ // the count reading wins over reading "at most 2 things" as a spawned kind.
181
+ { re: /^digging\s+in\s+(.+?)\s+turns\s+up\s+at\s+most\s+(\d+)\s+things?\.?$/i,
182
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:dig-spawn-max", object: m[2] }) },
183
+ { re: /^digging\s+in\s+(.+?)\s+turns\s+up\s+(.+?)\.?$/i,
184
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:dig-spawns", object: LOW(m[2]) }) },
185
+ { re: /^a\s+den\s+in\s+(.+?)\s+stores\s+(.+?)\.?$/i,
186
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:den-spawns", object: LOW(m[2]) }) },
187
+ { re: /^a\s+den\s+in\s+(.+?)\s+is\s+lived\s+in\s+by\s+(.+?)\.?$/i,
188
+ build: (m) => ({ subject: LOW(m[1]), predicate: "mgx:den-resident", object: LOW(m[2]) }) },
189
+ { re: /^the\s+burrow\s+digs\s+(\d+)\s+rooms?\s+out\s+from\s+the\s+(.+?)\.?$/i,
190
+ build: (m) => ({ subject: LOW(m[2]), predicate: "mgx:dig-reach", object: m[1] }) },
191
+ { re: /^one\s+dig\s+in\s+(\d+)\s+in\s+(.+?)\s+opens\s+a\s+den\.?$/i,
192
+ build: (m) => ({ subject: LOW(m[2]), predicate: "mgx:den-chance-in", object: m[1] }) },
193
+ { re: /^one\s+den\s+in\s+(\d+)\s+in\s+(.+?)\s+is\s+lived\s+in\.?$/i,
194
+ build: (m) => ({ subject: LOW(m[2]), predicate: "mgx:den-resident-chance-in", object: m[1] }) },
195
+ // The generic type fallback, tried last on purpose.
196
+ { re: /^(.+?)\s+is\s+an?\s+(.+?)\.?$/i,
197
+ build: (m) => ({ subject: LOW(m[1]), predicate: "rdf:type", object: LOW(m[2]) }) },
198
+ ];
199
+
200
+ const PLACEMENT_PREDICATES = new Set([
201
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:hidden-in",
202
+ ]);
203
+
204
+ /** One line -> `{ subject, predicate, object, kind }`, or null when nothing in
205
+ * the table recognizes it — an honest miss, never a guessed shape. Pure. */
206
+ export function parseMudEditorLine(line) {
207
+ const trimmed = String(line || "").trim();
208
+ if (!trimmed) return null;
209
+ for (const pattern of LINE_PATTERNS) {
210
+ const m = trimmed.match(pattern.re);
211
+ if (!m) continue;
212
+ const triple = pattern.build(m);
213
+ if (!triple.subject || !triple.object) continue;
214
+ const kind = triple.predicate === "mgx:is-open" ? OPENNESS_KIND
215
+ : triple.predicate === "mgx:hasMass" ? MASS_KIND
216
+ : PLACEMENT_PREDICATES.has(triple.predicate) ? PLACEMENT_KIND : OTHER_KIND;
217
+ return { ...triple, kind };
218
+ }
219
+ return null;
220
+ }
221
+
222
+ /** Parse the whole editor textarea: every non-blank line either becomes a triple
223
+ * or lands in `unrecognized` (1-based line numbers, and the original text —
224
+ * never silently dropped). Pure. */
225
+ export function parseMudEditorText(text) {
226
+ const triples = [];
227
+ const unrecognized = [];
228
+ String(text || "").split("\n").forEach((raw, i) => {
229
+ const trimmed = raw.trim();
230
+ if (!trimmed) return;
231
+ const parsed = parseMudEditorLine(trimmed);
232
+ if (parsed) triples.push(parsed);
233
+ else unrecognized.push({ line: i + 1, text: raw });
234
+ });
235
+ return { triples, unrecognized };
236
+ }
237
+
238
+ // ---- sync planning -----------------------------------------------------------
239
+
240
+ const tripleKey = (t) => `${t.subject} ${t.predicate} ${t.object}`;
241
+
242
+ /** Every raw fact row this editor's "other" family is allowed to touch. Skips
243
+ * @turnN snapshot subjects, mirroring the renderer, and skips a "false" row of
244
+ * a flag the renderer only writes a sentence for in the "true" direction. Pure. */
245
+ export function editableMudOtherRows(rows) {
246
+ return (rows || []).filter((r) => {
247
+ if (SNAPSHOT_RE.test(r.subject)) return false;
248
+ if (EXIT_PREDICATE_RE.test(r.predicate)) return true;
249
+ if (TRUE_ONLY_FLAG_PREDICATES.includes(r.predicate)) return r.object === "true";
250
+ return EDITABLE_OTHER_PREDICATES.includes(r.predicate);
251
+ });
252
+ }
253
+
254
+ /** Plan the fact-store writes one parsed edit implies, from already-parsed
255
+ * `triples` against the world's current `rows`/`state`. Returns
256
+ * `{ toAppend, toRemoveIds }` — pure, no I/O.
257
+ *
258
+ * Placement and openness triples are never retracted: one joins `toAppend` only
259
+ * when it actually differs from the subject's current folded value, so
260
+ * re-asserting an unchanged line doesn't append a no-op duplicate per keystroke.
261
+ * Everything else gets a real add/remove diff — but the CALLER decides whether
262
+ * `toRemoveIds` is safe to apply, and must skip it whenever the parse reported
263
+ * any unrecognized line. */
264
+ export function planMudEditorSync(rows, state, triples) {
265
+ const toAppend = [];
266
+ // One "already said this" set per fold-versioned family, never one shared set:
267
+ // a subject can carry a placement, an openness and a mass at once, and a
268
+ // single set would let whichever line came last silence the other two.
269
+ const seenByKind = { [PLACEMENT_KIND]: new Set(), [OPENNESS_KIND]: new Set(), [MASS_KIND]: new Set() };
270
+ const otherTriples = [];
271
+ // Last occurrence per subject wins, mirroring how a person reads a document
272
+ // top to bottom and treats a later line as the correction of an earlier one.
273
+ for (const t of [...(triples || [])].reverse()) {
274
+ if (t.kind === OTHER_KIND) { otherTriples.push(t); continue; }
275
+ const seen = seenByKind[t.kind];
276
+ if (seen.has(t.subject)) continue;
277
+ seen.add(t.subject);
278
+ if (t.kind === PLACEMENT_KIND) {
279
+ const current = state.placements?.get(t.subject);
280
+ if (!current || current.predicate !== t.predicate || current.object !== t.object) toAppend.push(t);
281
+ } else if (t.kind === OPENNESS_KIND) {
282
+ const current = state.openness?.get(t.subject);
283
+ if (!current || current.open !== (t.object === "true")) toAppend.push(t);
284
+ } else {
285
+ const current = state.masses?.get(t.subject);
286
+ if (!current || Number(current.value) !== Number(t.object)) toAppend.push(t);
287
+ }
288
+ }
289
+
290
+ const currentKeys = new Map(editableMudOtherRows(rows).map((r) => [tripleKey(r), r.id]));
291
+ const newOtherKeys = new Set();
292
+ for (const t of otherTriples) {
293
+ const key = tripleKey(t);
294
+ if (newOtherKeys.has(key)) continue;
295
+ newOtherKeys.add(key);
296
+ if (!currentKeys.has(key)) toAppend.push(t);
297
+ }
298
+ const toRemoveIds = [];
299
+ for (const [key, id] of currentKeys) {
300
+ if (!newOtherKeys.has(key)) toRemoveIds.push(id);
301
+ }
302
+ return { toAppend, toRemoveIds };
303
+ }
304
+
305
+ /** The word immediately before `cursorPos` in `text` — a run of letters, digits
306
+ * and hyphens, the shape this vocabulary's own terms take ("underground-space",
307
+ * "mole-1"). Empty when the cursor sits after whitespace or punctuation with no
308
+ * word directly behind it. Pure. */
309
+ export function wordBeforeCursor(text, cursorPos) {
310
+ const head = String(text || "").slice(0, cursorPos);
311
+ const m = head.match(/[A-Za-z][A-Za-z0-9-]*$/);
312
+ return m ? m[0].toLowerCase() : "";
313
+ }