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

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 (46) hide show
  1. package/README.md +78 -19
  2. package/bin/tmct.mjs +63 -2
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +23 -0
  5. package/src/domain/ask-vocab.mjs +19 -0
  6. package/src/domain/ask.mjs +10 -5
  7. package/src/domain/codegraph.mjs +23 -9
  8. package/src/domain/game-config.mjs +12 -0
  9. package/src/domain/interpret/strategies/keywords.mjs +30 -1
  10. package/src/domain/memory/capability.mjs +15 -11
  11. package/src/domain/router/drive.mjs +36 -17
  12. package/src/domain/router/resolver.mjs +63 -17
  13. package/src/domain/spider-fly-world.mjs +2 -2
  14. package/src/domain/sprite-templates.mjs +19 -7
  15. package/src/domain/syllogise.mjs +16 -6
  16. package/src/domain/town-square-world.mjs +1 -1
  17. package/src/services/adventure-viz.mjs +5 -2
  18. package/src/services/adventure.mjs +8 -1
  19. package/src/services/chat-page-viz.mjs +123 -25
  20. package/src/services/chat-session.mjs +60 -10
  21. package/src/services/chat.mjs +328 -36
  22. package/src/services/code-explorer-viz.mjs +3 -2
  23. package/src/services/extract-facts.mjs +47 -7
  24. package/src/services/ingest-viz.mjs +113 -29
  25. package/src/services/ledger-viz.mjs +9 -4
  26. package/src/services/memory-panel-viz.mjs +44 -0
  27. package/src/services/mud-viz.mjs +21 -3
  28. package/src/services/mudiii-scene.mjs +407 -36
  29. package/src/services/mudiii-turn.mjs +65 -9
  30. package/src/services/mudiii-viz.mjs +810 -157
  31. package/src/services/p2p-room.mjs +1 -1
  32. package/src/services/plan-viz.mjs +26 -4
  33. package/src/services/predator-prey.mjs +141 -37
  34. package/src/services/research-viz.mjs +17 -23
  35. package/src/services/spider-fly-turn.mjs +7 -1
  36. package/src/services/spider-fly-viz.mjs +13 -5
  37. package/src/services/sprite-catalog-viz.mjs +3 -2
  38. package/src/services/viz-theme.mjs +20 -0
  39. package/src/services/viz-ticker.mjs +15 -2
  40. package/src/surfaces/http/server-http.mjs +90 -13
  41. package/src/surfaces/web/memory-ask-browser.bundle.js +125 -125
  42. package/src/surfaces/web/mud-browser-entry.mjs +33 -1
  43. package/src/surfaces/web/mudiii-browser-entry.mjs +70 -34
  44. package/src/surfaces/web/tmct-surface.mjs +18 -6
  45. package/src/tools/handlers/tmct-ask.mjs +15 -2
  46. 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)`
@@ -42,12 +39,10 @@
42
39
  // agent or item, never the mid-lerp position. This is what an e2e
43
40
  // assertion reads, and what a later screenshot ready-check reads too
44
41
  // (see test-e2e/pages-mudiii.test.mjs's own header).
42
+ // .flashCell(cellId) / .showRoute(cells) / .clearRoute() — the clicked
43
+ // cell and the route the followed agent is walking to it, drawn along
44
+ // the cells the world's own exit search returned.
45
45
  // .ready() — whether boot() has finished at least once.
46
- // These three calls are not yet wired into mudiii-viz.mjs's own
47
- // boot()/applyTickResult()/camera handlers — that file is owned by the viz
48
- // track, not this one; the coordinator's own report names the exact call
49
- // sites needed, mirroring how window.mudiiiHandleSceneClick was already
50
- // added for the reverse direction.
51
46
  //
52
47
  // Reused from mudiii-viz.mjs rather than re-derived, off its own frozen
53
48
  // exports: `roleOfAgentId`, `cellToWorld`, `cellFromGroundPoint`,
@@ -70,6 +65,12 @@ import { prefersReducedMotion } from "./viz-ticker.mjs";
70
65
  * own `transition: left .25s ease` becomes in a requestAnimationFrame loop. */
71
66
  export const TWEEN_DURATION_MS = 250;
72
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
+
73
74
  // ---------------------------------------------------------------------------
74
75
  // Pure geometry/tween helpers — unit-tested directly in Node, then spliced
75
76
  // via `.toString()` into the standalone browser IIFE below, the same
@@ -102,8 +103,11 @@ export function tweenStep(tween, now) {
102
103
  return { ...point, t, done: false };
103
104
  }
104
105
 
105
- /** A fresh tween from `from` to `to`, starting at `now`. Pure. */
106
- 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) {
107
111
  return { from, to, startedAt: now, durationMs };
108
112
  }
109
113
 
@@ -113,7 +117,7 @@ export function startTween(from, to, now, durationMs = TWEEN_DURATION_MS) {
113
117
  * tweening); `to` is the new destination point. Pure — `tweenStep` is
114
118
  * called on the OLD tween at `now` to read the current position before
115
119
  * building the new one. */
116
- export function reseedTween(existingTween, to, now, durationMs = TWEEN_DURATION_MS) {
120
+ export function reseedTween(existingTween, to, now, durationMs = 250) {
117
121
  const current = existingTween ? tweenStep(existingTween, now) : null;
118
122
  const from = current ? Object.fromEntries(Object.keys(to).map((k) => [k, current[k]])) : to;
119
123
  return startTween(from, to, now, durationMs);
@@ -130,6 +134,69 @@ export function chebyshevDistanceBetweenCells(a, b) {
130
134
  return Math.max(Math.abs(Number(ma[1]) - Number(mb[1])), Math.abs(Number(ma[2]) - Number(mb[2])));
131
135
  }
132
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
+
133
200
  /** A facing word to a Y rotation in radians. Assumes a model's own local
134
201
  * forward is +Z (true for fox and goblin, the two rigs in the current
135
202
  * manifest, checked by walking each GLB's own bind-pose bone chain) so a
@@ -144,7 +211,7 @@ export function yawForFacing(facing) {
144
211
  /** Whether `agentId` (predator or prey) currently believes an agent of the
145
212
  * opposing role — the one belief-derived bit that separates a predator's
146
213
  * "chase" rung from "wander", and a prey's "evade" rung from "forage"/
147
- * "wander" (PLAN_MUD_MUDIII.md's own priority chains). Read entirely off
214
+ * "wander". Read entirely off
148
215
  * the tick payload's own `role`/`belief` fields — no rung name travels on
149
216
  * the wire, so this reconstructs which rung applied rather than trusting a
150
217
  * label. Pure. */
@@ -319,6 +386,8 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
319
386
  var startTween = ${startTween.toString()};
320
387
  var reseedTween = ${reseedTween.toString()};
321
388
  var chebyshevDistanceBetweenCells = ${chebyshevDistanceBetweenCells.toString()};
389
+ var lookTargetWithOffset = ${lookTargetWithOffset.toString()};
390
+ var skyGradientStops = ${skyGradientStops.toString()};
322
391
  var yawForFacing = ${yawForFacing.toString()};
323
392
  var threatEngagedFor = ${threatEngagedFor.toString()};
324
393
  var movementRungFor = ${movementRungFor.toString()};
@@ -366,8 +435,38 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
366
435
  // removal (source "ecology") apart from applyTick's own diff no-op
367
436
  // (source "diff") without guessing from a vanished mesh alone.
368
437
  var removalLog = [];
438
+ var routeLine = null, routeCells = [];
439
+ var flashMesh = null, flashUntil = 0;
440
+ var FLASH_MS = 600;
441
+ // The floor a one-shot flourish is held for when its own clip is shorter,
442
+ // long enough that a bite reads as a bite at the deck's fastest tick.
443
+ var ONE_SHOT_HOLD_MIN_MS = 450;
444
+ var tickRungs = {};
369
445
  var cameraState = { mode: "overhead", selectedId: null };
370
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;
371
470
  var lastFrameTs = null;
372
471
  var booted = false;
373
472
 
@@ -404,10 +503,31 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
404
503
  return true;
405
504
  }
406
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
+
407
527
  function setUpScene() {
408
528
  scene = new THREE.Scene();
409
- scene.background = new THREE.Color(0x10161b);
410
- 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);
411
531
  camera3.position.set(0, 8, 8);
412
532
  renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
413
533
  scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.2));
@@ -423,6 +543,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
423
543
  orbitControls.target.set(0, 0, 0);
424
544
 
425
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);
426
555
  window.addEventListener("resize", onResize);
427
556
  watchFoodPill();
428
557
  onResize();
@@ -445,12 +574,35 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
445
574
  scene.add(grid);
446
575
  }
447
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
+
448
588
  function onResize() {
449
589
  if (!renderer || !camera3) return;
450
590
  var w = canvas.clientWidth || 640, h = canvas.clientHeight || 360;
451
591
  renderer.setSize(w, h, false);
452
- camera3.aspect = w / Math.max(1, h);
592
+ camera3.aspect = canvasAspect();
453
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());
454
606
  }
455
607
 
456
608
  function pointFromEvent(evt) {
@@ -461,11 +613,46 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
461
613
  };
462
614
  }
463
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
+
464
649
  // The raycast target is the ground mesh ALONE — never scene.children — so
465
650
  // a click a hair off a prop or an agent still resolves to the cell under
466
651
  // the cursor rather than hitting whatever mesh happens to sit in front.
467
- function onPointerDown(evt) {
652
+ function resolveCellClick(evt) {
468
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;
469
656
  var ndc = pointFromEvent(evt);
470
657
  raycaster.setFromCamera(ndc, camera3);
471
658
  var hits = raycaster.intersectObject(groundMesh, false);
@@ -557,10 +744,8 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
557
744
  }
558
745
  }
559
746
 
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. ---------------------------------------------------------
747
+ // ---- items: crumbs and morsels, both the committed hay bale, each at
748
+ // whatever targetHeight its own data/mudiii-assets.json row names. ----------
564
749
  function itemAssetKeyFor(kind) { return kind === "morsel" ? "food-morsel" : "food-crumb"; }
565
750
 
566
751
  function animateScaleTo(object3D, target, now) {
@@ -635,21 +820,56 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
635
820
  // correctly clone a SkinnedMesh, and at this roster size — a handful of
636
821
  // foxes and goblins — the extra memory of a separate load per agent is
637
822
  // trivial next to that whole class of bug). ---------------------------------
823
+ // The id, drawn to a canvas and hung above the agent as a sprite. Parenting
824
+ // it to the agent's own group means it rides the movement tween with no
825
+ // per-frame bookkeeping, and a sprite always faces the camera, so there is
826
+ // no facing maths either. sizeAttenuation off holds it at a constant size on
827
+ // screen — an id that shrinks to nothing as the camera pulls back is worse
828
+ // than no id at all — which is why the scale below is in screen fractions
829
+ // rather than world units.
830
+ var LABEL_SCREEN_WIDTH = 0.13;
831
+ function makeAgentLabel(id, height) {
832
+ var canvas = document.createElement("canvas");
833
+ canvas.width = 256;
834
+ canvas.height = 64;
835
+ var ctx = canvas.getContext("2d");
836
+ ctx.font = "bold 38px ui-monospace, SFMono-Regular, Menlo, monospace";
837
+ ctx.textAlign = "center";
838
+ ctx.textBaseline = "middle";
839
+ ctx.lineWidth = 8;
840
+ ctx.lineJoin = "round";
841
+ ctx.strokeStyle = "rgba(20,16,10,.85)";
842
+ ctx.strokeText(id, 128, 34);
843
+ ctx.fillStyle = "#F3ECDD";
844
+ ctx.fillText(id, 128, 34);
845
+ var material = new THREE.SpriteMaterial({
846
+ map: new THREE.CanvasTexture(canvas), transparent: true, sizeAttenuation: false, depthWrite: false,
847
+ });
848
+ var sprite = new THREE.Sprite(material);
849
+ sprite.scale.set(LABEL_SCREEN_WIDTH, LABEL_SCREEN_WIDTH / 4, 1);
850
+ sprite.position.y = height + 0.3;
851
+ sprite.name = "label-" + id;
852
+ return sprite;
853
+ }
854
+
638
855
  function ensureAgent(id, agent) {
639
856
  if (agentGroups[id]) return agentGroups[id];
640
857
  var kind = roleOfAgentId(id);
641
858
  var entry = {
642
859
  group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
643
860
  kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null, oneShotAction: null,
861
+ oneShotUntil: 0, model: null,
644
862
  };
645
863
  entry.group.visible = false;
646
864
  scene.add(entry.group);
647
865
  agentGroups[id] = entry;
648
866
  var asset = manifestByKind[kind];
867
+ entry.group.add(makeAgentLabel(id, asset ? asset.targetHeight : 1));
649
868
  if (asset) {
650
869
  loadGlbRaw(modelUrlFor(asset.destPath)).then(function (gltf) {
651
870
  normalizeToHeight(gltf.scene, asset.targetHeight);
652
871
  entry.group.add(gltf.scene);
872
+ entry.model = gltf.scene;
653
873
  entry.group.visible = true;
654
874
  entry.clipMap = asset.clips || {};
655
875
  entry.mixer = new THREE.AnimationMixer(gltf.scene);
@@ -671,6 +891,12 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
671
891
  // keep blending into every clip after it, forever). Fade it out before
672
892
  // any of the guards below can skip the rest of this call.
673
893
  if (entry.oneShotAction) {
894
+ // A flourish holds for its own clip length, floored, before anything may
895
+ // fade it out. At the deck's 220ms default the next tick otherwise
896
+ // landed during the wind-up and a bite read as a twitch. This holds the
897
+ // ANIMATION only: the tick that arrived has already moved the agent, so
898
+ // the simulation never waits on a flourish.
899
+ if (performance.now() < entry.oneShotUntil) return;
674
900
  entry.oneShotAction.fadeOut(0.15);
675
901
  entry.oneShotAction = null;
676
902
  }
@@ -702,6 +928,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
702
928
  action.fadeIn(0.15).play();
703
929
  if (prev) prev.fadeOut(0.15);
704
930
  entry.oneShotAction = action;
931
+ var clip = typeof action.getClip === "function" ? action.getClip() : null;
932
+ entry.oneShotUntil = performance.now()
933
+ + Math.max(ONE_SHOT_HOLD_MIN_MS, clip && clip.duration ? clip.duration * 1000 : 0);
705
934
  entry.currentClip = null;
706
935
  }
707
936
 
@@ -731,7 +960,10 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
731
960
  }
732
961
  if (entry.clipMap) {
733
962
  var moving = singleHop;
734
- playClip(entry, clipForAction(agent.role, currentActionFor(id, lastAgentsById, moving), entry.clipMap));
963
+ // A hand-driven step is a walk, whatever the agent believes: it did not
964
+ // choose to chase or flee, the visitor chose for it.
965
+ var action = moving && tickRungs[id] === "driven" ? "driven" : currentActionFor(id, lastAgentsById, moving);
966
+ playClip(entry, clipForAction(agent.role, action, entry.clipMap));
735
967
  }
736
968
  }
737
969
 
@@ -766,21 +998,101 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
766
998
  }
767
999
  }
768
1000
 
1001
+ // ---- the clicked cell, and the route to it --------------------------------
1002
+ // The line follows the cells the world's own exit search returned, never a
1003
+ // straight segment from agent to target: a straight one cuts through
1004
+ // buildings and promises a walk the board would refuse.
1005
+ function clearRoute() {
1006
+ routeCells = [];
1007
+ if (!routeLine) return;
1008
+ if (routeLine.parent) routeLine.parent.remove(routeLine);
1009
+ routeLine.geometry.dispose();
1010
+ routeLine.material.dispose();
1011
+ routeLine = null;
1012
+ }
1013
+
1014
+ function showRoute(cells) {
1015
+ clearRoute();
1016
+ if (!scene || !cells || cells.length < 2) return;
1017
+ var points = [];
1018
+ for (var i = 0; i < cells.length; i += 1) {
1019
+ var world = cellToWorld(cells[i], GRID_SIZE, CELL_SIZE);
1020
+ if (!world) return;
1021
+ points.push(new THREE.Vector3(world.x, 0.09, world.z));
1022
+ }
1023
+ routeLine = new THREE.Line(
1024
+ new THREE.BufferGeometry().setFromPoints(points),
1025
+ new THREE.LineBasicMaterial({ color: 0xd98a2b }),
1026
+ );
1027
+ routeLine.name = "route";
1028
+ scene.add(routeLine);
1029
+ routeCells = cells.slice();
1030
+ }
1031
+
1032
+ function clearFlash() {
1033
+ if (!flashMesh) return;
1034
+ if (flashMesh.parent) flashMesh.parent.remove(flashMesh);
1035
+ flashMesh.geometry.dispose();
1036
+ flashMesh.material.dispose();
1037
+ flashMesh = null;
1038
+ }
1039
+
1040
+ function flashCell(cell) {
1041
+ if (!scene) return;
1042
+ var world = cellToWorld(cell, GRID_SIZE, CELL_SIZE);
1043
+ if (!world) return;
1044
+ clearFlash();
1045
+ flashMesh = new THREE.Mesh(
1046
+ new THREE.PlaneGeometry(CELL_SIZE, CELL_SIZE),
1047
+ new THREE.MeshBasicMaterial({ color: 0xd98a2b, transparent: true, opacity: 0.75 }),
1048
+ );
1049
+ flashMesh.name = "cell-flash";
1050
+ flashMesh.rotation.x = -Math.PI / 2;
1051
+ flashMesh.position.set(world.x, 0.03, world.z);
1052
+ scene.add(flashMesh);
1053
+ flashUntil = performance.now() + FLASH_MS;
1054
+ }
1055
+
769
1056
  // ---- camera ---------------------------------------------------------------
770
1057
  function agentSnapshotFor(id) {
771
1058
  var entry = agentGroups[id];
772
1059
  return entry ? { cell: entry.cell, facing: entry.facing } : null;
773
1060
  }
774
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.
775
1079
  function setCamera(state) {
776
- 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;
777
1090
  var agent = cameraState.selectedId ? agentSnapshotFor(cameraState.selectedId) : null;
778
- 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);
779
1093
  if (!rig) return;
780
- var now = performance.now();
781
- cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
782
- lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
783
- if (orbitControls) orbitControls.enabled = cameraState.mode === "overhead";
1094
+ cameraRigged = true;
1095
+ tweenCameraToRig(rig, performance.now());
784
1096
  }
785
1097
 
786
1098
  // Re-tween the camera toward its own rig after a tick lands, in case the
@@ -790,10 +1102,9 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
790
1102
  if (cameraState.mode === "overhead") return;
791
1103
  if (!cameraState.selectedId) return;
792
1104
  var agent = agentSnapshotFor(cameraState.selectedId);
793
- var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE);
1105
+ var rig = cameraRigFor(cameraState.mode, agent, GRID_SIZE, cameraView());
794
1106
  if (!rig) return;
795
- cameraTween = reseedTween(cameraTween, rig.position, now, tweenDurationMs);
796
- lookAtTween = reseedTween(lookAtTween, rig.lookAt, now, tweenDurationMs);
1107
+ tweenCameraToRig(rig, now);
797
1108
  }
798
1109
 
799
1110
  // ---- boot / tick / render loop --------------------------------------------
@@ -817,9 +1128,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
817
1128
  itemMeshes = {};
818
1129
  lastAgentsById = {};
819
1130
  lastItemsById = {};
1131
+ tickRungs = {};
1132
+ clearRoute();
1133
+ clearFlash();
820
1134
  removalLog = [];
821
1135
  manifestByKind = buildManifestByKind(input && input.assetManifest);
822
1136
  await placeProps((input && input.propPlacements) || []);
1137
+ cameraRigged = false;
1138
+ lookTarget = null;
1139
+ drag = null;
823
1140
  setCamera({ mode: "overhead", selectedId: null });
824
1141
  booted = true;
825
1142
  }
@@ -829,6 +1146,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
829
1146
  var now = performance.now();
830
1147
  var agents = (tick && tick.agents) || {};
831
1148
  var items = (tick && tick.items) || {};
1149
+ tickRungs = (tick && tick.rungs) || {};
832
1150
  for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
833
1151
  for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
834
1152
  // Ecology first: an eaten agent/item is already gone from this tick's own
@@ -859,8 +1177,34 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
859
1177
  }
860
1178
  if (entry.mixer) entry.mixer.update(deltaSec);
861
1179
  }
862
- if (cameraTween) { var cp = tweenStep(cameraTween, ts); camera3.position.set(cp.x, cp.y, cp.z); }
863
- if (lookAtTween) { var lp = tweenStep(lookAtTween, ts); camera3.lookAt(lp.x, lp.y, lp.z); }
1180
+ if (flashMesh) {
1181
+ var flashLeft = flashUntil - performance.now();
1182
+ if (flashLeft <= 0) clearFlash();
1183
+ else flashMesh.material.opacity = 0.75 * (flashLeft / FLASH_MS);
1184
+ }
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
+ }
864
1208
  if (orbitControls && orbitControls.enabled) orbitControls.update();
865
1209
  renderer.render(scene, camera3);
866
1210
  }
@@ -869,6 +1213,13 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
869
1213
  boot: boot,
870
1214
  applyTick: applyTick,
871
1215
  setCamera: setCamera,
1216
+ flashCell: flashCell,
1217
+ showRoute: showRoute,
1218
+ clearRoute: clearRoute,
1219
+ // The route currently drawn, cell by cell — an e2e assertion's read, so
1220
+ // it can check the line follows the board's own exits rather than
1221
+ // counting pixels on a software renderer.
1222
+ routeCellsDrawn: function () { return routeCells.slice(); },
872
1223
  cellOf: function (id) {
873
1224
  if (agentGroups[id]) return agentGroups[id].cell;
874
1225
  if (itemMeshes[id]) return itemMeshes[id].cell;
@@ -886,10 +1237,13 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
886
1237
  // manifest's own targetHeight to compare it against — an e2e assertion's
887
1238
  // read, so it goes through the group actually in the scene rather than a
888
1239
  // second, locally invented measurement.
1240
+ // Measured through the loaded MODEL, never the whole group: the group
1241
+ // also carries the id label, whose sprite geometry would widen the box
1242
+ // and report a height nobody rendered.
889
1243
  meshHeightOf: function (id) {
890
1244
  var entry = agentGroups[id];
891
- if (!entry || !entry.group || entry.group.children.length === 0) return null;
892
- var box = new THREE.Box3().setFromObject(entry.group);
1245
+ if (!entry || !entry.model) return null;
1246
+ var box = new THREE.Box3().setFromObject(entry.model);
893
1247
  var size = new THREE.Vector3();
894
1248
  box.getSize(size);
895
1249
  var asset = manifestByKind[entry.kind];
@@ -904,6 +1258,23 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
904
1258
  for (var i = 0; i < removalLog.length; i += 1) if (removalLog[i].id === id) out.push(removalLog[i]);
905
1259
  return out;
906
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
+ },
907
1278
  ready: function () { return booted; },
908
1279
  };
909
1280
  })();`;