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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -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
+ }