@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.
- package/corpus/sprites/src/sprite-facts.jsonl +28 -0
- package/corpus/tier2/generate.mjs +10 -1
- package/corpus/tier2/human.jsonl +23 -0
- package/corpus/tier2/manifest.json +3 -3
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +15 -5
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/src/mud-garden.jsonl +101 -0
- package/package.json +2 -1
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +67 -0
- package/src/domain/grammar/ace.mjs +11 -3
- package/src/domain/grammar/lexicon-core.json +3 -0
- package/src/domain/grammar/lexicon.mjs +13 -0
- package/src/domain/memory/trust.mjs +15 -0
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/sprite-map.mjs +10 -2
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +192 -39
- package/src/services/adventure.mjs +989 -69
- package/src/services/chat-page-viz.mjs +1060 -7
- package/src/services/chat-session.mjs +28 -3
- package/src/services/chat.mjs +2 -2
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +572 -0
- package/src/services/mud-viz.mjs +2055 -0
- package/src/services/p2p-room.mjs +559 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
- package/src/surfaces/web/mud-browser-entry.mjs +330 -0
- 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
|
-
|
|
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");
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
+
}
|