@polycode-projects/the-mechanical-code-talker 5.0.6 → 5.0.8

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.
Files changed (38) hide show
  1. package/README.md +21 -0
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +3 -2
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +8 -1
  7. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  8. package/src/domain/memory/capability.mjs +15 -11
  9. package/src/domain/router/drive.mjs +36 -17
  10. package/src/domain/router/resolver.mjs +63 -17
  11. package/src/domain/spider-fly-world.mjs +2 -2
  12. package/src/domain/sprite-templates.mjs +19 -7
  13. package/src/domain/syllogise.mjs +16 -6
  14. package/src/domain/town-square-world.mjs +1 -1
  15. package/src/services/adventure.mjs +8 -1
  16. package/src/services/chat-page-viz.mjs +118 -23
  17. package/src/services/chat-session.mjs +60 -10
  18. package/src/services/chat.mjs +228 -24
  19. package/src/services/extract-facts.mjs +47 -7
  20. package/src/services/ingest-viz.mjs +108 -25
  21. package/src/services/ledger-viz.mjs +4 -2
  22. package/src/services/memory-panel-viz.mjs +44 -0
  23. package/src/services/mud-viz.mjs +17 -0
  24. package/src/services/mudiii-scene.mjs +271 -24
  25. package/src/services/mudiii-turn.mjs +65 -9
  26. package/src/services/mudiii-viz.mjs +302 -125
  27. package/src/services/plan-viz.mjs +23 -2
  28. package/src/services/predator-prey.mjs +82 -33
  29. package/src/services/research-viz.mjs +12 -19
  30. package/src/services/spider-fly-turn.mjs +7 -1
  31. package/src/services/spider-fly-viz.mjs +10 -3
  32. package/src/services/viz-ticker.mjs +15 -2
  33. package/src/surfaces/http/server-http.mjs +90 -13
  34. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  35. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  36. package/src/surfaces/web/tmct-surface.mjs +18 -6
  37. package/src/tools/handlers/tmct-ask.mjs +15 -2
  38. package/src/tools/server.mjs +31 -2
@@ -11,11 +11,8 @@
11
11
  // this file renders is a read of one of those two — never a second, locally
12
12
  // invented notion of where an agent stands.
13
13
  //
14
- // PLAN_MUD_MUDIII.md's own render-layer section is corrected in two places
15
- // this file follows rather than the doc's original text: GLTFLoader ships in
16
- // the vendor bundle (handles KHR_mesh_quantization and EXT_texture_webp
17
- // natively; only EXT_meshopt_compression needs a decoder line), and there is
18
- // no globalThis.tmctMudiii — pages publish one globalThis.tmct.
14
+ // GLTFLoader ships in the vendor bundle, and handles KHR_mesh_quantization and
15
+ // EXT_texture_webp natively; only EXT_meshopt_compression needs a decoder line.
19
16
  //
20
17
  // The page-shell <-> scene handshake, since the two scripts share no scope:
21
18
  // - the page shell already defines `window.mudiiiHandleSceneClick(cellId)`
@@ -68,6 +65,12 @@ import { prefersReducedMotion } from "./viz-ticker.mjs";
68
65
  * own `transition: left .25s ease` becomes in a requestAnimationFrame loop. */
69
66
  export const TWEEN_DURATION_MS = 250;
70
67
 
68
+ /** The vertical field of view the page's one perspective camera is built with.
69
+ * Every rig this scene asks `cameraRigFor` for is measured against the live
70
+ * `camera3.fov`, so this number is only ever the camera's own starting value
71
+ * rather than a second copy a rig could drift from. */
72
+ export const CAMERA_FIELD_OF_VIEW_DEGREES = 55;
73
+
71
74
  // ---------------------------------------------------------------------------
72
75
  // Pure geometry/tween helpers — unit-tested directly in Node, then spliced
73
76
  // via `.toString()` into the standalone browser IIFE below, the same
@@ -100,8 +103,11 @@ export function tweenStep(tween, now) {
100
103
  return { ...point, t, done: false };
101
104
  }
102
105
 
103
- /** A fresh tween from `from` to `to`, starting at `now`. Pure. */
104
- export function startTween(from, to, now, durationMs = TWEEN_DURATION_MS) {
106
+ /** A fresh tween from `from` to `to`, starting at `now`. Pure — the default
107
+ * duration is written out rather than read from `TWEEN_DURATION_MS`, because
108
+ * the browser runs a `.toString()` copy of this function in a script that
109
+ * declares no such binding. mudiii-viz.test.mjs holds the two together. */
110
+ export function startTween(from, to, now, durationMs = 250) {
105
111
  return { from, to, startedAt: now, durationMs };
106
112
  }
107
113
 
@@ -111,7 +117,7 @@ export function startTween(from, to, now, durationMs = TWEEN_DURATION_MS) {
111
117
  * tweening); `to` is the new destination point. Pure — `tweenStep` is
112
118
  * called on the OLD tween at `now` to read the current position before
113
119
  * building the new one. */
114
- export function reseedTween(existingTween, to, now, durationMs = TWEEN_DURATION_MS) {
120
+ export function reseedTween(existingTween, to, now, durationMs = 250) {
115
121
  const current = existingTween ? tweenStep(existingTween, now) : null;
116
122
  const from = current ? Object.fromEntries(Object.keys(to).map((k) => [k, current[k]])) : to;
117
123
  return startTween(from, to, now, durationMs);
@@ -128,6 +134,69 @@ export function chebyshevDistanceBetweenCells(a, b) {
128
134
  return Math.max(Math.abs(Number(ma[1]) - Number(mb[1])), Math.abs(Number(ma[2]) - Number(mb[2])));
129
135
  }
130
136
 
137
+ /** Where the camera should aim once the visitor's own drag is applied on top
138
+ * of the rig's aim. `position` is where the rig put the camera, `lookAt` is
139
+ * where the rig wants it pointed, and `offset` is `{ yaw, pitch }` in radians
140
+ * — the drag's accumulated turn, held apart from the rig so a tick that
141
+ * recomputes the rig leaves the visitor's view alone.
142
+ *
143
+ * The offset turns the rig's own aim rather than replacing it, so the view
144
+ * swings with the agent when it turns: in POV that reads as the animal
145
+ * turning its head, which is the whole point of riding one.
146
+ *
147
+ * Pitch is clamped to 1.2 radians (about 69 degrees) either side of level, so
148
+ * a long upward drag can never carry the view past vertical and roll the
149
+ * board upside down. The returned `pitchOffset` is what survived that clamp,
150
+ * which the caller writes back over its own accumulator — otherwise a drag
151
+ * held past the clamp banks up an offset the view never shows, and the next
152
+ * drag back the other way does nothing until it has spent it.
153
+ *
154
+ * Returns `{ x, y, z, pitchOffset }`. Pure, self-contained. */
155
+ export function lookTargetWithOffset(position, lookAt, offset) {
156
+ const MAX_PITCH_RADIANS = 1.2;
157
+ const yaw = Number(offset && offset.yaw) || 0;
158
+ const pitch = Number(offset && offset.pitch) || 0;
159
+ if (!yaw && !pitch) return { x: lookAt.x, y: lookAt.y, z: lookAt.z, pitchOffset: 0 };
160
+ const dx = lookAt.x - position.x;
161
+ const dy = lookAt.y - position.y;
162
+ const dz = lookAt.z - position.z;
163
+ const radius = Math.sqrt(dx * dx + dy * dy + dz * dz);
164
+ if (!radius) return { x: lookAt.x, y: lookAt.y, z: lookAt.z, pitchOffset: 0 };
165
+ const basePitch = Math.asin(Math.max(-1, Math.min(1, dy / radius)));
166
+ const aimedPitch = Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, basePitch + pitch));
167
+ const aimedYaw = Math.atan2(dx, dz) + yaw;
168
+ const flat = Math.cos(aimedPitch) * radius;
169
+ return {
170
+ x: position.x + flat * Math.sin(aimedYaw),
171
+ y: position.y + radius * Math.sin(aimedPitch),
172
+ z: position.z + flat * Math.cos(aimedYaw),
173
+ pitchOffset: aimedPitch - basePitch,
174
+ };
175
+ }
176
+
177
+ /** The town square's own sky, as gradient stops running from straight up
178
+ * (`at` 0) to straight down (`at` 1), each `{ at, color }`.
179
+ *
180
+ * The colours are the page's own `--square-sky`, `--square-horizon`,
181
+ * `--square-stone` and `--square-stone-dark`, in the order the page body's
182
+ * own background already reads them, so the canvas continues the page it
183
+ * sits in rather than cutting a dark hole in it. The board is twelve units
184
+ * across with nothing beyond it, so a camera aimed anywhere off it — the
185
+ * opening follow shot, an overhead orbit, a long look-drag in POV — is
186
+ * looking at this and nothing else.
187
+ *
188
+ * The band sits at 0.5 because that is the horizon in an equirectangular
189
+ * projection, where the caller maps these onto a sphere. Pure,
190
+ * self-contained. */
191
+ export function skyGradientStops() {
192
+ return [
193
+ { at: 0, color: "#BFE3F0" },
194
+ { at: 0.46, color: "#E9D9B6" },
195
+ { at: 0.52, color: "#8C8172" },
196
+ { at: 1, color: "#59503F" },
197
+ ];
198
+ }
199
+
131
200
  /** A facing word to a Y rotation in radians. Assumes a model's own local
132
201
  * forward is +Z (true for fox and goblin, the two rigs in the current
133
202
  * manifest, checked by walking each GLB's own bind-pose bone chain) so a
@@ -142,7 +211,7 @@ export function yawForFacing(facing) {
142
211
  /** Whether `agentId` (predator or prey) currently believes an agent of the
143
212
  * opposing role — the one belief-derived bit that separates a predator's
144
213
  * "chase" rung from "wander", and a prey's "evade" rung from "forage"/
145
- * "wander" (PLAN_MUD_MUDIII.md's own priority chains). Read entirely off
214
+ * "wander". Read entirely off
146
215
  * the tick payload's own `role`/`belief` fields — no rung name travels on
147
216
  * the wire, so this reconstructs which rung applied rather than trusting a
148
217
  * label. Pure. */
@@ -317,6 +386,8 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
317
386
  var startTween = ${startTween.toString()};
318
387
  var reseedTween = ${reseedTween.toString()};
319
388
  var chebyshevDistanceBetweenCells = ${chebyshevDistanceBetweenCells.toString()};
389
+ var lookTargetWithOffset = ${lookTargetWithOffset.toString()};
390
+ var skyGradientStops = ${skyGradientStops.toString()};
320
391
  var yawForFacing = ${yawForFacing.toString()};
321
392
  var threatEngagedFor = ${threatEngagedFor.toString()};
322
393
  var movementRungFor = ${movementRungFor.toString()};
@@ -373,6 +444,29 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
373
444
  var tickRungs = {};
374
445
  var cameraState = { mode: "overhead", selectedId: null };
375
446
  var cameraTween = null, lookAtTween = null;
447
+ // Where the rig last wanted the camera pointed. Held past the end of its own
448
+ // tween because the visitor's look is re-applied every frame, not only while
449
+ // the tween runs.
450
+ var lookTarget = null;
451
+ // Whether setCamera has rigged the camera at least once. Without it the very
452
+ // first call — boot's own overhead call, which changes nothing — would read
453
+ // as "no change" and leave the camera at its construction position.
454
+ var cameraRigged = false;
455
+ // Whether the visitor has turned or zoomed the overhead view themselves.
456
+ // OrbitControls writes straight to the camera, so a resize cannot tell an
457
+ // orbited view from a rigged one by reading the camera back.
458
+ var overheadOrbited = false;
459
+ // The visitor's own look, turned by dragging the canvas and applied on top
460
+ // of whatever rig the camera mode computes. It survives every tick: a tick
461
+ // re-issues setCamera with the same mode and the same agent, so clearing it
462
+ // there would snap the view back a fifth of a second after every drag.
463
+ var lookOffset = { yaw: 0, pitch: 0 };
464
+ var LOOK_RADIANS_PER_PIXEL = 0.005;
465
+ // How far a pointer may travel and still count as a click on a cell rather
466
+ // than a drag. Without it every look-around also walked the followed agent
467
+ // to whatever cell the drag started over.
468
+ var CLICK_SLOP_PX = 6;
469
+ var drag = null;
376
470
  var lastFrameTs = null;
377
471
  var booted = false;
378
472
 
@@ -409,10 +503,31 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
409
503
  return true;
410
504
  }
411
505
 
506
+ // Equirectangular, not a flat screen-space backdrop: three maps this onto a
507
+ // sphere around the camera, so the horizon stays put as the visitor turns
508
+ // instead of sliding with the view. Eight pixels wide is enough for a
509
+ // gradient with no horizontal variation, and wraps with no seam.
510
+ function buildSkyTexture() {
511
+ var swatch = document.createElement("canvas");
512
+ swatch.width = 8;
513
+ swatch.height = 256;
514
+ var ctx = swatch.getContext("2d");
515
+ var gradient = ctx.createLinearGradient(0, 0, 0, swatch.height);
516
+ var stops = skyGradientStops();
517
+ for (var i = 0; i < stops.length; i += 1) gradient.addColorStop(stops[i].at, stops[i].color);
518
+ ctx.fillStyle = gradient;
519
+ ctx.fillRect(0, 0, swatch.width, swatch.height);
520
+ var texture = new THREE.CanvasTexture(swatch);
521
+ texture.mapping = THREE.EquirectangularReflectionMapping;
522
+ if (THREE.SRGBColorSpace) texture.colorSpace = THREE.SRGBColorSpace;
523
+ texture.needsUpdate = true;
524
+ return texture;
525
+ }
526
+
412
527
  function setUpScene() {
413
528
  scene = new THREE.Scene();
414
- scene.background = new THREE.Color(0x10161b);
415
- camera3 = new THREE.PerspectiveCamera(55, (canvas.clientWidth || 640) / Math.max(1, canvas.clientHeight || 360), 0.1, 500);
529
+ scene.background = buildSkyTexture();
530
+ camera3 = new THREE.PerspectiveCamera(${CAMERA_FIELD_OF_VIEW_DEGREES}, canvasAspect(), 0.1, 500);
416
531
  camera3.position.set(0, 8, 8);
417
532
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
418
533
  scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
@@ -428,6 +543,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
428
543
  orbitControls.target.set(0, 0, 0);
429
544
 
430
545
  canvas.addEventListener("pointerdown", onPointerDown);
546
+ canvas.addEventListener("wheel", function () {
547
+ if (cameraState.mode === "overhead") overheadOrbited = true;
548
+ }, { passive: true });
549
+ // On the window, not the canvas: a look-around that runs off the edge of
550
+ // the 3D stage must keep turning, and must still end when the button comes
551
+ // up over the deck.
552
+ window.addEventListener("pointermove", onPointerMove);
553
+ window.addEventListener("pointerup", onPointerUp);
554
+ window.addEventListener("pointercancel", onPointerCancel);
431
555
  window.addEventListener("resize", onResize);
432
556
  watchFoodPill();
433
557
  onResize();
@@ -450,12 +574,35 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
450
574
  scene.add(grid);
451
575
  }
452
576
 
577
+ function canvasAspect() {
578
+ return (canvas.clientWidth || 640) / Math.max(1, canvas.clientHeight || 360);
579
+ }
580
+
581
+ // What the overhead rig has to fit the board into. Read off the LIVE camera
582
+ // so one field of view drives both the projection and the distance chosen to
583
+ // suit it.
584
+ function cameraView() {
585
+ return { aspect: canvasAspect(), fovDegrees: camera3 ? camera3.fov : ${CAMERA_FIELD_OF_VIEW_DEGREES} };
586
+ }
587
+
453
588
  function onResize() {
454
589
  if (!renderer || !camera3) return;
455
590
  var w = canvas.clientWidth || 640, h = canvas.clientHeight || 360;
456
591
  renderer.setSize(w, h, false);
457
- camera3.aspect = w / Math.max(1, h);
592
+ camera3.aspect = canvasAspect();
458
593
  camera3.updateProjectionMatrix();
594
+ refitOverheadCamera();
595
+ }
596
+
597
+ // A new canvas shape fits a different amount of board, so the overhead rig is
598
+ // recomputed for it — a phone turned on its side otherwise keeps the height
599
+ // the old shape earned and crops the board's edges. A visitor who has already
600
+ // orbited keeps the view they made: pulling it back to the board's centre
601
+ // would undo their own gesture.
602
+ function refitOverheadCamera() {
603
+ if (!cameraRigged || cameraState.mode !== "overhead" || overheadOrbited) return;
604
+ var rig = cameraRigFor("overhead", null, GRID_SIZE, cameraView());
605
+ if (rig) tweenCameraToRig(rig, performance.now());
459
606
  }
460
607
 
461
608
  function pointFromEvent(evt) {
@@ -466,11 +613,46 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
466
613
  };
467
614
  }
468
615
 
616
+ // A press starts a drag rather than resolving a cell. Which of the two it
617
+ // turns out to be is only known when the button comes up, and until then
618
+ // pointermove turns the camera.
619
+ function onPointerDown(evt) {
620
+ if (!scene || !camera3 || !groundMesh) return;
621
+ drag = { pointerId: evt.pointerId, startX: evt.clientX, startY: evt.clientY, lastX: evt.clientX, lastY: evt.clientY, travelled: 0 };
622
+ }
623
+
624
+ function onPointerMove(evt) {
625
+ if (!drag || (drag.pointerId != null && evt.pointerId !== drag.pointerId)) return;
626
+ var dx = evt.clientX - drag.lastX;
627
+ var dy = evt.clientY - drag.lastY;
628
+ drag.lastX = evt.clientX;
629
+ drag.lastY = evt.clientY;
630
+ drag.travelled += Math.abs(dx) + Math.abs(dy);
631
+ // Overhead is OrbitControls' own drag, and two things turning one camera
632
+ // fight each other.
633
+ if (cameraState.mode === "overhead") { overheadOrbited = true; return; }
634
+ lookOffset.yaw -= dx * LOOK_RADIANS_PER_PIXEL;
635
+ lookOffset.pitch -= dy * LOOK_RADIANS_PER_PIXEL;
636
+ }
637
+
638
+ function onPointerUp(evt) {
639
+ if (!drag || (drag.pointerId != null && evt.pointerId !== drag.pointerId)) return;
640
+ var straightLine = Math.abs(evt.clientX - drag.startX) + Math.abs(evt.clientY - drag.startY);
641
+ var wasDrag = drag.travelled > CLICK_SLOP_PX || straightLine > CLICK_SLOP_PX;
642
+ drag = null;
643
+ if (wasDrag) return;
644
+ resolveCellClick(evt);
645
+ }
646
+
647
+ function onPointerCancel() { drag = null; }
648
+
469
649
  // The raycast target is the ground mesh ALONE — never scene.children — so
470
650
  // a click a hair off a prop or an agent still resolves to the cell under
471
651
  // the cursor rather than hitting whatever mesh happens to sit in front.
472
- function onPointerDown(evt) {
652
+ function resolveCellClick(evt) {
473
653
  if (!scene || !camera3 || !groundMesh) return;
654
+ var rect = canvas.getBoundingClientRect();
655
+ if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) return;
474
656
  var ndc = pointFromEvent(evt);
475
657
  raycaster.setFromCamera(ndc, camera3);
476
658
  var hits = raycaster.intersectObject(groundMesh, false);
@@ -877,15 +1059,40 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
877
1059
  return entry ? { cell: entry.cell, facing: entry.facing } : null;
878
1060
  }
879
1061
 
1062
+ // Both tweens start from where the camera ACTUALLY is, never from the old
1063
+ // tween's own endpoint: the visitor may have dragged or orbited it somewhere
1064
+ // else since, and a tween seeded off the rig alone teleports past that.
1065
+ function tweenCameraToRig(rig, now) {
1066
+ var from = camera3
1067
+ ? { x: camera3.position.x, y: camera3.position.y, z: camera3.position.z }
1068
+ : rig.position;
1069
+ cameraTween = startTween(from, rig.position, now, tweenDurationMs);
1070
+ lookAtTween = startTween(lookTarget || rig.lookAt, rig.lookAt, now, tweenDurationMs);
1071
+ }
1072
+
1073
+ // Re-rigs the camera ONLY when the mode or the followed agent actually
1074
+ // changed. Every tick calls this with the state it already had, and re-rigging
1075
+ // on those calls is what snapped a dragged view back a fifth of a second
1076
+ // after every drag, and what undid an overhead orbit a frame after it was
1077
+ // made. Tracking the agent as it moves is refreshCameraTween's job below,
1078
+ // which leaves the visitor's own look alone.
880
1079
  function setCamera(state) {
881
- cameraState = { mode: (state && state.mode) || "overhead", selectedId: (state && state.selectedId) || null };
1080
+ var next = { mode: (state && state.mode) || "overhead", selectedId: (state && state.selectedId) || null };
1081
+ var changed = !cameraRigged || next.mode !== cameraState.mode || next.selectedId !== cameraState.selectedId;
1082
+ cameraState = next;
1083
+ if (orbitControls) orbitControls.enabled = cameraState.mode === "overhead";
1084
+ if (!changed) return;
1085
+ // A new mode or a new agent is a request for a fresh view, so the drag's
1086
+ // own turn goes with the old one.
1087
+ lookOffset.yaw = 0;
1088
+ lookOffset.pitch = 0;
1089
+ overheadOrbited = false;
882
1090
  var agent = cameraState.selectedId ? agentSnapshotFor(cameraState.selectedId) : null;
883
- var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE) || cameraRigFor("overhead", null, GRID_SIZE);
1091
+ var view = cameraView();
1092
+ var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE, view) || cameraRigFor("overhead", null, GRID_SIZE, view);
884
1093
  if (!rig) return;
885
- var now = performance.now();
886
- cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
887
- lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
888
- if (orbitControls) orbitControls.enabled = cameraState.mode === "overhead";
1094
+ cameraRigged = true;
1095
+ tweenCameraToRig(rig, performance.now());
889
1096
  }
890
1097
 
891
1098
  // Re-tween the camera toward its own rig after a tick lands, in case the
@@ -895,10 +1102,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
895
1102
  if (cameraState.mode === "overhead") return;
896
1103
  if (!cameraState.selectedId) return;
897
1104
  var agent = agentSnapshotFor(cameraState.selectedId);
898
- var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE);
1105
+ var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE, cameraView());
899
1106
  if (!rig) return;
900
- cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
901
- lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
1107
+ tweenCameraToRig(rig, now);
902
1108
  }
903
1109
 
904
1110
  // ---- boot / tick / render loop --------------------------------------------
@@ -928,6 +1134,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
928
1134
  removalLog = [];
929
1135
  manifestByKind = buildManifestByKind(input && input.assetManifest);
930
1136
  await placeProps((input && input.propPlacements) || []);
1137
+ cameraRigged = false;
1138
+ lookTarget = null;
1139
+ drag = null;
931
1140
  setCamera({ mode: "overhead", selectedId: null });
932
1141
  booted = true;
933
1142
  }
@@ -973,8 +1182,29 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
973
1182
  if (flashLeft <= 0) clearFlash();
974
1183
  else flashMesh.material.opacity = 0.75 * (flashLeft / FLASH_MS);
975
1184
  }
976
- if (cameraTween) { var cp = tweenStep(cameraTween, ts); camera3.position.set(cp.x, cp.y, cp.z); }
977
- if (lookAtTween) { var lp = tweenStep(lookAtTween, ts); camera3.lookAt(lp.x, lp.y, lp.z); }
1185
+ // A finished tween is dropped rather than re-applied every frame. Left in
1186
+ // place it pinned the camera to its rig point on every single frame, which
1187
+ // is what stopped an overhead orbit from surviving the frame it was made in.
1188
+ if (cameraTween) {
1189
+ var cp = tweenStep(cameraTween, ts);
1190
+ camera3.position.set(cp.x, cp.y, cp.z);
1191
+ if (cp.done) cameraTween = null;
1192
+ }
1193
+ if (lookAtTween) {
1194
+ var lp = tweenStep(lookAtTween, ts);
1195
+ lookTarget = { x: lp.x, y: lp.y, z: lp.z };
1196
+ if (lp.done) lookAtTween = null;
1197
+ }
1198
+ // Follow and pov aim every frame, tween or no tween, because the visitor's
1199
+ // own drag has to keep showing between ticks. Overhead hands the camera to
1200
+ // OrbitControls once its own tween is spent.
1201
+ if (lookTarget && cameraState.mode !== "overhead") {
1202
+ var aimed = lookTargetWithOffset(camera3.position, lookTarget, lookOffset);
1203
+ lookOffset.pitch = aimed.pitchOffset;
1204
+ camera3.lookAt(aimed.x, aimed.y, aimed.z);
1205
+ } else if (lookTarget && lookAtTween) {
1206
+ camera3.lookAt(lookTarget.x, lookTarget.y, lookTarget.z);
1207
+ }
978
1208
  if (orbitControls && orbitControls.enabled) orbitControls.update();
979
1209
  renderer.render(scene, camera3);
980
1210
  }
@@ -1028,6 +1258,23 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
1028
1258
  for (var i = 0; i < removalLog.length; i += 1) if (removalLog[i].id === id) out.push(removalLog[i]);
1029
1259
  return out;
1030
1260
  },
1261
+ // How much of the canvas the ground plane actually covers, width and
1262
+ // height, each 0..1 — an e2e assertion's read, so a framing check measures
1263
+ // the projection the visitor sees rather than re-deriving the rig from the
1264
+ // numbers that built it. The four corners are projected through the live
1265
+ // camera, so a cropped board reads over 1 on the axis it runs off.
1266
+ boardFrameFraction: function () {
1267
+ if (!camera3 || !groundMesh) return null;
1268
+ var half = (GRID_SIZE * CELL_SIZE) / 2;
1269
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1270
+ var corners = [[-half, -half], [-half, half], [half, -half], [half, half]];
1271
+ for (var i = 0; i < corners.length; i += 1) {
1272
+ var ndc = new THREE.Vector3(corners[i][0], 0, corners[i][1]).project(camera3);
1273
+ minX = Math.min(minX, ndc.x); maxX = Math.max(maxX, ndc.x);
1274
+ minY = Math.min(minY, ndc.y); maxY = Math.max(maxY, ndc.y);
1275
+ }
1276
+ return { width: (maxX - minX) / 2, height: (maxY - minY) / 2 };
1277
+ },
1031
1278
  ready: function () { return booted; },
1032
1279
  };
1033
1280
  })();`;
@@ -3,9 +3,9 @@
3
3
  // command, the addressed teach-frame (extended to the food channel spider-fly
4
4
  // has no equivalent of), the bare "tick" command, the player's own food-
5
5
  // placement verb, and the belief and orientation asides. The fifth lane on
6
- // the shared plan slot, shaped exactly like spider-fly-turn.mjs (PLAN_MUD_
7
- // MUDIII.md, "The chat lane") with vocabulary from predator-prey.mjs's own
8
- // MUDIII_ROLES: fox hunts goblin, goblins forage crumbs and morsels.
6
+ // the shared plan slot, shaped exactly like spider-fly-turn.mjs, with
7
+ // vocabulary from predator-prey.mjs's own MUDIII_ROLES: fox hunts goblin,
8
+ // goblins forage crumbs and morsels.
9
9
  //
10
10
  // This module never plans a move or runs the ecology pass itself — every bit
11
11
  // of game logic (fold, pathfinding, belief, ecology, food placement) lives in
@@ -21,7 +21,7 @@ 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";
24
+ import { snapshotSubject, parseSnapshotSubject } from "./adventure.mjs";
25
25
  import { correctMisspellings, QUESTION_LEAD_RE } from "../domain/interpret/normalize.mjs";
26
26
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
27
27
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
@@ -424,6 +424,63 @@ export function believedFactSentence(id, believedCell) {
424
424
  return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
425
425
  }
426
426
 
427
+ // The mark both visitor-driven write paths stamp on a placement row: the teach
428
+ // lane's `world:<name>:taught:turnK`, and placeFood's own, which builds the
429
+ // same tag. The board's own ticks write the bare world tag with no such mark.
430
+ const TAUGHT_PROVENANCE_MARK = ":taught:";
431
+
432
+ /** Every subject standing where a VISITOR put it — taught into place by a
433
+ * sentence, or dropped there by the food verb. Both write the same
434
+ * `:taught:turnK` provenance, and the board's own ticks write none of it.
435
+ *
436
+ * Reads the row that WON, ranked the way foldTownSquareState ranks a
437
+ * placement, so a crumb a visitor moved and the board has since moved on from
438
+ * no longer counts. `epoch` is the fold's own, which unstamped rows (a
439
+ * hand-built fixture, a seed row) are ranked at. Pure. */
440
+ export function taughtPlacementSubjects(factRows, epoch = 0) {
441
+ const winner = new Map(); // subject -> { epoch, turn, taught }
442
+ for (const row of factRows || []) {
443
+ if (row.predicate !== PLACEMENT_PREDICATE) continue;
444
+ const snap = parseSnapshotSubject(row.subject);
445
+ const base = snap ? snap.base : row.subject;
446
+ const rowEpoch = snap ? snap.epoch : epoch;
447
+ const turn = snap ? snap.turn : 0;
448
+ const prior = winner.get(base);
449
+ if (prior && !(rowEpoch > prior.epoch || (rowEpoch === prior.epoch && turn >= prior.turn))) continue;
450
+ winner.set(base, { epoch: rowEpoch, turn, taught: String(row.provenance || "").includes(TAUGHT_PROVENANCE_MARK) });
451
+ }
452
+ const taught = new Set();
453
+ for (const [subject, entry] of winner) if (entry.taught) taught.add(subject);
454
+ return taught;
455
+ }
456
+
457
+ /** One observer's whole belief snapshot as a sentence.
458
+ *
459
+ * Anything the observer can place is named: seen, or told. So is anything the
460
+ * visitor put where it stands, seen or not, because the teach lane exists so
461
+ * a claim about ONE individual lands where a visitor can watch it, and an
462
+ * individual that dissolved into a count would take that with it.
463
+ *
464
+ * Everything else unobserved is counted rather than listed. A square at its
465
+ * food cap otherwise answers a question about what a goblin can see with a
466
+ * row of crumbs it cannot, and the list grows with the cap.
467
+ *
468
+ * `beliefEntries` is `Object.entries(beliefSnapshotFor(...))` and
469
+ * `taughtSubjects` a Set from taughtPlacementSubjects. Pure. */
470
+ export function beliefLineFor(observerId, beliefEntries, taughtSubjects) {
471
+ const taught = taughtSubjects || new Set();
472
+ const named = [];
473
+ let unobserved = 0;
474
+ for (const [id, cell] of beliefEntries || []) {
475
+ if (cell || taught.has(id)) named.push(believedFactSentence(id, cell));
476
+ else unobserved += 1;
477
+ }
478
+ if (!named.length && !unobserved) return `${observerId} is alone on the board — nothing else to see.`;
479
+ const rest = unobserved === 1 ? "1 other has not been observed." : `${unobserved} others have not been observed.`;
480
+ if (!named.length) return `${observerId} sees nothing yet. ${rest}`;
481
+ return `${observerId} sees: ${named.join(" ")}${unobserved ? ` ${rest}` : ""}`;
482
+ }
483
+
427
484
  /** "what does the goblin see?" / "what does the fox see?" rendered as plain
428
485
  * text: the same beliefSnapshotFor read predator-prey.mjs's own tick loop
429
486
  * uses, over the CURRENT board state — read-only, no tick runs. Candidates
@@ -431,7 +488,9 @@ export function believedFactSentence(id, believedCell) {
431
488
  * goblin's own forage target), exactly the beliefCandidates set
432
489
  * runTownSquareTick itself builds. toldFacts is empty — a told position only
433
490
  * ever arrives fresh alongside a tick, so there is none standing between
434
- * ticks to read back here. */
491
+ * ticks to read back here. beliefLineFor turns the snapshot into the
492
+ * sentence, naming what the observer can place and what a visitor put where
493
+ * it stands, and counting the rest. */
435
494
  async function mudiiiBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_CONFIG }) {
436
495
  const kind = match[1].toLowerCase();
437
496
  const num = match[2];
@@ -451,10 +510,7 @@ async function mudiiiBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_
451
510
  ? gameConfig?.mudiii?.predatorVisionRadius
452
511
  : gameConfig?.mudiii?.preyVisionRadius;
453
512
  const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
454
- const entries = Object.entries(belief);
455
- const text = entries.length
456
- ? `${observerId} sees: ${entries.map(([id, cell]) => believedFactSentence(id, cell)).join(" ")}`
457
- : `${observerId} is alone on the board — nothing else to see.`;
513
+ const text = beliefLineFor(observerId, Object.entries(belief), taughtPlacementSubjects(rows, state.epoch));
458
514
  return {
459
515
  text,
460
516
  lane: "game-inform",