@polycode-projects/the-mechanical-code-talker 4.1.8 → 5.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.
@@ -4,16 +4,21 @@
4
4
  // rows through the shared memory store. No chat, no rendering — a later
5
5
  // piece of work wraps runSpiderFlyTick's return shape for a chat turn.
6
6
  //
7
- // Grid geometry (cellId, parseCellId, visibleCells, isInWebBlock,
7
+ // Grid geometry (cellId, parseCellId, chebyshevDistance, isInWebBlock,
8
8
  // perimeterCells, DIRECTION_DELTA) is never redefined here — it all comes
9
9
  // from spider-fly-world.mjs, the one source of truth both the shipped world
10
- // pack and this engine read from.
10
+ // pack and this engine read from. Vision-gated belief is the same deal: it
11
+ // lives in the board-size-agnostic domain/agent-belief.mjs and is re-exported
12
+ // below, so this file's public surface is unchanged by where it sits.
11
13
 
12
14
  import {
13
15
  WORLD_NAME, WEB_HOME, WEB_DURATION_TURNS, SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN,
14
- cellId, parseCellId, chebyshevDistance, visibleCells, isInWebBlock, perimeterCells,
16
+ cellId, parseCellId, chebyshevDistance, isInWebBlock, perimeterCells,
15
17
  DIRECTION_DELTA, oneStepDirectionBetween,
16
18
  } from "../domain/spider-fly-world.mjs";
19
+ import {
20
+ DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor,
21
+ } from "../domain/agent-belief.mjs";
17
22
  import { findActionPath, findReachableSet } from "../domain/planning.mjs";
18
23
  import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
19
24
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
@@ -24,7 +29,6 @@ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
24
29
  // ---- tunable constants (starting values, not fixed — the vision radius and
25
30
  // mass economy all want checking against a real playable board) -------------
26
31
 
27
- export const DEFAULT_VISION_RADIUS = 4;
28
32
  export const FLY_INITIAL_MASS = 10;
29
33
  export const FLY_MASS_DECREMENT_PER_TURN = 1;
30
34
  export const EGG_HATCH_DELAY_TURNS = 3;
@@ -33,6 +37,7 @@ export const EGG_LAY_MASS_THRESHOLD = 25;
33
37
  export const EGG_HATCH_COUNT = 2;
34
38
  export const MIN_HATCHLING_MASS = 3;
35
39
  export { SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN, WEB_DURATION_TURNS };
40
+ export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
36
41
 
37
42
  // ---- seeded "randomness" (never Math.random) ---------------------------------
38
43
  // Every "random" decision (fly wander, fly/spawn placement) is a mulberry32
@@ -316,45 +321,11 @@ export function greedySpiderAvoid(spiderCell, believedOtherSpiderCell, applyActi
316
321
  }
317
322
 
318
323
  // ---- visibility and belief (§4): static grid topology is common knowledge
319
- // to both agents; only dynamic entity positions are gated by vision. A told
320
- // fact (a later chat-integration piece of work, not built here) is the one
321
- // extension point this function leaves open via its optional toldFacts
322
- // parameter shaped { subject, toAgent, cell, turn }, defaulting to empty
323
- // so today's belief is exactly "what's currently visible." -------------------
324
-
325
- /** Whether `observerSubject` currently believes `targetSubject` to be at a
326
- * particular cell: ground truth when the target's real cell is within the
327
- * observer's own visibleCells radius, else the newest told fact addressed
328
- * to this observer about this target, else null (unknown). A removed
329
- * target (eaten/starved/hatched) is never believed present. */
330
- export function believedCellOf(targetSubject, observerSubject, observerCell, state, opts = {}) {
331
- const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
332
- const place = state.placements.get(targetSubject);
333
- if (place && !state.removed.has(targetSubject)) {
334
- const seen = visibleCells(observerCell.x, observerCell.y, visionRadius);
335
- if (seen.includes(place.cell)) return parseCellId(place.cell);
336
- }
337
- const told = toldFacts
338
- .filter((f) => f.toAgent === observerSubject && f.subject === targetSubject)
339
- .sort((a, b) => (b.turn ?? 0) - (a.turn ?? 0))[0];
340
- return told ? parseCellId(told.cell) : null;
341
- }
342
-
343
- /** The nearest candidate (by believed Chebyshev distance) an observer has
344
- * any belief about at all — null when the observer believes nothing about
345
- * any candidate. `candidates` must already be in a deterministic order
346
- * (ties favor the earlier candidate). */
347
- export function nearestBelievedTarget(observerSubject, observerCell, candidates, state, opts = {}) {
348
- let best = null;
349
- let bestDist = Infinity;
350
- for (const subject of candidates) {
351
- const cell = believedCellOf(subject, observerSubject, observerCell, state, opts);
352
- if (!cell) continue;
353
- const dist = chebyshevDistance(observerCell.x, observerCell.y, cell.x, cell.y);
354
- if (dist < bestDist) { bestDist = dist; best = { subject, cell }; }
355
- }
356
- return best;
357
- }
324
+ // to both agents; only dynamic entity positions are gated by vision, in
325
+ // domain/agent-belief.mjs (imported and re-exported above). A told fact
326
+ // shaped { subject, toAgent, cell, turn } is the chat-integration channel
327
+ // into it, defaulting to empty so belief with no chat is exactly "what's
328
+ // currently visible." --------------------------------------------------------
358
329
 
359
330
  // ---- the ecology pass (§10): catch, eat, lay, hatch, spawn, starve, all as
360
331
  // ordinary turn-gated checks in one fixed-order pass. Order matters and is
@@ -656,27 +627,6 @@ function stepPlan(fromCell, toCell) {
656
627
  return direction ? [direction] : [];
657
628
  }
658
629
 
659
- /** A full "world knowledge graph" snapshot for one observer this tick: every
660
- * OTHER named candidate's believed cell (believedCellOf — ground truth
661
- * inside vision, else the newest told fact addressed to this observer,
662
- * else null for "unknown") — feeds a per-agent belief panel. Deliberately
663
- * never ground truth: showing the observer's own honest gap
664
- * between belief and reality (visibly widened by a deceiving pill or a fed
665
- * false fact) is the whole point of that panel. Returns a plain
666
- * `{ [candidateId]: cellId | null }` map. Exported as the read path for
667
- * what one agent can currently observe — every caller (this file's own
668
- * tick loop, a viz panel, a future chat lane) reads the same computation,
669
- * never a re-derived copy of it. */
670
- export function beliefSnapshotFor(observerSubject, observerCell, candidateIds, state, opts) {
671
- const belief = {};
672
- for (const candidateId of candidateIds) {
673
- if (candidateId === observerSubject) continue;
674
- const cell = believedCellOf(candidateId, observerSubject, observerCell, state, opts);
675
- belief[candidateId] = cell ? cellId(cell.x, cell.y) : null;
676
- }
677
- return belief;
678
- }
679
-
680
630
  /** Live (unexpired, by `turn`) dynamic webs from a `Map(webId -> {cell,
681
631
  * builtAtTurn})` (either a folded state's own `.webs`, or that widened with
682
632
  * web(s) minted THIS tick before they've been written/read back), as a
@@ -0,0 +1,214 @@
1
+ // world-teach.mjs — a declarative sentence read as a fact against the LIVE
2
+ // world, and written into it on the spot. This is the engine half of the
3
+ // adventure/mud pages' "teach" switch: with it off nothing here runs and the
4
+ // lane behaves exactly as it always has; with it on, a sentence the surface's
5
+ // own editor grammar can express becomes a world fact this turn.
6
+ //
7
+ // It owns the gates, the id minting, the snapshot stamping and the decline
8
+ // wording. It owns NO grammar. The sentence table belongs to the surface —
9
+ // adventure-editor.mjs for a manor, mud-editor.mjs for a burrow, which say
10
+ // placement with different words — so the parser arrives as `parseLine` and
11
+ // this module never learns either vocabulary. Both tables are the inverse of
12
+ // their own page's right-hand "the world's own account" renderer, so every
13
+ // sentence a page has already SHOWN somebody is a sentence they can type back.
14
+ //
15
+ // This is deliberately NOT chat.mjs's teachLane, and does not widen it. A
16
+ // chat teach carries `teach:chat:*` provenance, which worldActionRows filters
17
+ // out of the playable fold on purpose: a locative note somebody made mid-game
18
+ // must never silently move a prop. A world teach is a different act — the
19
+ // player asserting what the world IS — so it carries its own
20
+ // `world:<name>:taught:turnK` provenance, passes that same filter by the
21
+ // `world:` prefix every played turn already uses, and scores on the world's
22
+ // own Source with no change to the trust model. The two stay apart.
23
+ //
24
+ // Semantics are general on purpose. Anything the table can say becomes a
25
+ // fact: a new thing, a world-authored one moved, a locked cabinet opened, the
26
+ // player put somewhere else. The fold has no invariant layer, so a
27
+ // contradicting row does not conflict — the newer write simply wins, exactly
28
+ // as a played turn's does.
29
+ //
30
+ // One consequence worth stating plainly: a teach spends a turn number (every
31
+ // fold-versioned write is stamped `@turnK`, or it would rank as turn 0 and
32
+ // lose to anything already played) but does NOT run the NPC pass. Nobody in
33
+ // the cast moves in response to a sentence. That is the same trade the
34
+ // page's own edit mode already makes.
35
+ import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
36
+ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
37
+ import { correctMisspellings } from "../domain/interpret/normalize.mjs";
38
+ import {
39
+ classMassFacts, foldWorldState, freshObjectId, snapshotSubject, worldActionRows, worldRelook,
40
+ } from "./adventure.mjs";
41
+
42
+ // A trailing "?" is an unambiguous "this is a question", and a question must
43
+ // never reach a write boundary. A leading interrogative is the same signal one
44
+ // word earlier, run over the closed misspelling repair so a typo'd "wat is the
45
+ // lamp" goes back to the question side rather than reading as a declarative.
46
+ // Both mirror chat.mjs's own teach lane, which stands the whole lane down on
47
+ // either — a world teach has exactly the same reason to.
48
+ const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
49
+
50
+ // A subject with no referent of its own. The generic "X is a Y." fallback in
51
+ // both sentence tables would otherwise read "There is a book in the study" as
52
+ // the claim that `there` is a `book in the study` — a real fact, about
53
+ // nothing. These decline rather than fall through: the sentence is plainly
54
+ // meant as a teach, and quietly handing it to the ordinary lanes to answer as
55
+ // conversation is the guess this product doesn't make.
56
+ const EMPTY_SUBJECTS = new Set(["it", "they", "them", "there", "this", "that", "these", "those", "he", "she"]);
57
+
58
+ const PLACEMENT_PREDICATES = new Set([
59
+ "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
60
+ ]);
61
+
62
+ const SNAPSHOT_RE = /^(.+?)@(?:epoch(\d+)@)?turn(\d+)$/;
63
+
64
+ const decline = (text, note) => ({ text, lane: "game-answer", note: `ADVENTURE — world-teach: ${note}`, miss: true });
65
+
66
+ /** Which subject is which class, for the one genuinely ambiguous placement
67
+ * sentence ("X is in the Y." reads as a person standing or a prop resting,
68
+ * depending on X). The adventure table takes this as its second argument;
69
+ * the burrow table says the two with different words and ignores it. */
70
+ function typesFromRows(rows) {
71
+ const types = new Map();
72
+ for (const row of rows || []) {
73
+ if (row?.predicate === "rdf:type" && row.subject && row.object) {
74
+ types.set(String(row.subject).trim().toLowerCase(), String(row.object).trim().toLowerCase());
75
+ }
76
+ }
77
+ return types;
78
+ }
79
+
80
+ const roomsOf = (rows) => [...new Set((rows || [])
81
+ .filter((r) => r.predicate === "rdf:type" && r.object === "room" && !SNAPSHOT_RE.test(r.subject))
82
+ .map((r) => r.subject))].sort();
83
+
84
+ /** Every name the world already resolves — anything typed, anything placed,
85
+ * and either end of an exit. A subject already on this list is moved or
86
+ * re-described by a teach; one that isn't is minted. */
87
+ function knownNames(rows, state) {
88
+ const names = new Set();
89
+ for (const row of rows || []) {
90
+ if (SNAPSHOT_RE.test(row.subject)) continue;
91
+ if (row.predicate === "rdf:type" || PLACEMENT_PREDICATES.has(row.predicate)) names.add(row.subject);
92
+ if (/^mgx:has-exit-[a-z]+$/.test(row.predicate)) { names.add(row.subject); names.add(row.object); }
93
+ }
94
+ for (const subject of state?.placements?.keys() ?? []) names.add(subject);
95
+ return names;
96
+ }
97
+
98
+ /** An id nothing in the world answers to yet. The plain noun wherever it is
99
+ * free, so "Candle is in the study" leaves a thing called `candle` and the
100
+ * pill rail offers "take candle" on the very next redraw — the world's own
101
+ * class-default minting picks ids the same way. A collision falls back to
102
+ * the numbered id every mid-play spawn already uses. */
103
+ function mintedIdFor(rows, noun) {
104
+ const taken = (rows || []).some((r) => r.subject === noun || r.object === noun);
105
+ return taken ? freshObjectId(rows, noun) : noun;
106
+ }
107
+
108
+ function confirmation(triple, { thing, minted, rooms }) {
109
+ if (triple.predicate === "mgx:is-open") {
110
+ return `noted — the ${thing} is ${triple.object === "true" ? "open" : "closed"} now.`;
111
+ }
112
+ if (PLACEMENT_PREDICATES.has(triple.predicate)) {
113
+ const where = rooms.includes(triple.object) ? `in the ${triple.object}` : `with the ${triple.object}`;
114
+ return minted ? `noted — there's a ${thing} ${where} now.` : `noted — the ${thing} is ${where} now.`;
115
+ }
116
+ if (triple.predicate === "rdf:type") return `noted — the ${thing} is a ${triple.object} now.`;
117
+ return `noted — the world says that now.`;
118
+ }
119
+
120
+ /**
121
+ * One line read as a fact about the live world, or null when it is not a
122
+ * teach sentence at all and the ordinary lane should have it. `parseLine` is
123
+ * the surface's own editor parser (`parseEditorLine` / `parseMudEditorLine`);
124
+ * `planTriple` is that same editor's additive planner. `rows`/`state` are the
125
+ * caller's already-loaded fact rows and fold, so this never re-reads the
126
+ * store to decide.
127
+ *
128
+ * Returns the ordinary lane answer shape — `{ text, lane, note }`, plus
129
+ * `miss: true` on every decline so the transcript styles it like any other
130
+ * honest refusal, and `taught` (the rows actually written) on a success.
131
+ */
132
+ export async function worldTeachTurn(line, {
133
+ parseLine, planTriple, rows, state, memoryDir, world, actingSubject = "player", cache = null, graph = null,
134
+ }) {
135
+ const trimmed = String(line || "").trim();
136
+ if (!trimmed || !parseLine || !planTriple || !memoryDir) return null;
137
+ if (/\?\s*$/.test(trimmed)) return null;
138
+ if (QUESTION_LEAD_RE.test(correctMisspellings(trimmed))) return null;
139
+
140
+ const triple = parseLine(trimmed, typesFromRows(rows));
141
+ if (!triple) return null;
142
+
143
+ const rooms = roomsOf(rows);
144
+ if (EMPTY_SUBJECTS.has(triple.subject)) {
145
+ return decline(
146
+ `"${triple.subject}" doesn't name anything I can write a fact about — name the thing itself, like "candle is in the study".`,
147
+ `"${trimmed}" parses with "${triple.subject}" as its subject, which refers to nothing this world holds; declined rather than stored against a pronoun`,
148
+ );
149
+ }
150
+ if (triple.kind === "placement" && !rooms.includes(triple.object) && !knownNames(rows, state).has(triple.object)) {
151
+ return decline(
152
+ `I don't know a place called "${triple.object}" — this world has: ${rooms.join(", ")}.`,
153
+ `"${trimmed}" places ${triple.subject} in "${triple.object}", which names no room and nothing else the world holds; declined by name, alternatives listed`,
154
+ );
155
+ }
156
+
157
+ const { toAppend, reason } = planTriple(rows, state, triple);
158
+ const known = knownNames(rows, state);
159
+ const minting = triple.kind === "placement" && !known.has(triple.subject);
160
+ // Re-asserting something the world already holds is true, so it is not a
161
+ // miss — but it writes nothing, and saying so is the whole answer.
162
+ if (!toAppend.length && !minting) {
163
+ const room = state?.placements?.get(actingSubject)?.object ?? null;
164
+ return {
165
+ text: `the world already said that.${room ? ` ${await worldRelook(room, { memoryDir, graph, actingSubject })}` : ""}`,
166
+ lane: "game-answer",
167
+ miss: false,
168
+ note: `ADVENTURE — world-teach: "${trimmed}" asserts a fact the world already holds (${reason}); nothing written`,
169
+ taught: [],
170
+ };
171
+ }
172
+
173
+ const k = (state?.turnCount ?? 0) + 1;
174
+ const epoch = state?.epoch ?? 0;
175
+ const id = minting ? mintedIdFor(rows, triple.subject) : triple.subject;
176
+ // Only the fold-versioned families are stamped. foldWorldState ranks an
177
+ // unstamped row as turn 0, so an unstamped placement would silently lose to
178
+ // anything already played about the same thing. A type/exit/flag row is read
179
+ // raw by every reader and must keep its bare subject — a stamped one names a
180
+ // subject no verb resolves.
181
+ const stamp = (t) => (t.kind === "other"
182
+ ? { subject: id, predicate: t.predicate, object: t.object }
183
+ : { subject: snapshotSubject(id, k, epoch), predicate: t.predicate, object: t.object });
184
+ const facts = [
185
+ // A minted thing arrives with the same rows the world's own class-default
186
+ // and dig spawns write: its own class (which resolves its sprite), the
187
+ // portable class (which is what makes the take verb accept it), a plain
188
+ // reading name, and whatever mass its class declares. A class with no
189
+ // sprite of its own falls back to the parcel icon already drawn for it.
190
+ ...(minting ? [
191
+ { subject: id, predicate: "rdf:type", object: triple.subject },
192
+ { subject: id, predicate: "rdf:type", object: "portable" },
193
+ { subject: id, predicate: "mgx:display-name", object: triple.subject },
194
+ ...classMassFacts(rows, id, triple.subject),
195
+ ] : []),
196
+ ...toAppend.map(stamp),
197
+ ];
198
+ const provenance = `${worldProvenanceTag(world)}:taught:turn${k}`;
199
+ await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance })));
200
+ if (cache) cache.rows = null;
201
+
202
+ const freshState = foldWorldState(worldActionRows(readFactRows(await loadMemory(memoryDir))));
203
+ const here = freshState.placements.get(actingSubject)?.object ?? null;
204
+ const said = confirmation(triple, { thing: triple.subject, minted: minting, rooms });
205
+ const relook = here ? ` ${await worldRelook(here, { memoryDir, graph, actingSubject })}` : "";
206
+ return {
207
+ text: `${said}${relook}`,
208
+ lane: "game-answer",
209
+ miss: false,
210
+ goal: minting ? `put a ${triple.subject} in the world` : `change what the world says about the ${triple.subject}`,
211
+ note: `ADVENTURE — world-teach: ${reason}; ${minting ? `minted ${id} and ` : ""}wrote ${facts.length} row(s) at turn ${k} with provenance ${provenance}; no NPC pass rides a taught fact${here ? `; auto-relook appended for the ${here}` : ""}`,
212
+ taught: facts,
213
+ };
214
+ }
@@ -43,7 +43,7 @@ import {
43
43
  import { parseEntities } from "../../domain/codegraph.mjs";
44
44
  import { memoryFactGraphPayload } from "../../domain/memory-facts.mjs";
45
45
  import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
46
- import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows } from "../../services/adventure.mjs";
46
+ import { foldWorldState, worldDigestRows, roomAffordances, worldActionRows, snapshotSubject } from "../../services/adventure.mjs";
47
47
  import { runAdventureAutoplayTick, exposedFacts } from "../../services/adventure-autoplay.mjs";
48
48
  import { parseWorldEditorText, planWorldEditorSync } from "../../services/adventure-editor.mjs";
49
49
  import { resolveSpriteForClass, SPRITE_REGISTRY, classAncestorChain } from "../../domain/sprite-map.mjs";
@@ -194,15 +194,30 @@ export async function createAdventureSession(worldPayload, { restoredPayload = n
194
194
  * fact as "not in this text" and try to retract it). Retractions
195
195
  * (removeFacts) only ever run when the WHOLE document parsed cleanly —
196
196
  * see adventure-editor.mjs's own header for why a typo must never be
197
- * read as "this fact is gone". Returns `{ unrecognized, added, removed }`. */
197
+ * read as "this fact is gone". Returns `{ unrecognized, added, removed }`.
198
+ *
199
+ * A placement or openness edit is written as a TURN SNAPSHOT, the way
200
+ * every played turn writes one. foldWorldState ranks an unstamped row as
201
+ * turn 0, so an edit to something already played — the lamp taken on
202
+ * turn 4, then moved back to the desk in the editor — would be outranked
203
+ * by the very move it was meant to correct, and the edit would land in
204
+ * the store and change nothing anyone can see. Type/exit/puzzle rows are
205
+ * read raw and keep their bare subject: a stamped one names a subject no
206
+ * verb resolves. */
198
207
  async applyEdit(text) {
199
208
  const allRows = readFactRows(await loadMemory(memoryDir));
200
209
  const worldRows = allRows.filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(tag) === 0);
201
210
  const state = foldWorldState(worldRows);
202
211
  const { triples, unrecognized } = parseWorldEditorText(text, worldRows);
203
212
  const { toAppend, toRemoveIds } = planWorldEditorSync(worldRows, state, triples);
213
+ const editTurn = state.turnCount + 1;
204
214
  if (toAppend.length) {
205
- await appendFacts(memoryDir, toAppend.map((f) => ({ subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag })));
215
+ await appendFacts(memoryDir, toAppend.map((f) => ({
216
+ subject: f.kind === "other" ? f.subject : snapshotSubject(f.subject, editTurn, state.epoch),
217
+ predicate: f.predicate,
218
+ object: f.object,
219
+ provenance: f.kind === "other" ? tag : `${tag}:turn${editTurn}`,
220
+ })));
206
221
  }
207
222
  const removedCount = unrecognized.length === 0 && toRemoveIds.length ? (await removeFacts(memoryDir, toRemoveIds)).removed.length : 0;
208
223
  const here = foldWorldState(readFactRows(await loadMemory(memoryDir))).placements.get("player")?.object ?? null;