@polycode-projects/the-mechanical-code-talker 2.8.1 → 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/package.json +2 -1
- 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/services/adventure-editor.mjs +361 -0
- package/src/services/adventure-viz.mjs +422 -51
- 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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.3",
|
|
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
|
-
|
|
21
|
+
spiderVisionRadius: 4,
|
|
22
|
+
flyVisionRadius: 4,
|
|
22
23
|
eggHatchDelayTurns: 3,
|
|
23
24
|
flySpawnIntervalTurns: 3,
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|