@polycode-projects/the-mechanical-code-talker 5.0.3 → 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.
@@ -186,12 +186,14 @@ export function currentActionFor(agentId, agentsById, moving) {
186
186
  * so acting on the ecology row too would double it). `kind` is one of
187
187
  * "death" (the rig's own Death clip, then scale to zero) or "consume" (an
188
188
  * eaten item scales to zero, no clip — items are primitive geometry).
189
- * Pure. */
189
+ * `eat-agent` alone also carries `actorId`, the predator that gets a
190
+ * one-shot attack clip while its prey plays death — the one event where a
191
+ * second, surviving agent has its own flourish to play. Pure. */
190
192
  export function flourishForEcologyEvent(event) {
191
193
  if (!event || !event.type) return null;
192
194
  switch (event.type) {
193
195
  case "eat-agent":
194
- return { kind: "death", targetId: event.prey, cell: event.cell };
196
+ return { kind: "death", targetId: event.prey, cell: event.cell, actorId: event.predator };
195
197
  case "starve":
196
198
  return { kind: "death", targetId: event.agent, cell: event.cell };
197
199
  case "eat-item":
@@ -359,6 +361,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
359
361
  var manifestByKind = {};
360
362
  var lastAgentsById = {};
361
363
  var lastItemsById = {};
364
+ // Every removeAgent/removeItem call, kept small and reset on boot — an
365
+ // e2e assertion's read, so it can tell an eaten agent/item's flourish
366
+ // removal (source "ecology") apart from applyTick's own diff no-op
367
+ // (source "diff") without guessing from a vanished mesh alone.
368
+ var removalLog = [];
362
369
  var cameraState = { mode: "overhead", selectedId: null };
363
370
  var cameraTween = null, lookAtTween = null;
364
371
  var lastFrameTs = null;
@@ -385,9 +392,6 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
385
392
  });
386
393
  });
387
394
  }
388
- var glbCache = null;
389
- function loadGlb(url) { return glbCache.load(url); }
390
-
391
395
  async function ensureThree() {
392
396
  if (THREE) return true;
393
397
  var result = await loadThreeVendor();
@@ -396,7 +400,6 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
396
400
  OrbitControlsCtor = result.OrbitControls; MeshoptDecoderRef = result.MeshoptDecoder;
397
401
  gltfLoader = new GLTFLoaderCtor();
398
402
  gltfLoader.setMeshoptDecoder(MeshoptDecoderRef);
399
- glbCache = createCachedLoader(loadGlbRaw);
400
403
  setUpScene();
401
404
  return true;
402
405
  }
@@ -513,13 +516,23 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
513
516
  object3D.userData.tmctNormalized = true;
514
517
  }
515
518
 
516
- async function loadPropTemplate(asset) {
517
- var url = modelUrlFor(asset.destPath);
518
- if (propTemplates[url]) return propTemplates[url];
519
- var gltf = await loadGlb(url);
520
- normalizeToHeight(gltf.scene, asset.targetHeight);
521
- propTemplates[url] = gltf.scene;
522
- return gltf.scene;
519
+ // Keyed on the manifest row's own key field, not its destPath: food-crumb
520
+ // and food-morsel share one GLB at two different targetHeights, and
521
+ // normalizeToHeight refuses a second call on the same object, so a
522
+ // destPath-keyed cache would hand the second row the first row's
523
+ // already-normalized graph and throw.
524
+ function loadPropTemplate(asset) {
525
+ var key = asset.key;
526
+ if (!propTemplates[key]) {
527
+ var url = modelUrlFor(asset.destPath);
528
+ var promise = loadGlbRaw(url).then(function (gltf) {
529
+ normalizeToHeight(gltf.scene, asset.targetHeight);
530
+ return gltf.scene;
531
+ });
532
+ promise.catch(function () { delete propTemplates[key]; });
533
+ propTemplates[key] = promise;
534
+ }
535
+ return propTemplates[key];
523
536
  }
524
537
 
525
538
  async function placeProps(propPlacements) {
@@ -544,13 +557,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
544
557
  }
545
558
  }
546
559
 
547
- // ---- items: crumbs and morsels, primitive geometry ------------------------
548
- // Doubled from 0.08/0.18: against the fox's own 1m targetHeight and a 1-unit
549
- // cell, the old radii read as barely-visible dots on the board.
550
- var CRUMB_RADIUS = 0.16, MORSEL_RADIUS = 0.36;
551
- function itemGeometryFor(kind) { return new THREE.SphereGeometry(kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS, 10, 8); }
552
- function itemMaterialFor(kind) { return new THREE.MeshStandardMaterial({ color: kind === "morsel" ? 0xd98a2b : 0x8c6a3f }); }
553
- function itemHeightFor(kind) { return kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS; }
560
+ // ---- items: crumbs and morsels, the committed hay bale at two target
561
+ // heights (food-crumb 0.16, food-morsel 0.36 in data/mudiii-assets.json),
562
+ // matching the on-ground footprint the primitive spheres they replace were
563
+ // already tuned to. ---------------------------------------------------------
564
+ function itemAssetKeyFor(kind) { return kind === "morsel" ? "food-morsel" : "food-crumb"; }
554
565
 
555
566
  function animateScaleTo(object3D, target, now) {
556
567
  var duration = tweenDurationMs;
@@ -566,29 +577,55 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
566
577
  requestAnimationFrame(step);
567
578
  }
568
579
 
580
+ // A Group per live item, the same shape ensureAgent uses for agents: the
581
+ // group is in the scene (and in itemMeshes) synchronously so a second tick
582
+ // arriving before the GLB resolves finds an existing entry rather than
583
+ // starting a second load, but stays invisible until the model is in it.
569
584
  function applyItemTick(id, item, now) {
570
585
  var world = cellToWorld(item.cell, GRID_SIZE, CELL_SIZE);
571
586
  if (!world) return;
572
587
  var entry = itemMeshes[id];
573
- if (!entry) {
574
- var mesh = new THREE.Mesh(itemGeometryFor(item.kind), itemMaterialFor(item.kind));
575
- mesh.name = "item-" + id;
576
- mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
577
- mesh.scale.setScalar(0);
578
- scene.add(mesh);
579
- entry = { mesh: mesh, cell: item.cell, kind: item.kind };
580
- itemMeshes[id] = entry;
581
- animateScaleTo(mesh, 1, now);
588
+ if (entry) {
589
+ entry.cell = item.cell;
590
+ entry.mesh.position.set(world.x, 0, world.z);
582
591
  return;
583
592
  }
584
- entry.cell = item.cell;
585
- entry.mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
593
+ var group = new THREE.Group();
594
+ group.name = "item-" + id;
595
+ group.position.set(world.x, 0, world.z);
596
+ group.visible = false;
597
+ scene.add(group);
598
+ entry = { mesh: group, cell: item.cell, kind: item.kind };
599
+ itemMeshes[id] = entry;
600
+ var asset = manifestByKind[itemAssetKeyFor(item.kind)];
601
+ if (!asset) return;
602
+ loadPropTemplate(asset).then(function (template) {
603
+ if (itemMeshes[id] !== entry) return; // removed while the model was still loading
604
+ var instance = template.clone();
605
+ instance.position.y = template.position.y;
606
+ group.add(instance);
607
+ group.visible = true;
608
+ group.scale.setScalar(0);
609
+ animateScaleTo(group, 1, performance.now());
610
+ }).catch(function (err) {
611
+ // eslint-disable-next-line no-console
612
+ console.warn("tmct: an item model failed to load", id, err);
613
+ });
614
+ }
615
+
616
+ function recordRemoval(kind, id, source) {
617
+ removalLog.push({ kind: kind, id: id, source: source });
586
618
  }
587
619
 
588
- function removeItem(id, withFlourish) {
620
+ // source ("ecology" | "diff") is recorded, never branched on — it exists
621
+ // so an e2e assertion can tell an eaten item's flourish removal apart from
622
+ // a plain diff no-op, since a vanished mesh alone does not say which path
623
+ // did the removing.
624
+ function removeItem(id, withFlourish, source) {
589
625
  var entry = itemMeshes[id];
590
626
  if (!entry) return;
591
627
  delete itemMeshes[id];
628
+ recordRemoval("item", id, source);
592
629
  if (withFlourish) animateScaleTo(entry.mesh, 0, performance.now());
593
630
  else if (entry.mesh.parent) entry.mesh.parent.remove(entry.mesh);
594
631
  }
@@ -603,7 +640,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
603
640
  var kind = roleOfAgentId(id);
604
641
  var entry = {
605
642
  group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
606
- kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null,
643
+ kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null, oneShotAction: null,
607
644
  };
608
645
  entry.group.visible = false;
609
646
  scene.add(entry.group);
@@ -628,6 +665,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
628
665
  }
629
666
 
630
667
  function playClip(entry, clipName) {
668
+ // A one-shot flourish left its own action running at full weight (see
669
+ // playClipOnce below — fadeIn ramps the NEW action in but never ramps
670
+ // the old one out on its own, so without this the flourish pose would
671
+ // keep blending into every clip after it, forever). Fade it out before
672
+ // any of the guards below can skip the rest of this call.
673
+ if (entry.oneShotAction) {
674
+ entry.oneShotAction.fadeOut(0.15);
675
+ entry.oneShotAction = null;
676
+ }
631
677
  if (!entry.mixer || !clipName || entry.currentClip === clipName) return;
632
678
  var next = entry.actions[clipName];
633
679
  if (!next) return;
@@ -637,6 +683,28 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
637
683
  entry.currentClip = clipName;
638
684
  }
639
685
 
686
+ // A one-shot version of playClip for a flourish (the predator's own bite
687
+ // on eat-agent): LoopOnce + clampWhenFinished so the clip plays through and
688
+ // holds its last pose rather than looping. Never skips a repeat of
689
+ // entry.currentClip the way playClip does — a flourish must always play,
690
+ // even if the actor happened to already be on this same clip. Clears
691
+ // entry.currentClip to null so the next applyAgentTick's own playClip call
692
+ // is never refused as "already on this clip", and records the action on
693
+ // entry.oneShotAction so that same next call fades it back out.
694
+ function playClipOnce(entry, clipName) {
695
+ if (!entry.mixer || !clipName) return;
696
+ var action = entry.actions[clipName];
697
+ if (!action) return;
698
+ var prev = entry.currentClip && entry.currentClip !== clipName ? entry.actions[entry.currentClip] : null;
699
+ action.reset();
700
+ action.setLoop(THREE.LoopOnce, 1);
701
+ action.clampWhenFinished = true;
702
+ action.fadeIn(0.15).play();
703
+ if (prev) prev.fadeOut(0.15);
704
+ entry.oneShotAction = action;
705
+ entry.currentClip = null;
706
+ }
707
+
640
708
  function playSpawnFlourish(group, now) {
641
709
  group.scale.setScalar(0);
642
710
  animateScaleTo(group, 1, now);
@@ -667,10 +735,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
667
735
  }
668
736
  }
669
737
 
670
- function removeAgent(id) {
738
+ function removeAgent(id, source) {
671
739
  var entry = agentGroups[id];
672
740
  if (!entry) return;
673
741
  delete agentGroups[id];
742
+ recordRemoval("agent", id, source);
674
743
  var deathClip = entry.clipMap && entry.clipMap.death;
675
744
  var deathClipName = Array.isArray(deathClip) ? deathClip[0] : deathClip;
676
745
  if (deathClipName) {
@@ -683,10 +752,17 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
683
752
 
684
753
  function applyEcology(ecology) {
685
754
  for (var i = 0; i < (ecology || []).length; i += 1) {
686
- var flourish = flourishForEcologyEvent(ecology[i]);
755
+ var event = ecology[i];
756
+ var flourish = flourishForEcologyEvent(event);
687
757
  if (!flourish) continue;
688
- if (flourish.kind === "death") removeAgent(flourish.targetId);
689
- else if (flourish.kind === "consume") removeItem(flourish.targetId, true);
758
+ if (flourish.kind === "death") removeAgent(flourish.targetId, "ecology");
759
+ else if (flourish.kind === "consume") removeItem(flourish.targetId, true, "ecology");
760
+ if (flourish.actorId) {
761
+ var actorEntry = agentGroups[flourish.actorId];
762
+ if (actorEntry && actorEntry.clipMap) {
763
+ playClipOnce(actorEntry, clipForAction(actorEntry.role, event.type, actorEntry.clipMap));
764
+ }
765
+ }
690
766
  }
691
767
  }
692
768
 
@@ -741,6 +817,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
741
817
  itemMeshes = {};
742
818
  lastAgentsById = {};
743
819
  lastItemsById = {};
820
+ removalLog = [];
744
821
  manifestByKind = buildManifestByKind(input && input.assetManifest);
745
822
  await placeProps((input && input.propPlacements) || []);
746
823
  setCamera({ mode: "overhead", selectedId: null });
@@ -754,9 +831,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
754
831
  var items = (tick && tick.items) || {};
755
832
  for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
756
833
  for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
757
- for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent);
758
- for (var goneItem in lastItemsById) if (!(goneItem in items)) removeItem(goneItem, false);
834
+ // Ecology first: an eaten agent/item is already gone from this tick's own
835
+ // agents/items map, so applyEcology's own removeAgent/removeItem call is
836
+ // what actually removes it (with its flourish). The two diff loops below
837
+ // then find that id already deleted and no-op — removeAgent/removeItem
838
+ // both delete their map entry before anything else, so a second call for
839
+ // the same id returns immediately.
759
840
  applyEcology(tick && tick.ecology);
841
+ for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent, "diff");
842
+ for (var goneItem in lastItemsById) if (!(goneItem in items)) removeItem(goneItem, false, "diff");
760
843
  lastAgentsById = agents;
761
844
  lastItemsById = items;
762
845
  refreshCameraTween(now);
@@ -812,6 +895,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
812
895
  var asset = manifestByKind[entry.kind];
813
896
  return { height: size.y, minY: box.min.y, targetHeight: asset ? asset.targetHeight : null };
814
897
  },
898
+ // Every removal recorded for id, oldest first — each entry's own
899
+ // source is "ecology" (an eat/starve flourish removed it) or "diff"
900
+ // (applyTick found it missing from a tick's own agents/items map with no
901
+ // ecology event driving that). An e2e assertion's read.
902
+ removalsFor: function (id) {
903
+ var out = [];
904
+ for (var i = 0; i < removalLog.length; i += 1) if (removalLog[i].id === id) out.push(removalLog[i]);
905
+ return out;
906
+ },
815
907
  ready: function () { return booted; },
816
908
  };
817
909
  })();`;
@@ -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,
18
- agentKindOf, liveIdsOfKind, layoutNamed,
17
+ cellId, parseCellId, inBounds, isSolid, chebyshevDistance, oneStepDirectionBetween,
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";
@@ -88,6 +90,25 @@ const MUDIII_TOLD_RE = new RegExp(
88
90
  // never ground truth.
89
91
  const MUDIII_SEE_RE = /^what (?:does|can) the (fox|goblin)(?:-(\d+))?\s+see[.!?\s]*$/i;
90
92
 
93
+ // "who put/placed that there?" — provenance, ground truth (not belief), read
94
+ // from placeFood's own mgx:placed-by row. The subject must be named, either
95
+ // an item id ("who put morsel-1 there?") or a cell ("who put food at
96
+ // cell-3-4?") — a bare "who put that there?" with no referent at all names
97
+ // nothing to look up and is left to the ordinary lanes. Requires the leading
98
+ // "who put/placed/dropped" verb, so it never collides with an unrelated "who"
99
+ // question (the fox's own catches, the goblins' names, anything not about a
100
+ // placement) — those never start with this verb at all.
101
+ const MUDIII_WHO_PLACED_RE = new RegExp(
102
+ "^who\\s+(?:put|placed|dropped)\\s+"
103
+ + "(?:"
104
+ + "(?:the\\s+)?(morsel|crumb)(?:-(\\d+))?"
105
+ + "|"
106
+ + "(?:(?:the\\s+)?(?:food|morsel|crumb|that|it)\\s+)?(?:at|on)\\s+(cell-\\d+-\\d+)"
107
+ + ")"
108
+ + "(?:\\s+there)?[?.!\\s]*$",
109
+ "i",
110
+ );
111
+
91
112
  // The player's own verb: "put food at cell-3-4" (primary, never shadowed —
92
113
  // the adventure imperative grammar's own "put" arm hard-requires a literal
93
114
  // "in", so "at" never parses there) and "drop a morsel at cell-3-4"
@@ -441,6 +462,63 @@ async function mudiiiBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_
441
462
  };
442
463
  }
443
464
 
465
+ function noLivePlacedSubjectAnswer(kind) {
466
+ return {
467
+ text: `there's no live ${kind} on the board for that to be about.`,
468
+ lane: "game-inform",
469
+ note: `MUDIII — who-placed declined: no live ${kind} resolves`,
470
+ miss: true,
471
+ };
472
+ }
473
+
474
+ /** "who put/placed <item|cell> there?": ground truth read off the folded
475
+ * state's own placedBy Map (predator-prey.mjs's fold of every mgx:placed-by
476
+ * row), never belief. Resolves the subject the same way the told-fact and
477
+ * see recognizers already do — resolveAgentId for a named item, a direct
478
+ * placements scan for a cell — then declines, naming the reason, when the
479
+ * item isn't on the board at all or is on the board but carries no placedBy
480
+ * row (spawned food never does; placeFood is the only writer of that row). */
481
+ async function mudiiiWhoPlacedAnswer(match, { memoryDir }) {
482
+ const [, kindRaw, num, cellLiteral] = match;
483
+ const rows = readFactRows(await loadMemory(memoryDir));
484
+ const state = foldTownSquareState(rows);
485
+
486
+ let itemId = null;
487
+ if (kindRaw) {
488
+ itemId = resolveAgentId(kindRaw.toLowerCase(), num, state);
489
+ } else {
490
+ const entry = [...state.placements.entries()].find(([id, place]) =>
491
+ isFoodId(id) && !state.removed.has(id) && place.cell === cellLiteral);
492
+ itemId = entry ? entry[0] : null;
493
+ }
494
+
495
+ if (!itemId) {
496
+ if (kindRaw) return noLivePlacedSubjectAnswer(kindRaw.toLowerCase());
497
+ return {
498
+ text: `there's no food at ${cellLiteral} to ask about.`,
499
+ lane: "game-inform",
500
+ note: `MUDIII — who-placed declined: no live food item sits at ${cellLiteral}`,
501
+ miss: true,
502
+ };
503
+ }
504
+
505
+ const placed = state.placedBy.get(itemId);
506
+ if (!placed) {
507
+ return {
508
+ text: `${itemId} was never placed — it spawned on its own.`,
509
+ lane: "game-inform",
510
+ note: `MUDIII — who-placed declined: ${itemId} carries no mgx:placed-by row (spawned food)`,
511
+ miss: true,
512
+ };
513
+ }
514
+
515
+ return {
516
+ text: `${placed.by} put ${itemId} at ${state.placements.get(itemId).cell}.`,
517
+ lane: "game-inform",
518
+ note: `MUDIII — who-placed answered from state.placedBy for ${itemId}`,
519
+ };
520
+ }
521
+
444
522
  /** The addressed teach-frame turn: resolve the addressee and the belief
445
523
  * subject (an agent OR a food item), resolve the told cell, and run ONE tick
446
524
  * with that told-fact fed in. Told-facts are not persisted across turns —
@@ -477,6 +555,242 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig
477
555
  });
478
556
  }
479
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
+
480
794
  // ---- in-game orientation asides ---------------------------------------------
481
795
  //
482
796
  // "where is the fox", "where am I", "what can I do", "what is the fox's
@@ -632,6 +946,17 @@ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = nul
632
946
  return runPlaceFoodTurn(putMatch[1], { memoryDir, gameConfig, world: mudiii.world, layout });
633
947
  }
634
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
+
635
960
  if (isPlanFrameLine(line)) {
636
961
  return {
637
962
  text: 'the town square game is running — say "stop watching" to end it, then set your goal.',
@@ -659,6 +984,11 @@ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = nul
659
984
  return mudiiiBeliefAnswer(seeMatch, { memoryDir, gameConfig });
660
985
  }
661
986
 
987
+ const whoPlacedMatch = trimmed.match(MUDIII_WHO_PLACED_RE);
988
+ if (whoPlacedMatch) {
989
+ return mudiiiWhoPlacedAnswer(whoPlacedMatch, { memoryDir });
990
+ }
991
+
662
992
  if (MUDIII_TICK_RE.test(line)) {
663
993
  return runTickAndRender({ planHolder, memoryDir, cache, world: mudiii.world, toldFacts: [], gameConfig });
664
994
  }