@polycode-projects/the-mechanical-code-talker 5.0.4 → 5.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.4",
3
+ "version": "5.0.5",
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; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -50,6 +50,7 @@ import {
50
50
  planRetraction, mergeRetractions, retractionFromWire, retractionWireFact,
51
51
  isRetractedRecord, RETRACTION_CLASS,
52
52
  } from "../../domain/memory/retraction.mjs";
53
+ import { admittedNodes, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
53
54
  import { assertIndividualValid } from "./shacl.mjs";
54
55
 
55
56
  // The rollup vocabulary and its tuning constants live with the compaction
@@ -70,6 +71,8 @@ export {
70
71
  retractedRecordIds, retractedAtOf, retractionWireFact, retractionFromWire,
71
72
  } from "../../domain/memory/retraction.mjs";
72
73
 
74
+ export { admittedNodes, peersToConvince, stableRecordIds } from "../../domain/memory/causal-stability.mjs";
75
+
73
76
  export const MEMORY_DIR_REL = join(".tmct", "memory");
74
77
  export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
75
78
 
@@ -3189,6 +3192,56 @@ export async function appendRetractions(dir, wireFacts) {
3189
3192
  return { merged: incoming.length, removed };
3190
3193
  }
3191
3194
 
3195
+ /** What this store could retire, and the roster it has to convince first.
3196
+ *
3197
+ * `roster` is the world's admission graph, folded to a set of node ids —
3198
+ * replicated, grow-only, and the same on every peer holding the same facts.
3199
+ * `retirable` is the tombstones every peer on that roster is known to hold.
3200
+ * `acknowledgedBy(nodeId)` is what supplies that evidence; nothing produces it
3201
+ * yet, so `retirable` reads empty and this is a report rather than a sweep.
3202
+ * Retiring nothing is the current behaviour, and it is the safe one: a
3203
+ * tombstone dropped one peer early lets that peer's copy resurrect a retracted
3204
+ * fact. See docs/references/papers/crdt.md. */
3205
+ export function retirableRetractions(memory, { self = "", acknowledgedBy = null } = {}) {
3206
+ const roster = admittedNodes(readFactRows(memory));
3207
+ const recordIds = [];
3208
+ for (const ind of memory?.individuals || []) {
3209
+ if (ind?.class === RETRACTION_CLASS && ind.id) recordIds.push(ind.id);
3210
+ }
3211
+ return { roster, retirable: stableRecordIds({ recordIds, roster, self, acknowledgedBy }) };
3212
+ }
3213
+
3214
+ /** Drop named retraction records. Takes the ids rather than choosing them, so a
3215
+ * caller has to have run the stability rule and passed its answer; ids that are
3216
+ * not retraction records are skipped. Returns the ids that went. */
3217
+ export async function retireRetractions(dir, ids) {
3218
+ const asked = new Set((ids || []).filter(Boolean));
3219
+ const retired = [];
3220
+ if (!asked.size) return { retired };
3221
+ await mutateMemory(dir, (payload) => {
3222
+ const drop = new Set();
3223
+ payload.individuals = (payload.individuals || []).filter((ind) => {
3224
+ if (ind?.class !== RETRACTION_CLASS || !asked.has(ind.id)) return true;
3225
+ drop.add(ind.id);
3226
+ return false;
3227
+ });
3228
+ if (!drop.size) return;
3229
+ for (const id of drop) retired.push(id);
3230
+ const idx = memoryIndexOf(payload);
3231
+ if (idx) {
3232
+ for (const id of drop) {
3233
+ idx.individualsById.delete(id);
3234
+ const groupId = factGroupId(id);
3235
+ const held = (idx.retractionsByGroup.get(groupId) || []).filter((r) => !drop.has(r.id));
3236
+ if (held.length) idx.retractionsByGroup.set(groupId, held);
3237
+ else idx.retractionsByGroup.delete(groupId);
3238
+ }
3239
+ }
3240
+ recountClasses(payload);
3241
+ });
3242
+ return { retired };
3243
+ }
3244
+
3192
3245
  /** The trust floor a fact must clear before a differing object counts as a real
3193
3246
  * contradiction (below it the fact is too weak to contradict anything). */
3194
3247
  const CONTRADICTION_TRUST_FLOOR = 0.5;
@@ -90,6 +90,11 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
90
90
  // this is the one switch that turns that off (visionRadius: Infinity for
91
91
  // the food-only belief call) without any new belief machinery.
92
92
  foodVisionGated: true,
93
+ // Whether the town-square lane accepts teaching. Its own knob rather than
94
+ // a share of the adventure one below: the board's sentence table is a
95
+ // different vocabulary, and a page checkbox on the town square should not
96
+ // have to set a key named after another surface.
97
+ teach: false,
93
98
  }),
94
99
  guessNumber: Object.freeze({
95
100
  defaultLo: 1,
@@ -161,6 +166,7 @@ const MUDIII_KEY_MAP = Object.freeze({
161
166
  max_prey_population: "maxPreyPopulation",
162
167
  max_food_items: "maxFoodItems",
163
168
  food_vision_gated: "foodVisionGated",
169
+ teach: "teach",
164
170
  });
165
171
 
166
172
  const ADVENTURE_KEY_MAP = Object.freeze({
@@ -0,0 +1,82 @@
1
+ // memory/causal-stability.mjs — deciding when a replicated tombstone has been
2
+ // held by enough peers to retire.
3
+ //
4
+ // A retraction record and a compaction summary both work by staying put: they
5
+ // carry the ids they suppressed, and any peer that re-delivers one of those ids
6
+ // gets refused. That is what makes a delete survive a sync over a grow-only
7
+ // set. It also means the records accumulate, because nothing yet says when one
8
+ // has done its job.
9
+ //
10
+ // The literature calls the missing rule CAUSAL STABILITY: a record is safe to
11
+ // drop once every replica that could still send a conflicting copy has it. Two
12
+ // inputs, and the mesh has one of them.
13
+ //
14
+ // - The ROSTER. `node:<joiner> mgx:invitedBy node:<inviter>` is an ordinary
15
+ // replicated fact, so the set of node ids ever admitted to a world is a
16
+ // grow-only union every peer computes the same way. `admittedNodes` reads
17
+ // it. Grow-only is exactly right here: a roster that could shrink would let
18
+ // a forgotten node's stale copy back in.
19
+ // - The ACKNOWLEDGEMENT. Nothing yet records that a named node holds a named
20
+ // record. `stableRecordIds` takes it as an argument rather than inventing
21
+ // it, and answers "nothing is stable" when it is absent — which is the
22
+ // current answer, and the safe one.
23
+ //
24
+ // Every rule here errs the same way. Dropping a tombstone one peer short lets
25
+ // that peer's copy resurrect a retracted fact, and that failure is silent,
26
+ // late, and reads as the memory inventing something. Retiring nothing is
27
+ // merely unbounded. So: an empty roster retires nothing, a member with no
28
+ // acknowledgement retires nothing, and an unparseable input retires nothing.
29
+ //
30
+ // Pure: no clock, no counter, no arrival order. The answer is a function of the
31
+ // fact set and the acknowledgement evidence handed in, which is the same
32
+ // invariant every read-time resolver over the store has to meet.
33
+ // docs/references/papers/crdt.md carries the full design and the options it
34
+ // rejected.
35
+ import { INVITED_BY_PREDICATE } from "../p2p/facts.mjs";
36
+
37
+ /** The node ids a world has ever admitted, from its admission edges. Both ends
38
+ * of each edge count: the joiner wrote the edge about itself, and it names the
39
+ * node that let it in. Sorted, so two peers holding the same facts hand the
40
+ * same roster to the rule below. */
41
+ export function admittedNodes(rows) {
42
+ const nodes = new Set();
43
+ for (const row of rows || []) {
44
+ if (row?.predicate !== INVITED_BY_PREDICATE) continue;
45
+ if (row.subject) nodes.add(String(row.subject));
46
+ if (row.object) nodes.add(String(row.object));
47
+ }
48
+ return [...nodes].sort();
49
+ }
50
+
51
+ /** The roster this node has to convince before retiring anything: every
52
+ * admitted node except itself. A node holding its own record proves nothing
53
+ * about who else still has a copy. */
54
+ export function peersToConvince(roster, self = "") {
55
+ const me = String(self || "");
56
+ return (roster || []).map(String).filter((id) => id && id !== me);
57
+ }
58
+
59
+ /**
60
+ * Which of `recordIds` every peer on the roster is known to hold.
61
+ *
62
+ * `acknowledgedBy(nodeId)` returns the record ids that node is known to hold.
63
+ * Nothing supplies it in the product yet, so it defaults to knowing nothing and
64
+ * the answer defaults to the empty set. That default is the gate: this rule
65
+ * cannot retire anything until something can show a peer holds a record.
66
+ *
67
+ * An empty roster answers with nothing too. A store with no admission edges has
68
+ * not shown it is alone; it has shown it does not know who else is out there,
69
+ * and a copy of it can be sitting in a closed browser tab.
70
+ */
71
+ export function stableRecordIds({ recordIds = [], roster = [], self = "", acknowledgedBy = null } = {}) {
72
+ const peers = peersToConvince(roster, self);
73
+ if (!peers.length) return [];
74
+ if (typeof acknowledgedBy !== "function") return [];
75
+ const held = new Map();
76
+ for (const peer of peers) {
77
+ const ids = acknowledgedBy(peer);
78
+ held.set(peer, new Set([...(ids || [])].map(String)));
79
+ }
80
+ const candidates = [...new Set((recordIds || []).map(String).filter(Boolean))].sort();
81
+ return candidates.filter((id) => peers.every((peer) => held.get(peer).has(id)));
82
+ }
@@ -82,6 +82,42 @@ export function oneStepDirectionBetween(fromCell, toCell) {
82
82
  return null;
83
83
  }
84
84
 
85
+ /** The eight points a facing may take, clockwise from north. The four
86
+ * cardinals are DIRECTION_DELTA's own keys, and they are the only ones a STEP
87
+ * can use — the grid has no diagonal exits. The four intercardinals are
88
+ * turn-only, which is what a forty-five degree turn on the spot writes. */
89
+ export const COMPASS_POINTS = Object.freeze([
90
+ "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest",
91
+ ]);
92
+
93
+ /** `facing` turned `degrees` clockwise (a negative angle turns the other way),
94
+ * landing on one of COMPASS_POINTS. Null when `facing` isn't a compass point
95
+ * or the angle isn't a whole multiple of 45, so a caller refuses rather than
96
+ * rounding a nonsense turn into a real one. Pure. */
97
+ export function turnedFacing(facing, degrees) {
98
+ const at = COMPASS_POINTS.indexOf(String(facing ?? ""));
99
+ if (at < 0) return null;
100
+ if (!Number.isInteger(degrees) || degrees % 45 !== 0) return null;
101
+ const steps = degrees / 45;
102
+ return COMPASS_POINTS[(((at + steps) % 8) + 8) % 8];
103
+ }
104
+
105
+ /** The compass point directly behind `facing`, or null. Pure. */
106
+ export function reverseFacing(facing) {
107
+ return turnedFacing(facing, 180);
108
+ }
109
+
110
+ /** The cell one step `direction` from `cell`, or null when `direction` names
111
+ * no cardinal step or `cell` doesn't parse. Bounds and props are NOT checked
112
+ * here: whether that cell can actually be entered is the exit table's answer,
113
+ * and there is only one of those. Pure. */
114
+ export function stepCellFrom(cell, direction) {
115
+ const at = parseCellId(cell);
116
+ const delta = DIRECTION_DELTA[String(direction ?? "")];
117
+ if (!at || !delta) return null;
118
+ return cellId(at.x + delta.dx, at.y + delta.dy);
119
+ }
120
+
85
121
  // ---- the prop vocabulary ------------------------------------------------------
86
122
 
87
123
  /** The closed set of noun stems a prop id may use ("house-1" -> "house"). A
@@ -14,13 +14,15 @@
14
14
 
15
15
  import {
16
16
  TOWN_SQUARE_LAYOUTS, DEFAULT_GRID_SIZE, DIRECTION_DELTA,
17
- cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
17
+ cellId, parseCellId, inBounds, isSolid, chebyshevDistance, oneStepDirectionBetween,
18
18
  agentKindOf, liveIdsOfKind, layoutNamed, isFoodId,
19
19
  } from "../domain/town-square-world.mjs";
20
20
  import {
21
21
  MUDIII_ROLES, foldTownSquareState, startTownSquareGame, runTownSquareTick,
22
22
  placeFood, roleOfId, beliefSnapshotFor,
23
23
  } from "./predator-prey.mjs";
24
+ import { snapshotSubject } from "./adventure.mjs";
25
+ import { correctMisspellings, QUESTION_LEAD_RE } from "../domain/interpret/normalize.mjs";
24
26
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
25
27
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
26
28
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
@@ -553,6 +555,242 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
553
555
  });
554
556
  }
555
557
 
558
+ // ---- the teach lane: a declarative sentence read as a board fact -------------
559
+ //
560
+ // The town square's own half of the world-teach act world-teach.mjs performs
561
+ // for a manor and a burrow, and shaped the same way: a small closed sentence
562
+ // table, one additive planner over the live fold, and a "noted — … now."
563
+ // confirmation carrying the same `world:<name>:taught:turnK` provenance.
564
+ //
565
+ // It stays here rather than routing through world-teach.mjs because that
566
+ // module's gates are written for a ROOM world. It declines by naming rooms,
567
+ // mints a fresh portable when the subject is one the world has never heard
568
+ // of, and plans against foldWorldState. A board has cells instead of rooms
569
+ // and one fold of its own, and nothing here ever mints: every subject must
570
+ // already resolve to a live individual foldTownSquareState folds, or the
571
+ // write would put a fact on the board that the board cannot draw.
572
+
573
+ const PLACEMENT_PREDICATE = "mgx:currently-in";
574
+ const MASS_PREDICATE = "mgx:hasMass";
575
+ const MOOD_PREDICATE = "mgx:feels";
576
+ const FACING_PREDICATE = "mgx:facing";
577
+ const PLACED_BY_PREDICATE = "mgx:placed-by";
578
+
579
+ // The families foldTownSquareState ranks by (epoch, turn). A row in one of
580
+ // them needs a snapshot subject or it ranks as turn 0 and loses to anything
581
+ // already played about the same thing. mgx:placed-by is read raw and keeps
582
+ // its bare subject, exactly as placeFood writes it.
583
+ const TAUGHT_SNAPSHOT_PREDICATES = new Set([
584
+ PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
585
+ ]);
586
+
587
+ // The closed cast vocabulary a taught sentence may name, matching the lane's
588
+ // own recognizers above. Mood and facing take an agent alone — a crumb has
589
+ // neither — while a cell and a weight are true of an inert item too.
590
+ const CAST_SUBJECT = "(fox|goblin|crumb|morsel)(?:-(\\d+))?";
591
+ const AGENT_SUBJECT = "(fox|goblin)(?:-(\\d+))?";
592
+ const ITEM_SUBJECT = "(crumb|morsel)(?:-(\\d+))?";
593
+
594
+ /**
595
+ * The town square's sentence table: every fact about this board a person can
596
+ * state, one row per predicate the fold reads. Checked in order, first match
597
+ * wins.
598
+ *
599
+ * Fox-1 is at cell-3-4. mgx:currently-in
600
+ * The goblin weighs 4. mgx:hasMass
601
+ * The fox feels angry. mgx:feels
602
+ * Goblin-2 faces north. mgx:facing
603
+ * The baker put morsel-1 there. mgx:placed-by
604
+ *
605
+ * A bare kind ("the fox") names whichever individual of that kind is live and
606
+ * lowest-numbered; a numbered id names exactly one. Closed on both sides: a
607
+ * mood or a direction outside the engine's own words does not parse at all,
608
+ * so nothing here can write a value a renderer has no drawing for.
609
+ */
610
+ const TOWN_SQUARE_TEACH_PATTERNS = [
611
+ { kind: "placement", predicate: PLACEMENT_PREDICATE,
612
+ re: new RegExp(`^(?:the\\s+)?${CAST_SUBJECT}\\s+is\\s+at\\s+(cell-\\d+-\\d+)[.!\\s]*$`, "i") },
613
+ { kind: "mass", predicate: MASS_PREDICATE,
614
+ re: new RegExp(`^(?:the\\s+)?${CAST_SUBJECT}\\s+weighs\\s+(\\d+(?:\\.\\d+)?)[.!\\s]*$`, "i") },
615
+ { kind: "mood", predicate: MOOD_PREDICATE,
616
+ re: new RegExp(`^(?:the\\s+)?${AGENT_SUBJECT}\\s+feels\\s+(calm|angry|scared|happy)[.!\\s]*$`, "i") },
617
+ { kind: "facing", predicate: FACING_PREDICATE,
618
+ re: new RegExp(`^(?:the\\s+)?${AGENT_SUBJECT}\\s+faces\\s+(north|south|east|west)[.!\\s]*$`, "i") },
619
+ ];
620
+
621
+ // The one sentence whose subject is not its leading noun: the item is the
622
+ // subject and the placer is the object, which is the direction "who put that
623
+ // there?" reads the row back in.
624
+ const TOWN_SQUARE_PLACED_BY_RE = new RegExp(
625
+ `^(?:the\\s+)?([a-z][a-z-]*)\\s+(?:put|placed|dropped)\\s+(?:the\\s+)?${ITEM_SUBJECT}\\s+there[.!\\s]*$`,
626
+ "i",
627
+ );
628
+
629
+ /** One line -> `{ kind, predicate, kindWord, num, object }`, or null when the
630
+ * table recognizes nothing — an honest miss, never a guessed shape.
631
+ * `kindWord`/`num` name the individual the sentence is about; the caller
632
+ * resolves that pair against the live board. Pure. */
633
+ export function parseTownSquareTeachLine(line) {
634
+ const trimmed = String(line || "").trim();
635
+ if (!trimmed) return null;
636
+ for (const { kind, predicate, re } of TOWN_SQUARE_TEACH_PATTERNS) {
637
+ const m = trimmed.match(re);
638
+ if (!m) continue;
639
+ return { kind, predicate, kindWord: m[1].toLowerCase(), num: m[2] ?? null, object: m[3].toLowerCase() };
640
+ }
641
+ const placed = trimmed.match(TOWN_SQUARE_PLACED_BY_RE);
642
+ if (!placed) return null;
643
+ return {
644
+ kind: "placed-by", predicate: PLACED_BY_PREDICATE,
645
+ kindWord: placed[2].toLowerCase(), num: placed[3] ?? null, object: placed[1].toLowerCase(),
646
+ };
647
+ }
648
+
649
+ /**
650
+ * The rows one already-resolved taught triple implies against the board's
651
+ * current fold — the town square's counterpart to mud-editor.mjs's
652
+ * planTaughtMudTriple, and additive for the same reason: one sentence only
653
+ * ever says what it says, so nothing it leaves out is evidence of anything.
654
+ * Re-asserting a fact the board already holds appends nothing, and `reason`
655
+ * says which of the two happened.
656
+ *
657
+ * Takes the fold alone rather than the raw rows its burrow counterpart also
658
+ * needs: every family this table can say is one foldTownSquareState folds, so
659
+ * there is no raw-row family left to diff against. Pure.
660
+ */
661
+ export function planTaughtTownSquareTriple(state, triple) {
662
+ if (!triple?.subject || !triple?.object) return { toAppend: [], reason: "nothing parsed" };
663
+ const { subject, object } = triple;
664
+ switch (triple.kind) {
665
+ case "placement": {
666
+ const current = state?.placements?.get(subject);
667
+ if (current?.cell === object) return { toAppend: [], reason: `${subject} already stands at ${object}` };
668
+ return {
669
+ toAppend: [triple],
670
+ reason: current ? `${subject} moves from ${current.cell} to ${object}` : `${subject} is placed at ${object}`,
671
+ };
672
+ }
673
+ case "mass": {
674
+ const current = state?.mass?.get(subject);
675
+ if (current && Number(current.value) === Number(object)) return { toAppend: [], reason: `${subject} already weighs ${object}` };
676
+ return { toAppend: [triple], reason: `${subject} weighs ${object}` };
677
+ }
678
+ case "mood": {
679
+ const current = state?.mood?.get(subject);
680
+ if (current?.value === object) return { toAppend: [], reason: `${subject} already feels ${object}` };
681
+ return { toAppend: [triple], reason: `${subject} feels ${object}` };
682
+ }
683
+ case "facing": {
684
+ const current = state?.facing?.get(subject);
685
+ if (current?.value === object) return { toAppend: [], reason: `${subject} already faces ${object}` };
686
+ return { toAppend: [triple], reason: `${subject} faces ${object}` };
687
+ }
688
+ case "placed-by": {
689
+ const current = state?.placedBy?.get(subject);
690
+ if (current?.by === object) return { toAppend: [], reason: `${object} already put ${subject} there` };
691
+ return { toAppend: [triple], reason: `${object} put ${subject} there` };
692
+ }
693
+ default:
694
+ return { toAppend: [], reason: `the board folds nothing for ${triple.predicate}` };
695
+ }
696
+ }
697
+
698
+ /** One taught triple as the sentence the board says back, in world-teach.mjs's
699
+ * own `noted — … now.` shape. Pure. */
700
+ export function townSquareTeachConfirmation(triple) {
701
+ switch (triple.kind) {
702
+ case "placement": return `noted — ${triple.subject} is at ${triple.object} now.`;
703
+ case "mass": return `noted — ${triple.subject} weighs ${triple.object} now.`;
704
+ case "mood": return `noted — ${triple.subject} feels ${triple.object} now.`;
705
+ case "facing": return `noted — ${triple.subject} faces ${triple.object} now.`;
706
+ case "placed-by": return `noted — the ${triple.object} put ${triple.subject} there now.`;
707
+ default: return "noted — the board says that now.";
708
+ }
709
+ }
710
+
711
+ const teachDecline = (text, note) => ({ text, lane: "game-answer", note: `MUDIII — world-teach: ${note}`, miss: true });
712
+
713
+ /**
714
+ * One line read as a fact about the LIVE board, or null when it is not a
715
+ * teach sentence at all and the ordinary lane should have it. Writes the
716
+ * fold-versioned families under a snapshot subject stamped at the next tick's
717
+ * own turn number, the same convention placeFood uses so a teach and the tick
718
+ * that resolves it share one turn rather than the teach quietly spending one.
719
+ *
720
+ * Nobody moves in response: a taught fact never runs the ecology pass, which
721
+ * is the same trade world-teach.mjs makes for a manor.
722
+ */
723
+ async function mudiiiTeachTurn(line, { memoryDir, cache, world, layout }) {
724
+ const trimmed = String(line || "").trim();
725
+ if (!trimmed || !memoryDir) return null;
726
+ // A trailing "?" is an unambiguous question, and a leading interrogative is
727
+ // the same signal one word earlier — a question must never reach a write
728
+ // boundary. Both mirror world-teach.mjs, which stands down on either.
729
+ if (/\?\s*$/.test(trimmed)) return null;
730
+ if (QUESTION_LEAD_RE.test(correctMisspellings(trimmed))) return null;
731
+
732
+ const parsed = parseTownSquareTeachLine(trimmed);
733
+ if (!parsed) return null;
734
+
735
+ const rows = readFactRows(await loadMemory(memoryDir));
736
+ const state = foldTownSquareState(rows);
737
+ const subject = resolveAgentId(parsed.kindWord, parsed.num, state);
738
+ if (!subject) {
739
+ return teachDecline(
740
+ `there's no live ${parsed.kindWord} on the board for that to be about.`,
741
+ `"${trimmed}" is about a ${parsed.kindWord} nothing live answers to; declined rather than minting one the board cannot draw`,
742
+ );
743
+ }
744
+
745
+ if (parsed.kind === "placement") {
746
+ const cell = parseCellId(parsed.object);
747
+ if (!cell || !inBounds(layout.gridSize, cell.x, cell.y)) {
748
+ return teachDecline(
749
+ `${parsed.object} is off the board — this square runs cell-1-1 to cell-${layout.gridSize}-${layout.gridSize}.`,
750
+ `"${trimmed}" names a cell outside the ${layout.gridSize}x${layout.gridSize} board`,
751
+ );
752
+ }
753
+ if (isSolid(layout, parsed.object)) {
754
+ return teachDecline(
755
+ `${parsed.object} is blocked — nothing stands inside a building.`,
756
+ `"${trimmed}" would stand ${subject} on a prop cell, which no path ever reaches`,
757
+ );
758
+ }
759
+ }
760
+
761
+ const triple = { subject, predicate: parsed.predicate, object: parsed.object, kind: parsed.kind };
762
+ const { toAppend, reason } = planTaughtTownSquareTriple(state, triple);
763
+ if (!toAppend.length) {
764
+ return {
765
+ text: "the board already says that.",
766
+ lane: "game-answer",
767
+ miss: false,
768
+ note: `MUDIII — world-teach: "${trimmed}" asserts a fact the board already holds (${reason}); nothing written`,
769
+ taught: [],
770
+ };
771
+ }
772
+
773
+ const k = state.tickCount + 1;
774
+ const epoch = state.epoch;
775
+ const facts = toAppend.map((t) => ({
776
+ subject: TAUGHT_SNAPSHOT_PREDICATES.has(t.predicate) ? snapshotSubject(t.subject, k, epoch) : t.subject,
777
+ predicate: t.predicate,
778
+ object: t.object,
779
+ }));
780
+ const provenance = `${worldProvenanceTag(world)}:taught:turn${k}`;
781
+ await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance })));
782
+ if (cache) cache.rows = null;
783
+
784
+ return {
785
+ text: townSquareTeachConfirmation(triple),
786
+ lane: "game-answer",
787
+ miss: false,
788
+ goal: `change what the board says about ${subject}`,
789
+ note: `MUDIII — world-teach: ${reason}; wrote ${facts.length} row(s) at turn ${k} with provenance ${provenance}; no tick rides a taught fact`,
790
+ taught: facts,
791
+ };
792
+ }
793
+
556
794
  // ---- in-game orientation asides ---------------------------------------------
557
795
  //
558
796
  // "where is the fox", "where am I", "what can I do", "what is the fox's
@@ -708,6 +946,17 @@ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = nul
708
946
  return runPlaceFoodTurn(putMatch[1], { memoryDir, gameConfig, world: mudiii.world, layout });
709
947
  }
710
948
 
949
+ // The teach switch runs before the plan-frame guard for the same reason the
950
+ // food verb does: "the fox is at cell-3-4" reads as a planning frame on its
951
+ // leading noun, and answering a sentence this lane's own table accepts with
952
+ // "stop watching, then set your goal" refuses the one thing the switch is
953
+ // for. With the switch off nothing here runs and the lane behaves exactly
954
+ // as it always has.
955
+ if (gameConfig?.mudiii?.teach) {
956
+ const taught = await mudiiiTeachTurn(trimmed, { memoryDir, cache, world: mudiii.world, layout });
957
+ if (taught) return taught;
958
+ }
959
+
711
960
  if (isPlanFrameLine(line)) {
712
961
  return {
713
962
  text: 'the town square game is running — say "stop watching" to end it, then set your goal.',