@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.
- package/README.md +30 -16
- package/corpus/tier2/generate.mjs +1 -0
- package/corpus/tier2/human.jsonl +1 -0
- package/corpus/tier2/manifest.json +3 -3
- package/package.json +2 -1
- package/src/adapters/corpus/sprite-large-template-files.mjs +6 -2
- package/src/domain/game-config.mjs +10 -4
- package/src/domain/hanoi-lesson.mjs +53 -0
- package/src/domain/spider-fly-world.mjs +16 -0
- package/src/domain/sprite-expressions.mjs +120 -0
- package/src/domain/sprite-templates.mjs +31 -9
- package/src/services/adventure-editor.mjs +361 -0
- package/src/services/adventure-viz.mjs +422 -51
- package/src/services/chat-page-viz.mjs +398 -0
- package/src/services/plan-pddl.mjs +245 -0
- package/src/services/plan-viz.mjs +324 -67
- package/src/services/spider-fly-turn.mjs +120 -3
- package/src/services/spider-fly-viz.mjs +341 -22
- package/src/services/spider-fly.mjs +337 -143
- package/src/services/sprite-catalog-viz.mjs +395 -0
- package/src/surfaces/web/adventure-browser-entry.mjs +34 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +10 -4
- package/src/surfaces/web/plan-browser-entry.mjs +114 -0
- package/src/surfaces/web/spider-fly-browser-entry.mjs +33 -1
|
@@ -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
|
+
}
|