@polycode-projects/the-mechanical-code-talker 5.0.3 → 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.
@@ -50,17 +50,17 @@
50
50
  {
51
51
  "file": "src/town-square-chapel.jsonl",
52
52
  "bytes": 108408,
53
- "sha256": "2c378e51c2f7d53d700e557970b72035aadcbea9496cc959adee09dd77181334"
53
+ "sha256": "02036d284ec27244494e1f575b750efa71d9a0fc232e57dbd8b5dab7e6a6b7b3"
54
54
  },
55
55
  {
56
56
  "file": "src/town-square-market.jsonl",
57
57
  "bytes": 52311,
58
- "sha256": "91682ef54cdddd3d3fe0c5877d107f9577bff9d85621bfb4979947f89b029a45"
58
+ "sha256": "dd374f9cb1c6a13ccfab62badeb4150b8cfefdc349f834430c2c9e105c1d7a4f"
59
59
  },
60
60
  {
61
61
  "file": "src/town-square.jsonl",
62
62
  "bytes": 75251,
63
- "sha256": "3238dcd53827e4139c0752bebc204257cac4a4663a8263183d387cc98e739779"
63
+ "sha256": "2fb87416759d8cee1464b55d4e811b91e772d52f65e0f06a88eac1d3a8fc5e7f"
64
64
  }
65
65
  ],
66
66
  "files": [
@@ -106,18 +106,18 @@
106
106
  },
107
107
  {
108
108
  "file": "shards/town-square-chapel.jsonl.gz",
109
- "bytes": 5540,
110
- "sha256": "f7515fc12de8bf40f5a5c0635032be2d4fe2f5a18d153a1b64fdc2adbcf5745c"
109
+ "bytes": 5538,
110
+ "sha256": "826e8732b419b506098f4fbce1adcc07d11bd7d91ea672aa46f13ed3ccebb0bb"
111
111
  },
112
112
  {
113
113
  "file": "shards/town-square-market.jsonl.gz",
114
- "bytes": 3116,
115
- "sha256": "5791be29b86ad2329978a8bca76dafb9a0ae2419beb0d8d00b83b958adb989d6"
114
+ "bytes": 3113,
115
+ "sha256": "c6834f0754498ff800e7be620e3e2268c375686bd753fa9da9b7dd89a63aa009"
116
116
  },
117
117
  {
118
118
  "file": "shards/town-square.jsonl.gz",
119
- "bytes": 4277,
120
- "sha256": "657e3a8825e579e38b24b64f4df95c17ee95caa10862afc0067acdf1f2db842f"
119
+ "bytes": 4275,
120
+ "sha256": "460864ccee3a6eed9dae93f158abaf9780f2b7fb4051f45c3ebc63c7400b01d2"
121
121
  }
122
122
  ]
123
123
  }
@@ -920,7 +920,7 @@
920
920
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
921
921
  {"world":"town-square-chapel","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
922
922
  {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
923
- {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
923
+ {"world":"town-square-chapel","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
924
924
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
925
925
  {"world":"town-square-chapel","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
926
926
  {"world":"town-square-chapel","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -446,7 +446,7 @@
446
446
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
447
447
  {"world":"town-square-market","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
448
448
  {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
449
- {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
449
+ {"world":"town-square-market","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
450
450
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
451
451
  {"world":"town-square-market","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
452
452
  {"world":"town-square-market","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -678,7 +678,7 @@
678
678
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:lose","object":"0.06 mass per turn by default"}
679
679
  {"world":"town-square","kind":"fact","subject":"goblin","predicate":"mgx:arrive","object":"every 5 turns at the edge of the board by default"}
680
680
  {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:arrive","object":"every 3 turns on any open cell by default"}
681
- {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 1 by default"}
681
+ {"world":"town-square","kind":"fact","subject":"crumb","predicate":"mgx:start-with","object":"mass 2 by default"}
682
682
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:start-with","object":"mass 2 by default"}
683
683
  {"world":"town-square","kind":"fact","subject":"morsel","predicate":"mgx:arrive","object":"where the player puts it"}
684
684
  {"world":"town-square","kind":"fact","subject":"vision radius","predicate":"mgx:hasProperty","object":"4 cells for the fox by default"}
@@ -21,7 +21,7 @@
21
21
  "destPath": "public/models/creatures/fox.glb",
22
22
  "licence": "CC0-1.0",
23
23
  "author": "Quaternius",
24
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
24
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
25
25
  "bytes": 332748,
26
26
  "sha256": "2694028b47e84bad3b9d985aa7743dae02a3bcfddd6becd73b46c747a1e067b3",
27
27
  "targetHeight": 1.0,
@@ -30,7 +30,10 @@
30
30
  "walk": "Walk",
31
31
  "run": "Gallop",
32
32
  "attack": "Attack",
33
- "hit": ["Idle_HitReact_Left", "Idle_HitReact_Right"],
33
+ "hit": [
34
+ "Idle_HitReact_Left",
35
+ "Idle_HitReact_Right"
36
+ ],
34
37
  "death": "Death",
35
38
  "eat": "Eating"
36
39
  }
@@ -41,7 +44,7 @@
41
44
  "destPath": "public/models/creatures/goblin.glb",
42
45
  "licence": "CC0-1.0",
43
46
  "author": "Quaternius",
44
- "source": "https://poly.pizza/u/Quaternius · https://quaternius.com",
47
+ "source": "https://poly.pizza/u/Quaternius \u00b7 https://quaternius.com",
45
48
  "bytes": 47924,
46
49
  "sha256": "2f02ce927a8871554f3931655d75f1abb8237d36be8a19a35b09920d0812bc3c",
47
50
  "targetHeight": 2.1,
@@ -50,7 +53,9 @@
50
53
  "walk": "Walk",
51
54
  "run": "Run",
52
55
  "attack": "Attack",
53
- "hit": ["HitRecieve"],
56
+ "hit": [
57
+ "HitRecieve"
58
+ ],
54
59
  "death": "Death"
55
60
  }
56
61
  },
@@ -207,7 +212,7 @@
207
212
  "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
208
213
  "bytes": 14004,
209
214
  "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
210
- "targetHeight": 0.16,
215
+ "targetHeight": 0.6,
211
216
  "clips": null
212
217
  },
213
218
  {
@@ -219,7 +224,7 @@
219
224
  "source": "https://github.com/KayKit-Game-Assets/KayKit-Dungeon-Remastered-1.0",
220
225
  "bytes": 14004,
221
226
  "sha256": "8b340af18ea1325d5a9bd4a967d3ef37bb90838ca2d90927b123e23877f7ddad",
222
- "targetHeight": 0.36,
227
+ "targetHeight": 0.6,
223
228
  "clips": null
224
229
  }
225
230
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "5.0.3",
3
+ "version": "5.0.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
@@ -78,7 +78,9 @@ export const DEFAULT_GAME_CONFIG = Object.freeze({
78
78
  preyVisionRadius: 3,
79
79
  preySpawnIntervalTurns: 5,
80
80
  foodSpawnIntervalTurns: 3,
81
- spawnedFoodMass: 1,
81
+ // Spawned and placed food are one thing: the same hay bale, worth the
82
+ // same. The two knobs stay separate so a world can still tell them apart.
83
+ spawnedFoodMass: 2,
82
84
  placedFoodMass: 2,
83
85
  maxPreyPopulation: 6,
84
86
  maxFoodItems: 8,
@@ -57,9 +57,9 @@ export const chebyshevDistance = (ax, ay, bx, by) => Math.max(Math.abs(ax - bx),
57
57
  export const inBounds = (x, y) => x >= 1 && x <= GRID_SIZE && y >= 1 && y <= GRID_SIZE;
58
58
 
59
59
  /** Every cell within Chebyshev `radius` of (cx, cy), clipped to the board, in
60
- * raster order — the one visibility primitive PLAN_SPIDER_FLY.md §4 names
61
- * ("visibleCells(cx, cy, radius=4)"), shared by both agents' belief and this
62
- * module's own web-block derivation below. */
60
+ * raster order — the one visibility primitive ("visibleCells(cx, cy, radius=4)")
61
+ * shared by both agents' belief and this module's own web-block derivation
62
+ * below. */
63
63
  export function visibleCells(cx, cy, radius) {
64
64
  const out = [];
65
65
  for (let y = Math.max(1, cy - radius); y <= Math.min(GRID_SIZE, cy + radius); y += 1) {
@@ -72,8 +72,7 @@ export function visibleCells(cx, cy, radius) {
72
72
 
73
73
  export const isInWebBlock = (x, y) => chebyshevDistance(x, y, WEB_HOME.x, WEB_HOME.y) <= WEB_RADIUS;
74
74
 
75
- /** Every board-edge (perimeter) cell, raster order — where a spawned fly
76
- * arrives (PLAN_SPIDER_FLY.md §10). */
75
+ /** Every board-edge (perimeter) cell, raster order. */
77
76
  export function perimeterCells() {
78
77
  const out = [];
79
78
  for (let y = 1; y <= GRID_SIZE; y += 1) {
@@ -110,11 +109,10 @@ export function oneStepDirectionBetween(fromCell, toCell) {
110
109
  return null;
111
110
  }
112
111
 
113
- /** The world's seed taxonomy (PLAN_SPIDER_FLY.md §7): enough for the
114
- * ontology-to-sprite worked example (a poodle sprite, a sheepdog falling
115
- * back to the generic dog sprite) to run on the default persona, no
116
- * --persona-size large flag required. [subject, superclass] pairs, written
117
- * as rdfs:subClassOf facts. */
112
+ /** The world's seed taxonomy enough for the ontology-to-sprite worked example
113
+ * (a poodle sprite, a sheepdog falling back to the generic dog sprite) to run
114
+ * on the default persona without the --persona-size large flag. [subject,
115
+ * superclass] pairs, written as rdfs:subClassOf facts. */
118
116
  export const SEED_TAXONOMY = Object.freeze([
119
117
  Object.freeze(["poodle", "dog"]),
120
118
  Object.freeze(["sheepdog", "dog"]),
@@ -293,9 +291,9 @@ export function isLiveRenderableAgent(id, state) {
293
291
  /** A minimal, inert rule-row family, so scripts/build-worlds-pack.mjs's
294
292
  * shared validator ("every world needs at least one rule row") passes.
295
293
  * src/services/spider-fly-turn.mjs never reads these back: grid movement is
296
- * hand-written pathfinding over findActionPath/findReachableSet
297
- * (PLAN_SPIDER_FLY.md §5), not the taught action-Rule DSL, so this rides in
298
- * the shard unused, same as an unrelated fact would. */
294
+ * hand-written pathfinding over findActionPath/findReachableSet, not the
295
+ * taught action-Rule DSL, so this rides in the shard unused, same as an
296
+ * unrelated fact would. */
299
297
  export function* worldRuleRows() {
300
298
  yield {
301
299
  world: WORLD_NAME, kind: "rule", name: "go", ruleKind: "action-signature",
@@ -186,12 +186,14 @@ export function currentActionFor(agentId, agentsById, moving) {
186
186
  * so acting on the ecology row too would double it). `kind` is one of
187
187
  * "death" (the rig's own Death clip, then scale to zero) or "consume" (an
188
188
  * eaten item scales to zero, no clip — items are primitive geometry).
189
- * Pure. */
189
+ * `eat-agent` alone also carries `actorId`, the predator that gets a
190
+ * one-shot attack clip while its prey plays death — the one event where a
191
+ * second, surviving agent has its own flourish to play. Pure. */
190
192
  export function flourishForEcologyEvent(event) {
191
193
  if (!event || !event.type) return null;
192
194
  switch (event.type) {
193
195
  case "eat-agent":
194
- return { kind: "death", targetId: event.prey, cell: event.cell };
196
+ return { kind: "death", targetId: event.prey, cell: event.cell, actorId: event.predator };
195
197
  case "starve":
196
198
  return { kind: "death", targetId: event.agent, cell: event.cell };
197
199
  case "eat-item":
@@ -359,6 +361,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
359
361
  var manifestByKind = {};
360
362
  var lastAgentsById = {};
361
363
  var lastItemsById = {};
364
+ // Every removeAgent/removeItem call, kept small and reset on boot — an
365
+ // e2e assertion's read, so it can tell an eaten agent/item's flourish
366
+ // removal (source "ecology") apart from applyTick's own diff no-op
367
+ // (source "diff") without guessing from a vanished mesh alone.
368
+ var removalLog = [];
362
369
  var cameraState = { mode: "overhead", selectedId: null };
363
370
  var cameraTween = null, lookAtTween = null;
364
371
  var lastFrameTs = null;
@@ -385,9 +392,6 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
385
392
  });
386
393
  });
387
394
  }
388
- var glbCache = null;
389
- function loadGlb(url) { return glbCache.load(url); }
390
-
391
395
  async function ensureThree() {
392
396
  if (THREE) return true;
393
397
  var result = await loadThreeVendor();
@@ -396,7 +400,6 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
396
400
  OrbitControlsCtor = result.OrbitControls; MeshoptDecoderRef = result.MeshoptDecoder;
397
401
  gltfLoader = new GLTFLoaderCtor();
398
402
  gltfLoader.setMeshoptDecoder(MeshoptDecoderRef);
399
- glbCache = createCachedLoader(loadGlbRaw);
400
403
  setUpScene();
401
404
  return true;
402
405
  }
@@ -513,13 +516,23 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
513
516
  object3D.userData.tmctNormalized = true;
514
517
  }
515
518
 
516
- async function loadPropTemplate(asset) {
517
- var url = modelUrlFor(asset.destPath);
518
- if (propTemplates[url]) return propTemplates[url];
519
- var gltf = await loadGlb(url);
520
- normalizeToHeight(gltf.scene, asset.targetHeight);
521
- propTemplates[url] = gltf.scene;
522
- return gltf.scene;
519
+ // Keyed on the manifest row's own key field, not its destPath: food-crumb
520
+ // and food-morsel share one GLB at two different targetHeights, and
521
+ // normalizeToHeight refuses a second call on the same object, so a
522
+ // destPath-keyed cache would hand the second row the first row's
523
+ // already-normalized graph and throw.
524
+ function loadPropTemplate(asset) {
525
+ var key = asset.key;
526
+ if (!propTemplates[key]) {
527
+ var url = modelUrlFor(asset.destPath);
528
+ var promise = loadGlbRaw(url).then(function (gltf) {
529
+ normalizeToHeight(gltf.scene, asset.targetHeight);
530
+ return gltf.scene;
531
+ });
532
+ promise.catch(function () { delete propTemplates[key]; });
533
+ propTemplates[key] = promise;
534
+ }
535
+ return propTemplates[key];
523
536
  }
524
537
 
525
538
  async function placeProps(propPlacements) {
@@ -544,13 +557,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
544
557
  }
545
558
  }
546
559
 
547
- // ---- items: crumbs and morsels, primitive geometry ------------------------
548
- // Doubled from 0.08/0.18: against the fox's own 1m targetHeight and a 1-unit
549
- // cell, the old radii read as barely-visible dots on the board.
550
- var CRUMB_RADIUS = 0.16, MORSEL_RADIUS = 0.36;
551
- function itemGeometryFor(kind) { return new THREE.SphereGeometry(kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS, 10, 8); }
552
- function itemMaterialFor(kind) { return new THREE.MeshStandardMaterial({ color: kind === "morsel" ? 0xd98a2b : 0x8c6a3f }); }
553
- function itemHeightFor(kind) { return kind === "morsel" ? MORSEL_RADIUS : CRUMB_RADIUS; }
560
+ // ---- items: crumbs and morsels, the committed hay bale at two target
561
+ // heights (food-crumb 0.16, food-morsel 0.36 in data/mudiii-assets.json),
562
+ // matching the on-ground footprint the primitive spheres they replace were
563
+ // already tuned to. ---------------------------------------------------------
564
+ function itemAssetKeyFor(kind) { return kind === "morsel" ? "food-morsel" : "food-crumb"; }
554
565
 
555
566
  function animateScaleTo(object3D, target, now) {
556
567
  var duration = tweenDurationMs;
@@ -566,29 +577,55 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
566
577
  requestAnimationFrame(step);
567
578
  }
568
579
 
580
+ // A Group per live item, the same shape ensureAgent uses for agents: the
581
+ // group is in the scene (and in itemMeshes) synchronously so a second tick
582
+ // arriving before the GLB resolves finds an existing entry rather than
583
+ // starting a second load, but stays invisible until the model is in it.
569
584
  function applyItemTick(id, item, now) {
570
585
  var world = cellToWorld(item.cell, GRID_SIZE, CELL_SIZE);
571
586
  if (!world) return;
572
587
  var entry = itemMeshes[id];
573
- if (!entry) {
574
- var mesh = new THREE.Mesh(itemGeometryFor(item.kind), itemMaterialFor(item.kind));
575
- mesh.name = "item-" + id;
576
- mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
577
- mesh.scale.setScalar(0);
578
- scene.add(mesh);
579
- entry = { mesh: mesh, cell: item.cell, kind: item.kind };
580
- itemMeshes[id] = entry;
581
- animateScaleTo(mesh, 1, now);
588
+ if (entry) {
589
+ entry.cell = item.cell;
590
+ entry.mesh.position.set(world.x, 0, world.z);
582
591
  return;
583
592
  }
584
- entry.cell = item.cell;
585
- entry.mesh.position.set(world.x, itemHeightFor(item.kind), world.z);
593
+ var group = new THREE.Group();
594
+ group.name = "item-" + id;
595
+ group.position.set(world.x, 0, world.z);
596
+ group.visible = false;
597
+ scene.add(group);
598
+ entry = { mesh: group, cell: item.cell, kind: item.kind };
599
+ itemMeshes[id] = entry;
600
+ var asset = manifestByKind[itemAssetKeyFor(item.kind)];
601
+ if (!asset) return;
602
+ loadPropTemplate(asset).then(function (template) {
603
+ if (itemMeshes[id] !== entry) return; // removed while the model was still loading
604
+ var instance = template.clone();
605
+ instance.position.y = template.position.y;
606
+ group.add(instance);
607
+ group.visible = true;
608
+ group.scale.setScalar(0);
609
+ animateScaleTo(group, 1, performance.now());
610
+ }).catch(function (err) {
611
+ // eslint-disable-next-line no-console
612
+ console.warn("tmct: an item model failed to load", id, err);
613
+ });
614
+ }
615
+
616
+ function recordRemoval(kind, id, source) {
617
+ removalLog.push({ kind: kind, id: id, source: source });
586
618
  }
587
619
 
588
- function removeItem(id, withFlourish) {
620
+ // source ("ecology" | "diff") is recorded, never branched on — it exists
621
+ // so an e2e assertion can tell an eaten item's flourish removal apart from
622
+ // a plain diff no-op, since a vanished mesh alone does not say which path
623
+ // did the removing.
624
+ function removeItem(id, withFlourish, source) {
589
625
  var entry = itemMeshes[id];
590
626
  if (!entry) return;
591
627
  delete itemMeshes[id];
628
+ recordRemoval("item", id, source);
592
629
  if (withFlourish) animateScaleTo(entry.mesh, 0, performance.now());
593
630
  else if (entry.mesh.parent) entry.mesh.parent.remove(entry.mesh);
594
631
  }
@@ -603,7 +640,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
603
640
  var kind = roleOfAgentId(id);
604
641
  var entry = {
605
642
  group: new THREE.Group(), tween: null, cell: null, facing: agent.facing, role: agent.role,
606
- kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null,
643
+ kind: kind, mixer: null, actions: {}, currentClip: null, clipMap: null, oneShotAction: null,
607
644
  };
608
645
  entry.group.visible = false;
609
646
  scene.add(entry.group);
@@ -628,6 +665,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
628
665
  }
629
666
 
630
667
  function playClip(entry, clipName) {
668
+ // A one-shot flourish left its own action running at full weight (see
669
+ // playClipOnce below — fadeIn ramps the NEW action in but never ramps
670
+ // the old one out on its own, so without this the flourish pose would
671
+ // keep blending into every clip after it, forever). Fade it out before
672
+ // any of the guards below can skip the rest of this call.
673
+ if (entry.oneShotAction) {
674
+ entry.oneShotAction.fadeOut(0.15);
675
+ entry.oneShotAction = null;
676
+ }
631
677
  if (!entry.mixer || !clipName || entry.currentClip === clipName) return;
632
678
  var next = entry.actions[clipName];
633
679
  if (!next) return;
@@ -637,6 +683,28 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
637
683
  entry.currentClip = clipName;
638
684
  }
639
685
 
686
+ // A one-shot version of playClip for a flourish (the predator's own bite
687
+ // on eat-agent): LoopOnce + clampWhenFinished so the clip plays through and
688
+ // holds its last pose rather than looping. Never skips a repeat of
689
+ // entry.currentClip the way playClip does — a flourish must always play,
690
+ // even if the actor happened to already be on this same clip. Clears
691
+ // entry.currentClip to null so the next applyAgentTick's own playClip call
692
+ // is never refused as "already on this clip", and records the action on
693
+ // entry.oneShotAction so that same next call fades it back out.
694
+ function playClipOnce(entry, clipName) {
695
+ if (!entry.mixer || !clipName) return;
696
+ var action = entry.actions[clipName];
697
+ if (!action) return;
698
+ var prev = entry.currentClip && entry.currentClip !== clipName ? entry.actions[entry.currentClip] : null;
699
+ action.reset();
700
+ action.setLoop(THREE.LoopOnce, 1);
701
+ action.clampWhenFinished = true;
702
+ action.fadeIn(0.15).play();
703
+ if (prev) prev.fadeOut(0.15);
704
+ entry.oneShotAction = action;
705
+ entry.currentClip = null;
706
+ }
707
+
640
708
  function playSpawnFlourish(group, now) {
641
709
  group.scale.setScalar(0);
642
710
  animateScaleTo(group, 1, now);
@@ -667,10 +735,11 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
667
735
  }
668
736
  }
669
737
 
670
- function removeAgent(id) {
738
+ function removeAgent(id, source) {
671
739
  var entry = agentGroups[id];
672
740
  if (!entry) return;
673
741
  delete agentGroups[id];
742
+ recordRemoval("agent", id, source);
674
743
  var deathClip = entry.clipMap && entry.clipMap.death;
675
744
  var deathClipName = Array.isArray(deathClip) ? deathClip[0] : deathClip;
676
745
  if (deathClipName) {
@@ -683,10 +752,17 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
683
752
 
684
753
  function applyEcology(ecology) {
685
754
  for (var i = 0; i < (ecology || []).length; i += 1) {
686
- var flourish = flourishForEcologyEvent(ecology[i]);
755
+ var event = ecology[i];
756
+ var flourish = flourishForEcologyEvent(event);
687
757
  if (!flourish) continue;
688
- if (flourish.kind === "death") removeAgent(flourish.targetId);
689
- else if (flourish.kind === "consume") removeItem(flourish.targetId, true);
758
+ if (flourish.kind === "death") removeAgent(flourish.targetId, "ecology");
759
+ else if (flourish.kind === "consume") removeItem(flourish.targetId, true, "ecology");
760
+ if (flourish.actorId) {
761
+ var actorEntry = agentGroups[flourish.actorId];
762
+ if (actorEntry && actorEntry.clipMap) {
763
+ playClipOnce(actorEntry, clipForAction(actorEntry.role, event.type, actorEntry.clipMap));
764
+ }
765
+ }
690
766
  }
691
767
  }
692
768
 
@@ -741,6 +817,7 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
741
817
  itemMeshes = {};
742
818
  lastAgentsById = {};
743
819
  lastItemsById = {};
820
+ removalLog = [];
744
821
  manifestByKind = buildManifestByKind(input && input.assetManifest);
745
822
  await placeProps((input && input.propPlacements) || []);
746
823
  setCamera({ mode: "overhead", selectedId: null });
@@ -754,9 +831,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
754
831
  var items = (tick && tick.items) || {};
755
832
  for (var id in agents) if (Object.prototype.hasOwnProperty.call(agents, id)) applyAgentTick(id, agents[id], now);
756
833
  for (var itemId in items) if (Object.prototype.hasOwnProperty.call(items, itemId)) applyItemTick(itemId, items[itemId], now);
757
- for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent);
758
- for (var goneItem in lastItemsById) if (!(goneItem in items)) removeItem(goneItem, false);
834
+ // Ecology first: an eaten agent/item is already gone from this tick's own
835
+ // agents/items map, so applyEcology's own removeAgent/removeItem call is
836
+ // what actually removes it (with its flourish). The two diff loops below
837
+ // then find that id already deleted and no-op — removeAgent/removeItem
838
+ // both delete their map entry before anything else, so a second call for
839
+ // the same id returns immediately.
759
840
  applyEcology(tick && tick.ecology);
841
+ for (var goneAgent in lastAgentsById) if (!(goneAgent in agents)) removeAgent(goneAgent, "diff");
842
+ for (var goneItem in lastItemsById) if (!(goneItem in items)) removeItem(goneItem, false, "diff");
760
843
  lastAgentsById = agents;
761
844
  lastItemsById = items;
762
845
  refreshCameraTween(now);
@@ -812,6 +895,15 @@ export function mudiiiSceneScript({ canvasId, statusId, gridSize, cellSize } = {
812
895
  var asset = manifestByKind[entry.kind];
813
896
  return { height: size.y, minY: box.min.y, targetHeight: asset ? asset.targetHeight : null };
814
897
  },
898
+ // Every removal recorded for id, oldest first — each entry's own
899
+ // source is "ecology" (an eat/starve flourish removed it) or "diff"
900
+ // (applyTick found it missing from a tick's own agents/items map with no
901
+ // ecology event driving that). An e2e assertion's read.
902
+ removalsFor: function (id) {
903
+ var out = [];
904
+ for (var i = 0; i < removalLog.length; i += 1) if (removalLog[i].id === id) out.push(removalLog[i]);
905
+ return out;
906
+ },
815
907
  ready: function () { return booted; },
816
908
  };
817
909
  })();`;
@@ -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
  }