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

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 (37) hide show
  1. package/corpus/worlds/manifest.json +9 -9
  2. package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
  3. package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
  4. package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
  5. package/corpus/worlds/src/town-square-chapel.jsonl +1 -1
  6. package/corpus/worlds/src/town-square-market.jsonl +1 -1
  7. package/corpus/worlds/src/town-square.jsonl +1 -1
  8. package/data/mudiii-assets.json +35 -5
  9. package/package.json +1 -1
  10. package/src/adapters/memory/core.mjs +236 -18
  11. package/src/domain/ask-vocab.mjs +1 -1
  12. package/src/domain/ask.mjs +20 -10
  13. package/src/domain/game-config.mjs +10 -1
  14. package/src/domain/interpret/normalize.mjs +4 -0
  15. package/src/domain/memory/retraction.mjs +232 -0
  16. package/src/domain/p2p/sync-filter.mjs +9 -1
  17. package/src/domain/spider-fly-world.mjs +80 -35
  18. package/src/domain/syllogise.mjs +128 -75
  19. package/src/services/adventure-autoplay.mjs +2 -2
  20. package/src/services/adventure-viz.mjs +12 -7
  21. package/src/services/chat.mjs +42 -14
  22. package/src/services/mud-turn.mjs +1 -1
  23. package/src/services/mud-viz.mjs +11 -2
  24. package/src/services/mudiii-scene.mjs +199 -55
  25. package/src/services/mudiii-turn.mjs +82 -1
  26. package/src/services/mudiii-viz.mjs +17 -7
  27. package/src/services/p2p-room.mjs +130 -9
  28. package/src/services/predator-prey.mjs +524 -69
  29. package/src/services/spider-fly-turn.mjs +128 -56
  30. package/src/services/spider-fly-viz.mjs +65 -71
  31. package/src/services/world-teach.mjs +3 -3
  32. package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
  33. package/src/surfaces/web/memory-ask-browser.bundle.js +118 -118
  34. package/src/surfaces/web/mud-browser-entry.mjs +10 -3
  35. package/src/surfaces/web/mudiii-browser-entry.mjs +3 -3
  36. package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
  37. package/src/services/spider-fly.mjs +0 -943
@@ -130,16 +130,14 @@ export function chebyshevDistanceBetweenCells(a, b) {
130
130
  return Math.max(Math.abs(Number(ma[1]) - Number(mb[1])), Math.abs(Number(ma[2]) - Number(mb[2])));
131
131
  }
132
132
 
133
- /** A facing word to a Y rotation in radians, `south` (the world's own
134
- * `defaultFacing`) at zero matches `cameraRigFor`'s own FACING_VECTOR
135
- * convention (`south: {x:0,z:1}`, `north: {x:0,z:-1}`, `east: {x:1,z:0}`,
136
- * `west: {x:-1,z:0}`) so an agent mesh turns to face the same way its own
137
- * follow/pov camera rig would sit behind it. A rig's own forward axis may
138
- * need a per-model offset once real assets are in place; this is the
139
- * world-facing convention, not a guarantee about any one GLB's neutral
140
- * pose. Pure. */
133
+ /** A facing word to a Y rotation in radians. Assumes a model's own local
134
+ * forward is +Z (true for fox and goblin, the two rigs in the current
135
+ * manifest, checked by walking each GLB's own bind-pose bone chain) so a
136
+ * rotated mesh matches `cameraRigFor`'s own FACING_VECTOR table exactly for
137
+ * all four cardinals a model with a -Z neutral pose would need its own
138
+ * offset. Pure. */
141
139
  export function yawForFacing(facing) {
142
- const YAW = { south: 0, north: Math.PI, east: -Math.PI / 2, west: Math.PI / 2 };
140
+ const YAW = { south: 0, north: Math.PI, east: Math.PI / 2, west: -Math.PI / 2 };
143
141
  return YAW[facing] ?? YAW.south;
144
142
  }
145
143
 
@@ -188,12 +186,14 @@ export function currentActionFor(agentId, agentsById, moving) {
188
186
  * so acting on the ecology row too would double it). `kind` is one of
189
187
  * "death" (the rig's own Death clip, then scale to zero) or "consume" (an
190
188
  * eaten item scales to zero, no clip — items are primitive geometry).
191
- * 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. */
192
192
  export function flourishForEcologyEvent(event) {
193
193
  if (!event || !event.type) return null;
194
194
  switch (event.type) {
195
195
  case "eat-agent":
196
- return { kind: "death", targetId: event.prey, cell: event.cell };
196
+ return { kind: "death", targetId: event.prey, cell: event.cell, actorId: event.predator };
197
197
  case "starve":
198
198
  return { kind: "death", targetId: event.agent, cell: event.cell };
199
199
  case "eat-item":
@@ -352,7 +352,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
352
352
 
353
353
  var loadThreeVendor = createThreeVendorLoader();
354
354
  var THREE = null, GLTFLoaderCtor = null, OrbitControlsCtor = null, MeshoptDecoderRef = null;
355
- var gltfLoader = null, loadQueue = createConcurrencyQueue(4), rawLoader = null;
355
+ var gltfLoader = null, loadQueue = createConcurrencyQueue(4);
356
356
  var scene = null, camera3 = null, renderer = null, orbitControls = null, groundMesh = null, raycaster = null;
357
357
  var agentGroups = {};
358
358
  var itemMeshes = {};
@@ -361,21 +361,37 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
361
361
  var manifestByKind = {};
362
362
  var lastAgentsById = {};
363
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 = [];
364
369
  var cameraState = { mode: "overhead", selectedId: null };
365
370
  var cameraTween = null, lookAtTween = null;
366
371
  var lastFrameTs = null;
367
372
  var booted = false;
368
373
 
374
+ // Cache the fetched BYTES, not the parsed scene: one network request per
375
+ // model URL, however many agents share that kind, then a GLTFLoader.parse
376
+ // per caller off the shared ArrayBuffer. Each caller still ends up owning
377
+ // its own object graph, so normalizeToHeight's own guards still hold.
378
+ function fetchGlbBytes(url) {
379
+ return fetch(url).then(function (res) {
380
+ if (!res.ok) throw new Error("model fetch failed with status " + res.status + ": " + url);
381
+ return res.arrayBuffer();
382
+ });
383
+ }
384
+ var glbBytesCache = createCachedLoader(fetchGlbBytes);
385
+
369
386
  function loadGlbRaw(url) {
370
- return loadQueue.run(function () {
371
- return new Promise(function (resolve, reject) {
372
- gltfLoader.load(url, resolve, undefined, reject);
387
+ return glbBytesCache.load(url).then(function (buffer) {
388
+ return loadQueue.run(function () {
389
+ return new Promise(function (resolve, reject) {
390
+ gltfLoader.parse(buffer, THREE.LoaderUtils.extractUrlBase(url), resolve, reject);
391
+ });
373
392
  });
374
393
  });
375
394
  }
376
- var glbCache = null;
377
- function loadGlb(url) { return glbCache.load(url); }
378
-
379
395
  async function ensureThree() {
380
396
  if (THREE) return true;
381
397
  var result = await loadThreeVendor();
@@ -384,7 +400,6 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
384
400
  OrbitControlsCtor = result.OrbitControls; MeshoptDecoderRef = result.MeshoptDecoder;
385
401
  gltfLoader = new GLTFLoaderCtor();
386
402
  gltfLoader.setMeshoptDecoder(MeshoptDecoderRef);
387
- glbCache = createCachedLoader(loadGlbRaw);
388
403
  setUpScene();
389
404
  return true;
390
405
  }
@@ -473,24 +488,51 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
473
488
  }
474
489
 
475
490
  // ---- props ---------------------------------------------------------------
491
+ // Box3.setFromObject reads through a parent's world matrix, so measuring a
492
+ // parented object reads whatever that parent's spawn flourish is doing in
493
+ // that frame and applies a scale that depends on the frame. The guards make
494
+ // both misuses fail loudly rather than render a wrong size silently. Keep
495
+ // no backtick in this block: the function's source sits inside the module's
496
+ // own template literal.
476
497
  function normalizeToHeight(object3D, targetHeight) {
498
+ if (object3D.parent) {
499
+ throw new Error("normalizeToHeight refused an object that already has a parent: " + (object3D.name || object3D.uuid));
500
+ }
501
+ if (object3D.userData.tmctNormalized) {
502
+ throw new Error("normalizeToHeight refused a second call on the same object: " + (object3D.name || object3D.uuid));
503
+ }
477
504
  var box = new THREE.Box3().setFromObject(object3D);
478
505
  var size = new THREE.Vector3();
479
506
  box.getSize(size);
480
- var height = size.y || 1;
481
- var scale = targetHeight && height ? targetHeight / height : 1;
507
+ if (!size.y) {
508
+ // Substituting 1 here invented a scale from an unmeasurable height, and
509
+ // that is what produced the house-height mesh.
510
+ throw new Error("normalizeToHeight found no measurable height on: " + (object3D.name || object3D.uuid));
511
+ }
512
+ var scale = targetHeight ? targetHeight / size.y : 1;
482
513
  object3D.scale.setScalar(scale);
483
514
  var seated = new THREE.Box3().setFromObject(object3D);
484
515
  object3D.position.y -= seated.min.y;
485
- }
486
-
487
- async function loadPropTemplate(asset) {
488
- var url = modelUrlFor(asset.destPath);
489
- if (propTemplates[url]) return propTemplates[url];
490
- var gltf = await loadGlb(url);
491
- normalizeToHeight(gltf.scene, asset.targetHeight);
492
- propTemplates[url] = gltf.scene;
493
- return gltf.scene;
516
+ object3D.userData.tmctNormalized = true;
517
+ }
518
+
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];
494
536
  }
495
537
 
496
538
  async function placeProps(propPlacements) {
@@ -515,11 +557,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
515
557
  }
516
558
  }
517
559
 
518
- // ---- items: crumbs and morsels, primitive geometry ------------------------
519
- var CRUMB_RADIUS = 0.08, MORSEL_RADIUS = 0.18;
520
- function itemGeometryFor(kind) { return new THREE.SphereGeometry(kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS, 10, 8); }
521
- function itemMaterialFor(kind) { return new THREE.MeshStandardMaterial({ color: kind === "morsel" ? 0xd98a2b : 0x8c6a3f }); }
522
- 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"; }
523
565
 
524
566
  function animateScaleTo(object3D, target, now) {
525
567
  var duration = tweenDurationMs;
@@ -535,29 +577,55 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
535
577
  requestAnimationFrame(step);
536
578
  }
537
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.
538
584
  function applyItemTick(id, item, now) {
539
585
  var world = cellToWorld(item.cell, GRID_SIZE, CELL_SIZE);
540
586
  if (!world) return;
541
587
  var entry = itemMeshes[id];
542
- if (!entry) {
543
- var mesh = new THREE.Mesh(itemGeometryFor(item.kind), itemMaterialFor(item.kind));
544
- mesh.name = "item-" + id;
545
- mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
546
- mesh.scale.setScalar(0);
547
- scene.add(mesh);
548
- entry = { mesh: mesh, cell: item.cell, kind: item.kind };
549
- itemMeshes[id] = entry;
550
- animateScaleTo(mesh, 1, now);
588
+ if (entry) {
589
+ entry.cell = item.cell;
590
+ entry.mesh.position.set(world.x, 0, world.z);
551
591
  return;
552
592
  }
553
- entry.cell = item.cell;
554
- 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
+ });
555
614
  }
556
615
 
557
- function removeItem(id, withFlourish) {
616
+ function recordRemoval(kind, id, source) {
617
+ removalLog.push({ kind: kind, id: id, source: source });
618
+ }
619
+
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) {
558
625
  var entry = itemMeshes[id];
559
626
  if (!entry) return;
560
627
  delete itemMeshes[id];
628
+ recordRemoval("item", id, source);
561
629
  if (withFlourish) animateScaleTo(entry.mesh, 0, performance.now());
562
630
  else if (entry.mesh.parent) entry.mesh.parent.remove(entry.mesh);
563
631
  }
@@ -572,14 +640,14 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
572
640
  var kind = roleOfAgentId(id);
573
641
  var entry = {
574
642
  group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
575
- kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null,
643
+ kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null, oneShotAction: null,
576
644
  };
577
645
  entry.group.visible = false;
578
646
  scene.add(entry.group);
579
647
  agentGroups[id] = entry;
580
648
  var asset = manifestByKind[kind];
581
649
  if (asset) {
582
- loadGlb(modelUrlFor(asset.destPath)).then(function (gltf) {
650
+ loadGlbRaw(modelUrlFor(asset.destPath)).then(function (gltf) {
583
651
  normalizeToHeight(gltf.scene, asset.targetHeight);
584
652
  entry.group.add(gltf.scene);
585
653
  entry.group.visible = true;
@@ -597,6 +665,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
597
665
  }
598
666
 
599
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
+ }
600
677
  if (!entry.mixer || !clipName || entry.currentClip === clipName) return;
601
678
  var next = entry.actions[clipName];
602
679
  if (!next) return;
@@ -606,6 +683,28 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
606
683
  entry.currentClip = clipName;
607
684
  }
608
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
+
609
708
  function playSpawnFlourish(group, now) {
610
709
  group.scale.setScalar(0);
611
710
  animateScaleTo(group, 1, now);
@@ -636,10 +735,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
636
735
  }
637
736
  }
638
737
 
639
- function removeAgent(id) {
738
+ function removeAgent(id, source) {
640
739
  var entry = agentGroups[id];
641
740
  if (!entry) return;
642
741
  delete agentGroups[id];
742
+ recordRemoval("agent", id, source);
643
743
  var deathClip = entry.clipMap && entry.clipMap.death;
644
744
  var deathClipName = Array.isArray(deathClip) ? deathClip[0] : deathClip;
645
745
  if (deathClipName) {
@@ -652,10 +752,17 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
652
752
 
653
753
  function applyEcology(ecology) {
654
754
  for (var i = 0; i < (ecology || []).length; i += 1) {
655
- var flourish = flourishForEcologyEvent(ecology[i]);
755
+ var event = ecology[i];
756
+ var flourish = flourishForEcologyEvent(event);
656
757
  if (!flourish) continue;
657
- if (flourish.kind === "death") removeAgent(flourish.targetId);
658
- 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
+ }
659
766
  }
660
767
  }
661
768
 
@@ -710,6 +817,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
710
817
  itemMeshes = {};
711
818
  lastAgentsById = {};
712
819
  lastItemsById = {};
820
+ removalLog = [];
713
821
  manifestByKind = buildManifestByKind(input && input.assetManifest);
714
822
  await placeProps((input && input.propPlacements) || []);
715
823
  setCamera({ mode: "overhead", selectedId: null });
@@ -723,9 +831,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
723
831
  var items = (tick && tick.items) || {};
724
832
  for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
725
833
  for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
726
- for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent);
727
- 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.
728
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");
729
843
  lastAgentsById = agents;
730
844
  lastItemsById = items;
731
845
  refreshCameraTween(now);
@@ -760,6 +874,36 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
760
874
  if (itemMeshes[id]) return itemMeshes[id].cell;
761
875
  return null;
762
876
  },
877
+ // The live agent group's own applied Y rotation, in radians -- an e2e
878
+ // assertion's read, so it can compare the yaw actually applied against
879
+ // the direction an agent travelled between two cells rather than trusting
880
+ // yawForFacing's own output in isolation.
881
+ yawOf: function (id) {
882
+ var entry = agentGroups[id];
883
+ return entry ? entry.group.rotation.y : null;
884
+ },
885
+ // The live agent mesh's world-space height and lowest point, plus the
886
+ // manifest's own targetHeight to compare it against — an e2e assertion's
887
+ // read, so it goes through the group actually in the scene rather than a
888
+ // second, locally invented measurement.
889
+ meshHeightOf: function (id) {
890
+ 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);
893
+ var size = new THREE.Vector3();
894
+ box.getSize(size);
895
+ var asset = manifestByKind[entry.kind];
896
+ return { height: size.y, minY: box.min.y, targetHeight: asset ? asset.targetHeight : null };
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
+ },
763
907
  ready: function () { return booted; },
764
908
  };
765
909
  })();`;
@@ -15,7 +15,7 @@
15
15
  import {
16
16
  TOWN_SQUARE_LAYOUTS, DEFAULT_GRID_SIZE, DIRECTION_DELTA,
17
17
  cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
18
- agentKindOf, liveIdsOfKind, layoutNamed,
18
+ agentKindOf, liveIdsOfKind, layoutNamed, isFoodId,
19
19
  } from "../domain/town-square-world.mjs";
20
20
  import {
21
21
  MUDIII_ROLES, foldTownSquareState, startTownSquareGame, runTownSquareTick,
@@ -88,6 +88,25 @@ const MUDIII_TOLD_RE = new RegExp(
88
88
  // never ground truth.
89
89
  const MUDIII_SEE_RE = /^what (?:does|can) the (fox|goblin)(?:-(\d+))?\s+see[.!?\s]*$/i;
90
90
 
91
+ // "who put/placed that there?" — provenance, ground truth (not belief), read
92
+ // from placeFood's own mgx:placed-by row. The subject must be named, either
93
+ // an item id ("who put morsel-1 there?") or a cell ("who put food at
94
+ // cell-3-4?") — a bare "who put that there?" with no referent at all names
95
+ // nothing to look up and is left to the ordinary lanes. Requires the leading
96
+ // "who put/placed/dropped" verb, so it never collides with an unrelated "who"
97
+ // question (the fox's own catches, the goblins' names, anything not about a
98
+ // placement) — those never start with this verb at all.
99
+ const MUDIII_WHO_PLACED_RE = new RegExp(
100
+ "^who\\s+(?:put|placed|dropped)\\s+"
101
+ + "(?:"
102
+ + "(?:the\\s+)?(morsel|crumb)(?:-(\\d+))?"
103
+ + "|"
104
+ + "(?:(?:the\\s+)?(?:food|morsel|crumb|that|it)\\s+)?(?:at|on)\\s+(cell-\\d+-\\d+)"
105
+ + ")"
106
+ + "(?:\\s+there)?[?.!\\s]*$",
107
+ "i",
108
+ );
109
+
91
110
  // The player's own verb: "put food at cell-3-4" (primary, never shadowed —
92
111
  // the adventure imperative grammar's own "put" arm hard-requires a literal
93
112
  // "in", so "at" never parses there) and "drop a morsel at cell-3-4"
@@ -441,6 +460,63 @@ async function mudiiiBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GAME_
441
460
  };
442
461
  }
443
462
 
463
+ function noLivePlacedSubjectAnswer(kind) {
464
+ return {
465
+ text: `there's no live ${kind} on the board for that to be about.`,
466
+ lane: "game-inform",
467
+ note: `MUDIII — who-placed declined: no live ${kind} resolves`,
468
+ miss: true,
469
+ };
470
+ }
471
+
472
+ /** "who put/placed <item|cell> there?": ground truth read off the folded
473
+ * state's own placedBy Map (predator-prey.mjs's fold of every mgx:placed-by
474
+ * row), never belief. Resolves the subject the same way the told-fact and
475
+ * see recognizers already do — resolveAgentId for a named item, a direct
476
+ * placements scan for a cell — then declines, naming the reason, when the
477
+ * item isn't on the board at all or is on the board but carries no placedBy
478
+ * row (spawned food never does; placeFood is the only writer of that row). */
479
+ async function mudiiiWhoPlacedAnswer(match, { memoryDir }) {
480
+ const [, kindRaw, num, cellLiteral] = match;
481
+ const rows = readFactRows(await loadMemory(memoryDir));
482
+ const state = foldTownSquareState(rows);
483
+
484
+ let itemId = null;
485
+ if (kindRaw) {
486
+ itemId = resolveAgentId(kindRaw.toLowerCase(), num, state);
487
+ } else {
488
+ const entry = [...state.placements.entries()].find(([id, place]) =>
489
+ isFoodId(id) && !state.removed.has(id) && place.cell === cellLiteral);
490
+ itemId = entry ? entry[0] : null;
491
+ }
492
+
493
+ if (!itemId) {
494
+ if (kindRaw) return noLivePlacedSubjectAnswer(kindRaw.toLowerCase());
495
+ return {
496
+ text: `there's no food at ${cellLiteral} to ask about.`,
497
+ lane: "game-inform",
498
+ note: `MUDIII — who-placed declined: no live food item sits at ${cellLiteral}`,
499
+ miss: true,
500
+ };
501
+ }
502
+
503
+ const placed = state.placedBy.get(itemId);
504
+ if (!placed) {
505
+ return {
506
+ text: `${itemId} was never placed — it spawned on its own.`,
507
+ lane: "game-inform",
508
+ note: `MUDIII — who-placed declined: ${itemId} carries no mgx:placed-by row (spawned food)`,
509
+ miss: true,
510
+ };
511
+ }
512
+
513
+ return {
514
+ text: `${placed.by} put ${itemId} at ${state.placements.get(itemId).cell}.`,
515
+ lane: "game-inform",
516
+ note: `MUDIII — who-placed answered from state.placedBy for ${itemId}`,
517
+ };
518
+ }
519
+
444
520
  /** The addressed teach-frame turn: resolve the addressee and the belief
445
521
  * subject (an agent OR a food item), resolve the told cell, and run ONE tick
446
522
  * with that told-fact fed in. Told-facts are not persisted across turns —
@@ -659,6 +735,11 @@ export async function mudiiiTurn(line, { planHolder, memoryDir, env, cache = nul
659
735
  return mudiiiBeliefAnswer(seeMatch, { memoryDir, gameConfig });
660
736
  }
661
737
 
738
+ const whoPlacedMatch = trimmed.match(MUDIII_WHO_PLACED_RE);
739
+ if (whoPlacedMatch) {
740
+ return mudiiiWhoPlacedAnswer(whoPlacedMatch, { memoryDir });
741
+ }
742
+
662
743
  if (MUDIII_TICK_RE.test(line)) {
663
744
  return runTickAndRender({ planHolder, memoryDir, cache, world: mudiii.world, toldFacts: [], gameConfig });
664
745
  }
@@ -82,7 +82,7 @@ const NPC_COUNT_MIN = 1;
82
82
  const NPC_COUNT_MAX = 10;
83
83
  const NPC_COUNT_LABELLED = [1, 5, 10];
84
84
  const DEFAULT_NPC_COUNT = 2;
85
- const DEFAULT_DELAY_MS = 650;
85
+ const DEFAULT_DELAY_MS = 220;
86
86
  const DEFAULT_MAX_TURNS = 400;
87
87
  // test/fixtures/mudiii-ticks.json's own board size — the fallback for a
88
88
  // scenario that names no gridSize of its own.
@@ -813,6 +813,7 @@ function pageScript() {
813
813
  const el = (id) => document.getElementById(id);
814
814
  let scenarioIndex = 0;
815
815
  const scenario = function () { return DATA.scenarios[scenarioIndex]; };
816
+ const gridSizeOf = function () { return scenario().gridSize || DATA.gridSize; };
816
817
  const rosterOf = function (s, role) {
817
818
  return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
818
819
  };
@@ -881,6 +882,9 @@ function pageScript() {
881
882
 
882
883
  function applyTickResult(result) {
883
884
  if (!result) return;
885
+ // The engine owns the count. Anything that advances a turn — the deck, a
886
+ // chat frame — lands here, so the page never keeps a rival tally.
887
+ if (typeof result.turn === "number") globalTurn = result.turn;
884
888
  if (result.agents) agentsById = result.agents;
885
889
  if (result.items) itemsById = result.items;
886
890
  callScene("applyTick", { agents: result.agents, items: result.items, ecology: result.ecology });
@@ -892,8 +896,7 @@ function pageScript() {
892
896
  async function runOneTick() {
893
897
  return serializeTick(async function () {
894
898
  if (!session) return null;
895
- globalTurn += 1;
896
- const result = await session.tick(globalTurn);
899
+ const result = await session.tick();
897
900
  applyTickResult(result);
898
901
  renderAll();
899
902
  return result;
@@ -921,8 +924,16 @@ function pageScript() {
921
924
  function sendCommand(line) {
922
925
  appendChat("u", line);
923
926
  if (!session) { appendChat("a", "no session is open yet \\u2014 reset to start one."); return Promise.resolve(); }
924
- return serializeTick(function () { return tmct.turn(line); }).then(function (res) {
927
+ // A chat line can run a real turn. An addressed told-fact does, and the
928
+ // visitor should watch the lie land, so the board is read back in the same
929
+ // queue slot. board() spends no turn, so a line that ran none costs
930
+ // nothing. It reports no goal or plan either, because a resting board has
931
+ // decided nothing, which is the blank boot() already draws at turn 0.
932
+ return serializeTick(async function () {
933
+ const res = await tmct.turn(line);
934
+ const board = await session.board();
925
935
  appendChat("a", res.answer);
936
+ applyTickResult(board);
926
937
  renderAll();
927
938
  return res;
928
939
  });
@@ -1027,7 +1038,7 @@ function pageScript() {
1027
1038
 
1028
1039
  // ---- the top-down map panel ---------------------------------------------
1029
1040
  function renderMapPanel() {
1030
- const dots = mapDotsFor(agentsList(), itemsList(), DATA.gridSize);
1041
+ const dots = mapDotsFor(agentsList(), itemsList(), gridSizeOf());
1031
1042
  el("mapPanelBoard").innerHTML = dots.map(function (d) {
1032
1043
  return '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
1033
1044
  }).join("");
@@ -1216,7 +1227,7 @@ function pageScript() {
1216
1227
  agentsById = {};
1217
1228
  itemsById = {};
1218
1229
  el("chatInput").disabled = false;
1219
- callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
1230
+ await callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: gridSizeOf(), cellSize: 1 });
1220
1231
 
1221
1232
  // The opening board, drawn through the very path a tick takes. Without
1222
1233
  // this the page's first sight of where anything stands is the first tick,
@@ -1225,7 +1236,6 @@ function pageScript() {
1225
1236
  // the cells both come back from it rather than being guessed here.
1226
1237
  const opening = await session.board();
1227
1238
  if (seq !== bootSeq) return;
1228
- globalTurn = opening.turn || 0;
1229
1239
  camera.selectedId = Object.keys(opening.agents || {}).sort()[0] || null;
1230
1240
  applyTickResult(opening);
1231
1241
  renderAll();