@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
@@ -266,7 +266,7 @@ export function createP2pRoom({
266
266
  // is a change worth broadcasting even though its provenance never moved.
267
267
  let cachedRetractions = [];
268
268
  const seenRetractionValueById = new Map();
269
- const retractionDiffValue = (fact) => `${fact.provenance}${fact.object}`;
269
+ const retractionDiffValue = (fact) => `${fact.provenance}\u0000${fact.object}`;
270
270
 
271
271
  // Store-touching work runs one job at a time, in arrival order. Every path
272
272
  // that reads or writes memoryDir/seenProvenanceById/cachedRows crosses at
@@ -18,7 +18,7 @@
18
18
  // inline script degrades honestly when that sibling script is absent or
19
19
  // fails to load — the live controls disable themselves rather than pretend
20
20
  // to work.
21
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel } from "./viz-theme.mjs";
21
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
22
22
  import { planToPddl } from "./plan-pddl.mjs";
23
23
 
24
24
  const BOARD_W = 640;
@@ -334,6 +334,7 @@ button { font: inherit; }
334
334
  /* ---- head strip: the nameplate ---- */
335
335
  .headStrip { display: flex; justify-content: space-between; align-items: flex-end; gap: .6rem; flex-wrap: wrap; padding: .9rem 1.1rem .75rem; background: linear-gradient(180deg, var(--strip2), var(--strip)); border-bottom: 1px solid var(--line); border-radius: 9px 9px 0 0; }
336
336
  .eyebrow { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); margin: 0 0 .35rem; }
337
+ ${EYEBROW_LINKS_CSS}
337
338
  h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0; color: var(--ink); }
338
339
  .chip { font-family: ${MONO_STACK}; font-size: .64rem; letter-spacing: .02em; color: var(--muted); border: 1px solid var(--line); border-radius: 3px; padding: .22rem .6rem; background: var(--well); text-align: right; }
339
340
 
@@ -432,7 +433,7 @@ h1 { font-family: ${SERIF_STACK}; font-size: 1.2rem; font-weight: 600; margin: 0
432
433
  <div class="rack">
433
434
  <div class="headStrip">
434
435
  <div>
435
- <div class="eyebrow">tmct &middot; plan</div>
436
+ <div class="eyebrow">${demoEyebrowHtml("plan", "plan")}</div>
436
437
  <h1 id="pageTitle">${escapeHtml(pageTitle)}</h1>
437
438
  </div>
438
439
  <span class="chip">blocks archetype · ${pageData.layouts.length} snapshots · plan: findActionPath</span>
@@ -529,6 +530,11 @@ const PLAN = ${embedded};
529
530
  // server-rendered embed, for a plain renderPlanHtml() caller with no live
530
531
  // bundle nearby).
531
532
  let plan = null, N = 0, blockEls = {}, step = 0, playing = false, animating = false;
533
+ // The promise of whatever animateMove() call is currently in flight, or
534
+ // null between moves — reset() awaits this instead of declining outright
535
+ // when it is clicked mid-move, so a reset can never be silently dropped
536
+ // (see the reset handler below for why that mattered).
537
+ let animatingPromise = null;
532
538
 
533
539
  const posIn = (snap, id) => snap.items.find((i) => i.id === id && i.kind === "block");
534
540
  function drawState(i) {
@@ -608,7 +614,11 @@ const PLAN = ${embedded};
608
614
  }
609
615
  async function forward() {
610
616
  if (animating || step >= N) return;
611
- render(); await animateMove(step); step += 1; render();
617
+ render();
618
+ animatingPromise = animateMove(step);
619
+ await animatingPromise;
620
+ animatingPromise = null;
621
+ step += 1; render();
612
622
  }
613
623
  async function playRange(from, to) {
614
624
  if (animating) return;
@@ -674,7 +684,19 @@ const PLAN = ${embedded};
674
684
 
675
685
  btn.next.addEventListener("click", () => { playing = false; forward(); });
676
686
  btn.back.addEventListener("click", () => { if (animating) return; playing = false; step = Math.max(0, step - 1); drawState(step); render(); });
677
- btn.reset.addEventListener("click", () => { if (animating) return; playing = false; step = 0; drawState(0); render(); });
687
+ // playing drops the instant reset is clicked, even mid-move a click
688
+ // that landed while animating used to bail out before touching playing
689
+ // at all, which left playRange's own loop free to run one more step the
690
+ // moment the in-flight move settled, so a fast play-then-reset click could
691
+ // reset the board and have it immediately start moving again. Waiting on
692
+ // animatingPromise (rather than declining) means the reset itself still
693
+ // never fires while a move might be touching the same block elements, but
694
+ // it is never dropped either.
695
+ btn.reset.addEventListener("click", async () => {
696
+ playing = false;
697
+ if (animatingPromise) await animatingPromise;
698
+ step = 0; drawState(0); render();
699
+ });
678
700
  btn.play.addEventListener("click", async () => {
679
701
  if (animating) return;
680
702
  if (playing) { playing = false; render(); return; }
@@ -14,7 +14,7 @@
14
14
  // which is board-size agnostic and models vision as plain Chebyshev distance
15
15
  // with no line of sight — a building blocks movement, never sight.
16
16
  //
17
- // Three mechanics are OPT-IN, off unless `config` asks for them by name, so a
17
+ // Four mechanics are OPT-IN, off unless `config` asks for them by name, so a
18
18
  // cast that does not want them sees the same board it always did:
19
19
  //
20
20
  // - `carryPreyToWeb` splits catching from eating. A predator that shares a
@@ -27,6 +27,11 @@
27
27
  // - `layEggs` adds the reproduction stage: an egg (mgx:laid-at-turn) laid in
28
28
  // a web once mass crosses a threshold, hatching (mgx:hatched-into) into
29
29
  // hatchlings that split the egg's mass.
30
+ // - `blendPreyDecision` replaces the prey's strict evade-then-forage order
31
+ // with one weighted score over both distances (`preyThreatWeight`), so a
32
+ // prey can take a crumb that costs it nothing. Off, prey abandon food the
33
+ // moment anything is in view. scripts/compare-prey-decision.mjs measures
34
+ // the two against each other.
30
35
 
31
36
  import {
32
37
  DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
@@ -47,13 +52,36 @@ import { objectClassChain, parseSnapshotSubject, snapshotSubject, worldEpochFact
47
52
  export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
48
53
 
49
54
  /** The v1 cast. Keyed by role, never by species — every knob this engine reads
50
- * is role-keyed too, so swapping the pair is data. */
55
+ * is role-keyed too, so swapping the pair is data. `hunts` is the cast's own
56
+ * statement of who preys on whom, and it is the only thing the decision chain
57
+ * reads to tell a threat from a bystander. */
51
58
  export const MUDIII_ROLES = Object.freeze({
52
- predator: { role: "predator", kind: "fox", idPrefix: "fox" },
53
- prey: { role: "prey", kind: "goblin", idPrefix: "goblin" },
59
+ predator: { role: "predator", kind: "fox", idPrefix: "fox", hunts: "prey" },
60
+ prey: { role: "prey", kind: "goblin", idPrefix: "goblin", hunts: null },
54
61
  food: { spawnedKind: "crumb", placedKind: "morsel" },
55
62
  });
56
63
 
64
+ const CAST_ROLES = Object.freeze(["predator", "prey"]);
65
+
66
+ /** Which roles `role` hunts, as the cast declares it: a role name, a list of
67
+ * them, or null for a role that hunts nothing. A roles object that states no
68
+ * link at all keeps the pairing its two role names already name, so a cast
69
+ * written before `hunts` existed hunts exactly as it did. Pure. */
70
+ export function rolesHuntedBy(role, roles = MUDIII_ROLES) {
71
+ const entry = roles?.[role];
72
+ if (!entry) return [];
73
+ const declared = "hunts" in entry ? entry.hunts : (role === "predator" ? "prey" : null);
74
+ if (declared === null || declared === undefined) return [];
75
+ return [].concat(declared).filter((named) => CAST_ROLES.includes(named));
76
+ }
77
+
78
+ /** Which roles hunt `role`, read back off those same links. Empty for a role
79
+ * nothing preys on, which is what keeps a predator hunting when it catches
80
+ * sight of another predator rather than fleeing its own kind. Pure. */
81
+ export function rolesHunting(role, roles = MUDIII_ROLES) {
82
+ return CAST_ROLES.filter((other) => rolesHuntedBy(other, roles).includes(role));
83
+ }
84
+
57
85
  const PLACEMENT_PREDICATE = "mgx:currently-in";
58
86
  const MASS_PREDICATE = "mgx:hasMass";
59
87
  const MOOD_PREDICATE = "mgx:feels";
@@ -366,15 +394,14 @@ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter, tieBreakScoreO
366
394
  }
367
395
 
368
396
  /** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
369
- * terms from `awayFrom`. Both the prey's evade rung and the predator's avoid
370
- * rung are this function.
397
+ * terms from `awayFrom`. The evade rung is this function.
371
398
  *
372
399
  * `opts.towardCell`, when given, breaks a tie among equally-safe cells in
373
400
  * favor of whichever is closest to it — a fleeing prey that knows where food
374
401
  * is should flee toward it, not toward whichever direction DIRECTION_DELTA's
375
402
  * key order happens to check first. Opt-in and null by default, so a caller
376
- * that never passes it (the predator's own avoid rung) sees no change at
377
- * all: same options, same scores, same first-wins tie order. */
403
+ * that never passes it sees no change at all: same options, same scores, same
404
+ * first-wins tie order. */
378
405
  export function greedyAway(fromCell, awayFrom, applyActions, { towardCell = null } = {}) {
379
406
  if (!awayFrom) return fromCell;
380
407
  return bestOneStepBy(
@@ -396,6 +423,29 @@ export function greedyToward(fromCell, towardCell, applyActions) {
396
423
  );
397
424
  }
398
425
 
426
+ /** One-ply greedy over BOTH distances at once, rather than one rung after the
427
+ * other: the reachable cell (or staying put) that maximizes
428
+ *
429
+ * weight * distance-from-`awayFrom` - (1 - weight) * distance-to-`towardCell`
430
+ *
431
+ * At weight 1 this is greedyAway and at weight 0 it is greedyToward. In
432
+ * between it lets an agent take a step toward food that costs it little or no
433
+ * distance from the thing hunting it, which strict priority rungs cannot do.
434
+ *
435
+ * Falls back to whichever single term it still has when the other cell is
436
+ * null, so a caller never has to check first. */
437
+ export function greedyBlend(fromCell, awayFrom, towardCell, applyActions, weight) {
438
+ if (!awayFrom) return greedyToward(fromCell, towardCell, applyActions);
439
+ if (!towardCell) return greedyAway(fromCell, awayFrom, applyActions);
440
+ const w = Number.isFinite(Number(weight)) ? Number(weight) : DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
441
+ return bestOneStepBy(
442
+ fromCell, applyActions,
443
+ (cell) => w * chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y)
444
+ - (1 - w) * chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y),
445
+ (score, bestScore) => score > bestScore,
446
+ );
447
+ }
448
+
399
449
  /** A seeded, uniform pick among staying put or any one-ply reachable cell.
400
450
  * Deterministic and replayable, and it looks random to somebody watching.
401
451
  * Both roles' last rung: a motionless predator reads as a broken page. */
@@ -483,14 +533,13 @@ const round2 = (n) => Math.round(n * 100) / 100;
483
533
  // ---- the goal line and the mood word -------------------------------------------
484
534
  // Every branch assigns a mood beside the goal sentence it renders, and that
485
535
  // word is written as a real mgx:feels fact for the turn. The words are the four
486
- // spider-fly already uses: a predator mid-chase is angry, one avoiding a rival
487
- // is scared, anything wandering or foraging is calm, and anything that just ate
488
- // is happy.
536
+ // spider-fly already uses: an agent mid-chase is angry, one fleeing something
537
+ // that hunts it is scared, anything wandering or foraging is calm, and anything
538
+ // that just ate is happy.
489
539
 
490
540
  function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false, facing = null, held = false } = {}) {
491
541
  switch (kind) {
492
542
  case "driven": return held ? `driven by hand — holding at ${cell}, facing ${facing}.` : `driven by hand — stepping to ${cell}.`;
493
- case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
494
543
  case "chase":
495
544
  if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
496
545
  return catches ? `co-located with ${subject} — catching it.` : `standing over ${subject} — taking it.`;
@@ -874,22 +923,41 @@ export async function recastTownSquare(memoryDir, {
874
923
  });
875
924
  }
876
925
 
926
+ /** The opening cast, seeded: predators on open cells, prey on the perimeter
927
+ * they wander in from, and NO cell handed out twice.
928
+ *
929
+ * Every other placement path in this engine already refuses a cell something
930
+ * stands on — spawn-prey and spawn-food both filter the occupied set and
931
+ * simply don't spawn when nothing is left, and placeFood refuses a prop or
932
+ * another item. The page's own blocked-cell rule counts a single agent as
933
+ * enough to block a cell too, so a mint that stacked two would put the board
934
+ * in breach of the rule it opens under.
935
+ *
936
+ * Prey prefer the perimeter, but a perimeter with no room left puts one a
937
+ * cell inside rather than inside another animal. An agent goes unminted only
938
+ * when the whole board is full, which is the same answer spawn-prey gives. */
877
939
  function seededRoster(layout, { predators, prey, roles, epoch }) {
878
940
  const roster = {};
879
941
  const taken = new Set();
880
942
  const open = openCells(layout);
881
943
  const edge = perimeterCells(layout);
944
+ const pickFreeCell = (preferred, id) => {
945
+ const free = preferred.filter((c) => !taken.has(c));
946
+ const options = free.length ? free : open.filter((c) => !taken.has(c));
947
+ if (!options.length) return null;
948
+ return seededPick(options, seedKey(layout.name, epoch, 0, id, "spawn"));
949
+ };
882
950
  for (let i = 1; i <= predators; i += 1) {
883
951
  const id = `${roles.predator.idPrefix}-${i}`;
884
- const free = open.filter((c) => !taken.has(c));
885
- const cell = seededPick(free.length ? free : open, seedKey(layout.name, epoch, 0, id, "spawn"));
952
+ const cell = pickFreeCell(open, id);
953
+ if (!cell) break;
886
954
  taken.add(cell);
887
955
  roster[id] = { role: "predator", cell, facing: DEFAULT_FACING };
888
956
  }
889
957
  for (let i = 1; i <= prey; i += 1) {
890
958
  const id = `${roles.prey.idPrefix}-${i}`;
891
- const free = edge.filter((c) => !taken.has(c));
892
- const cell = seededPick(free.length ? free : edge, seedKey(layout.name, epoch, 0, id, "spawn"));
959
+ const cell = pickFreeCell(edge, id);
960
+ if (!cell) break;
893
961
  taken.add(cell);
894
962
  roster[id] = { role: "prey", cell, facing: DEFAULT_FACING };
895
963
  }
@@ -1023,7 +1091,7 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
1023
1091
  * `agents` and `items` and `ecology` are the frozen render payload — see
1024
1092
  * townSquareTickPayload, which projects exactly those three plus the turn.
1025
1093
  * `rungs` is the decision each live agent reached this turn ("driven" for a
1026
- * hand-driven move, then "chase", "evade", "forage", "avoid", "wander", and —
1094
+ * hand-driven move, then "chase", "evade", "forage", "wander", and —
1027
1095
  * on a cast that carries or spins webs —
1028
1096
  * "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
1029
1097
  * that decided and then died still has a rung and no longer has an `agents`
@@ -1053,6 +1121,10 @@ export async function runTownSquareTick(memoryDir, {
1053
1121
 
1054
1122
  const carriesPrey = config.carryPreyToWeb === true;
1055
1123
  const buildsWebs = config.buildWebs === true;
1124
+ const blendsPreyDecision = config.blendPreyDecision === true;
1125
+ const preyThreatWeight = Number.isFinite(Number(config.preyThreatWeight))
1126
+ ? Number(config.preyThreatWeight)
1127
+ : DEFAULT_GAME_CONFIG.mudiii.preyThreatWeight;
1056
1128
  const boardNoun = lay.boardNoun ?? "square";
1057
1129
  const webbedAt = (c) => hasActiveWebAt(lay, state, k, c.x, c.y, config.webDurationTurns);
1058
1130
  // Widened in place as predators spin webs this tick, for the renderer's own
@@ -1070,6 +1142,10 @@ export async function runTownSquareTick(memoryDir, {
1070
1142
  }
1071
1143
  }
1072
1144
 
1145
+ const liveIdsOfRole = { predator: predators, prey };
1146
+ const agentsOfRoles = (roleList, exceptId) =>
1147
+ roleList.flatMap((r) => liveIdsOfRole[r] ?? []).filter((id) => id !== exceptId).sort();
1148
+
1073
1149
  const decide = (agentId, role) => {
1074
1150
  const fromCell = parseCellId(state.placements.get(agentId).cell);
1075
1151
  // The visitor's hand, checked before any of the belief chain below. A
@@ -1090,8 +1166,14 @@ export async function runTownSquareTick(memoryDir, {
1090
1166
  // visionRadius: Infinity call, not new belief machinery. The rival-threat
1091
1167
  // lookup below stays on beliefOpts either way: this switch is about food.
1092
1168
  const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
1093
- const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
1094
- const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1169
+ // Who counts as a threat and who counts as quarry both come off the cast's
1170
+ // own hunts links. Nothing hunts a predator on either shipped board, so a
1171
+ // predator's threat list is empty and a second predator in view is just
1172
+ // another animal on the square.
1173
+ const huntedRoles = rolesHuntedBy(role, roles);
1174
+ const huntsAgents = huntedRoles.length > 0;
1175
+ const hunters = agentsOfRoles(rolesHunting(role, roles), agentId);
1176
+ const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, hunters, state, beliefOpts);
1095
1177
 
1096
1178
  let rung;
1097
1179
  let nextCell;
@@ -1100,8 +1182,7 @@ export async function runTownSquareTick(memoryDir, {
1100
1182
  let mood;
1101
1183
  let drivenFacing = null;
1102
1184
  // A carried prey and its captor both leave the ordinary chain. A carrying
1103
- // predator never drops its catch to dodge a rival or chase a second one:
1104
- // nothing here eats a predator, so "avoid" is contention, not survival.
1185
+ // predator never drops its catch to chase a second one.
1105
1186
  const carriedPreyId = (carriesPrey && role === "predator") ? (state.carrying.get(agentId)?.prey ?? null) : null;
1106
1187
  const isCarrying = Boolean(carriedPreyId) && prey.includes(carriedPreyId);
1107
1188
  const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
@@ -1152,24 +1233,47 @@ export async function runTownSquareTick(memoryDir, {
1152
1233
  goal = goalLine("trapped");
1153
1234
  mood = "scared";
1154
1235
  } else if (threat) {
1155
- rung = role === "predator" ? "avoid" : "evade";
1156
- // A fleeing prey that already knows where food is should flee toward
1157
- // it, not away from it — among cells that are equally safe, break the
1158
- // tie toward the nearest believed crumb. Prey-only: the predator's own
1159
- // avoid rung passes nothing, so its ties still resolve the old way.
1160
- const towardFood = role === "prey"
1161
- ? nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts)
1162
- : null;
1163
- nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
1164
- plan = stepPlan(fromCell, nextCell);
1165
- goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1166
- mood = "scared";
1236
+ const towardFood = huntsAgents
1237
+ ? null
1238
+ : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
1239
+ if (blendsPreyDecision && !huntsAgents && towardFood) {
1240
+ // The one case the two rungs disagree about: this prey believes a
1241
+ // predator AND food, so a strict order has to pick between them and a
1242
+ // score does not.
1243
+ nextCell = greedyBlend(fromCell, threat.cell, towardFood.cell, applyActions, preyThreatWeight);
1244
+ const closedOnFood = chebyshevDistance(fromCell.x, fromCell.y, towardFood.cell.x, towardFood.cell.y)
1245
+ > chebyshevDistance(nextCell.x, nextCell.y, towardFood.cell.x, towardFood.cell.y);
1246
+ // The score is one number, but the step it buys is still one of two
1247
+ // legible things: this move closed on the crumb, or it did not. The
1248
+ // rung and the goal line say which, so no surface has to render
1249
+ // "mostly evading, somewhat hungry".
1250
+ rung = closedOnFood ? "forage" : "evade";
1251
+ plan = stepPlan(fromCell, nextCell);
1252
+ goal = closedOnFood
1253
+ ? goalLine("forage", {
1254
+ subject: towardFood.subject,
1255
+ cell: cellId(towardFood.cell.x, towardFood.cell.y),
1256
+ arrived: nextCell.x === towardFood.cell.x && nextCell.y === towardFood.cell.y,
1257
+ })
1258
+ : goalLine("evade", { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1259
+ mood = closedOnFood ? "calm" : "scared";
1260
+ } else {
1261
+ rung = "evade";
1262
+ // An agent that already knows where food is should flee toward it, not
1263
+ // away from it — among cells that are equally safe, break the tie
1264
+ // toward the nearest believed crumb. An agent that hunts has no food
1265
+ // cell to offer, so its ties resolve on first-wins order alone.
1266
+ nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
1267
+ plan = stepPlan(fromCell, nextCell);
1268
+ goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
1269
+ mood = "scared";
1270
+ }
1167
1271
  } else {
1168
- const quarry = role === "predator"
1169
- ? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
1272
+ const quarry = huntsAgents
1273
+ ? nearestBelievedTarget(agentId, fromCell, agentsOfRoles(huntedRoles, agentId), state, beliefOpts)
1170
1274
  : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
1171
1275
  if (quarry) {
1172
- rung = role === "predator" ? "chase" : "forage";
1276
+ rung = huntsAgents ? "chase" : "forage";
1173
1277
  const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
1174
1278
  if (path && path.actions.length) {
1175
1279
  nextCell = path.states[1];
@@ -1183,7 +1287,7 @@ export async function runTownSquareTick(memoryDir, {
1183
1287
  }
1184
1288
  const arrived = nextCell.x === quarry.cell.x && nextCell.y === quarry.cell.y;
1185
1289
  goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived, catches: carriesPrey });
1186
- mood = role === "predator" ? "angry" : "calm";
1290
+ mood = huntsAgents ? "angry" : "calm";
1187
1291
  } else if (role === "predator" && buildsWebs) {
1188
1292
  // A web-spinning predator with nothing in sight has something better to
1189
1293
  // do than wander: hold this cell, and spin a web here unless a live one
@@ -19,8 +19,8 @@
19
19
  // renderResearchHtml() is pure: no I/O, deterministic output for identical
20
20
  // input. scripts/build-demo-site.mjs calls it directly and writes the result to
21
21
  // public/research.html, after research-browser.bundle.js already exists.
22
- import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
23
- import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
22
+ import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
23
+ import { fetchWithProgress, loadSeedPayload, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
24
24
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
25
25
  import { loadWinkVendor } from "./viz-boot.mjs";
26
26
  import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
@@ -108,7 +108,8 @@ ${DASH_DARK_CHROME_CSS}
108
108
  header.topbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.4rem; flex-wrap: wrap; padding: .6rem 0 1rem; border-bottom: 1px solid var(--line); margin-bottom: 1.3rem; }
109
109
  .brand { display: flex; flex-direction: column; gap: .3rem; max-width: 640px; }
110
110
  .eyebrow { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
111
- .subtitle { font-size: .92rem; color: var(--ink); opacity: .82; max-width: 58ch; }
111
+ ${EYEBROW_LINKS_CSS}
112
+ .subtitle { margin: 0; font-size: .92rem; font-weight: 400; color: var(--ink); opacity: .82; max-width: 58ch; }
112
113
  .statuspanel { display: flex; flex-wrap: wrap; gap: .5rem 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
113
114
  .statuspanel .stat { display: flex; flex-direction: column; gap: .14rem; min-width: 7rem; }
114
115
  .statuspanel .stat-label { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
@@ -240,8 +241,8 @@ ${DASH_DARK_CHROME_CSS}
240
241
  <div class="wrap">
241
242
  <header class="topbar">
242
243
  <div class="brand">
243
- <span class="eyebrow">the-mechanical-code-talker &middot; research</span>
244
- <span class="subtitle">Grow one graph three ways. Watch what it learns, then ask a question scoped to the sources you trust.</span>
244
+ <span class="eyebrow">${demoEyebrowHtml("research", "research")}</span>
245
+ <h1 class="subtitle">Grow one graph three ways. Watch what it learns, then ask a question scoped to the sources you trust.</h1>
245
246
  </div>
246
247
  <div class="statuspanel" id="statusPanel" aria-live="polite">
247
248
  <div class="stat stat-facts"><span class="stat-label">facts in the graph</span><span class="stat-value" id="statFacts">&mdash;</span></div>
@@ -353,6 +354,7 @@ ${DASH_DARK_CHROME_CSS}
353
354
  const sourceLabelFor = ${sourceLabelFor.toString()};
354
355
  const factTripleParts = ${factTripleParts.toString()};
355
356
  const fetchWithProgress = ${fetchWithProgress.toString()};
357
+ const loadSeedPayload = ${loadSeedPayload.toString()};
356
358
  const createTicker = ${createTicker.toString()};
357
359
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
358
360
  const loadWinkVendor = ${loadWinkVendor.toString()};
@@ -396,25 +398,17 @@ ${DASH_DARK_CHROME_CSS}
396
398
 
397
399
  let seedPayload = null;
398
400
  let seedFacts = 0;
399
- // One retry with a cache-busting query param: a CDN edge can serve a
400
- // corrupted or truncated precompressed response (a transient bad cache
401
- // entry, not a code defect real bytes decompress fine, and the same URL
402
- // fetched moments later is clean), and JSON.parse throwing is the only
403
- // signal of that. The bust param forces a fresh fetch past that one entry.
401
+ // The same starter memory chat.html and ingest.html load, reported the same
402
+ // way: tmct.seed carries whether it arrived, so a page with an empty store
403
+ // can say which kind of empty it is.
404
+ window.tmct.seed = { state: "loading", facts: 0 };
404
405
  async function fetchSeed() {
405
- for (let attempt = 1; attempt <= 2; attempt++) {
406
- try {
407
- const bust = attempt === 1 ? "" : (SEED_QUERY ? "&" : "?") + "retry=1";
408
- const blob = await fetchWithProgress("./chat-seed.json" + SEED_QUERY + bust, (loaded, total) => noteProgress("seed", loaded, total));
409
- seedPayload = JSON.parse(await blob.text());
410
- seedFacts = (seedPayload.individuals || []).filter((i) => i.class === "Fact").length;
411
- return;
412
- } catch (err) {
413
- if (attempt === 2) {
414
- seedPayload = null;
415
- console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
416
- }
417
- }
406
+ const outcome = await loadSeedPayload(fetchWithProgress, "./chat-seed.json", SEED_QUERY, (loaded, total) => noteProgress("seed", loaded, total));
407
+ seedPayload = outcome.payload;
408
+ seedFacts = outcome.status.facts;
409
+ window.tmct.seed = outcome.status;
410
+ if (outcome.status.state === "failed") {
411
+ console.error("tmct research: chat-seed.json unavailable — starting unseeded (" + outcome.status.error + ")");
418
412
  }
419
413
  }
420
414
  async function newSession() {
@@ -130,6 +130,12 @@ const SPIDER_FLY_SEE_RE = /^what (?:does|can) the (spider|fly)(?:-(\d+))?\s+see[
130
130
  const WORLD_OPENING_FALLBACK =
131
131
  "a spider waits in its web; a fly drifts in from the edge of the board. Neither is yours to move. Watch, or address one by name in chat.";
132
132
 
133
+ /** The word that steps the board, said on the OPENING turn. It used to appear
134
+ * only on re-entry, so a first-time player was told to watch a board that
135
+ * never moved. Appended to whatever opening the worlds pack carries, so the
136
+ * pack owns the scene and this owns the control. */
137
+ const ADVANCE_HINT = 'Say "tick" to advance a turn.';
138
+
133
139
  // ---- the opening turn: load the shipped board through the worlds pack -------
134
140
 
135
141
  async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
@@ -164,7 +170,7 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig
164
170
  const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1, config: gameConfig?.spiderFly });
165
171
  planHolder.state = { spiderFly: { turn: 0 } };
166
172
  const opener = started
167
- ? (payload.meta?.opening || WORLD_OPENING_FALLBACK)
173
+ ? `${payload.meta?.opening || WORLD_OPENING_FALLBACK} ${ADVANCE_HINT}`
168
174
  : 'back to the spider-and-fly board — the spider and fly are already in play. Say "tick" to advance, or address one, e.g. "@spider the fly is east".';
169
175
  return {
170
176
  text: opener,
@@ -32,7 +32,7 @@
32
32
  // bundle's own real ES exports instead, since (unlike ledger-viz, which
33
33
  // reuses a FIXED shared bundle it can't extend for one page's own needs) this
34
34
  // page ships its own dedicated bundle and can just export what it needs.
35
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml } from "./viz-theme.mjs";
35
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
36
36
  import { createTicker } from "./viz-ticker.mjs";
37
37
  import { loadWinkVendor } from "./viz-boot.mjs";
38
38
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId, agentKindOf } from "../domain/spider-fly-world.mjs";
@@ -250,6 +250,7 @@ ${THEME_TOKENS_CSS}
250
250
  .mono { font-family: ${MONO_STACK}; }
251
251
  main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
252
252
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--chrome-accent); }
253
+ ${EYEBROW_LINKS_CSS}
253
254
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
254
255
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
255
256
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@@ -443,7 +444,7 @@ ${THEME_TOKENS_CSS}
443
444
  <main>
444
445
  <div class="stage page-head">
445
446
  <div class="head-inner">
446
- <div class="eyebrow">tmct &middot; spider and fly</div>
447
+ <div class="eyebrow">${demoEyebrowHtml("spider-fly", "spider and fly")}</div>
447
448
  <h1>Multiple competing planning agents</h1>
448
449
  </div>
449
450
  <div></div>
@@ -1007,12 +1008,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1007
1008
  chatqEl.focus();
1008
1009
  });
1009
1010
  }
1011
+ // A direction pill REPLACES the claim rather than appending to it — unlike
1012
+ // adventure.html/mud.html's claim pills, which are meant to compose ("look
1013
+ // at" then "the book"), these four are mutually exclusive readings of the
1014
+ // SAME fact (where one agent is), so a second click was building
1015
+ // "@spider the fly is north the fly is east", which the grammar has never
1016
+ // accepted and never will: a thing has one position, not several at once.
1017
+ // The address prefix survives a direction click (so "@fly" then a
1018
+ // direction still addresses the fly), the same way it already did before
1019
+ // this fix — only the claim after it is replaced rather than grown.
1010
1020
  for (const btn of directionPillEls) {
1011
1021
  btn.addEventListener("click", () => {
1012
1022
  const kind = addresseeKindOf(chatqEl.value) || "spider";
1013
- let value = chatqEl.value;
1014
- if (!addresseeKindOf(value)) value = "@" + kind + " " + value.trimStart();
1015
- chatqEl.value = value.replace(/\\s+$/, "") + " " + btn.textContent;
1023
+ chatqEl.value = "@" + kind + " " + btn.textContent;
1016
1024
  refreshPills();
1017
1025
  chatqEl.focus();
1018
1026
  });
@@ -51,7 +51,7 @@ import { spriteFactRows } from "../domain/sprite-facts.mjs";
51
51
  import { SEED_TAXONOMY } from "../domain/spider-fly-world.mjs";
52
52
  import { loadSlice, loadMap, toFacts, WORDNET_DIR } from "../adapters/corpus/conceptnet.mjs";
53
53
  import { join } from "node:path";
54
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
54
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, demoEyebrowHtml, EYEBROW_LINKS_CSS } from "./viz-theme.mjs";
55
55
 
56
56
  const DEFAULT_TITLE = "tmct — the sprite library";
57
57
  const MAX_CHAIN_DISPLAY = 6;
@@ -905,6 +905,7 @@ ${THEME_TOKENS_CSS}
905
905
  .appbar { display: flex; align-items: flex-end; gap: 1rem; margin: 0 -1.2rem; padding: .6rem 1.2rem 0; background: var(--ai-bar); }
906
906
  .appbar h1 { font-family: ${MONO_STACK}; font-size: .84rem; font-weight: 600; letter-spacing: .02em; margin: 0; padding: .32rem .8rem .38rem; background: var(--ai-panel); color: var(--ink); border-radius: 4px 4px 0 0; }
907
907
  .appbar .doc-sub { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .07em; text-transform: uppercase; color: color-mix(in srgb, var(--ai-bar-ink) 65%, transparent); padding-bottom: .5rem; }
908
+ ${EYEBROW_LINKS_CSS}
908
909
  .topbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .9rem; background: var(--ai-panel); border-bottom: 1px solid var(--ai-edge); margin: 0 -1.2rem 1.4rem; padding: .5rem 1.2rem; }
909
910
  .jump { font-family: ${MONO_STACK}; font-size: .7rem; padding: .2rem .6rem; border: 1px solid var(--ai-edge); border-radius: 3px; background: transparent; color: var(--ink); text-decoration: none; }
910
911
  .jump:hover { border-color: var(--corpus); color: var(--corpus); }
@@ -982,7 +983,7 @@ ${dockCss}</style>
982
983
  <main>
983
984
  <header class="appbar">
984
985
  <h1>Sprites</h1>
985
- <span class="doc-sub">tmct &middot; the sprite library</span>
986
+ <span class="doc-sub">${demoEyebrowHtml("sprites", "the sprite library")}</span>
986
987
  </header>
987
988
  ${composerHtml}
988
989
  ${dockHtml}
@@ -32,6 +32,26 @@ export function embedScriptText(js) {
32
32
  return String(js ?? "").replaceAll("</script", "<\\/script");
33
33
  }
34
34
 
35
+ /** The three-link nav every demo page's header opens with: the site's own
36
+ * name (home), the demo's own name (its own page) and "about" (its about
37
+ * page). `page` is the demo's key in scripts/site-pages.mjs's DEMO_PAGES,
38
+ * which is also its filename stem (e.g. "mud" -> mud.html); the about-page
39
+ * suffix below mirrors that same file's `aboutPageOf`, restated rather than
40
+ * imported since scripts/ sits outside src/ and never ships with the
41
+ * published package. Returns the inner markup only — the caller supplies
42
+ * the wrapping element (div/h1/span) and keeps whatever class and
43
+ * neighbouring markup its own header already carries. Style hook:
44
+ * EYEBROW_LINKS_CSS. */
45
+ export function demoEyebrowHtml(page, label) {
46
+ return `<span class="eyebrow-links"><a href="./index.html">tmct</a> &middot; <a href="./${page}.html">${escapeHtml(label)}</a> &middot; <a href="./${page}-about.html">about</a></span>`;
47
+ }
48
+
49
+ /** Styling every page that calls demoEyebrowHtml splices into its own inline
50
+ * <style>: the links read in the eyebrow's own colour (each page sets that
51
+ * colour itself), underlining only on hover/focus. */
52
+ export const EYEBROW_LINKS_CSS = `.eyebrow-links a { color: inherit; text-decoration: none; }
53
+ .eyebrow-links a:hover, .eyebrow-links a:focus-visible { text-decoration: underline; }`;
54
+
35
55
  /** A world name read as a name in a scenario dropdown: "mud-garden" ->
36
56
  * "mud garden". The world's own hyphenated id is the only thing every caller
37
57
  * is guaranteed to have, so a scenario that wants a hand-written label passes
@@ -55,6 +55,10 @@ export function createTicker({
55
55
  } = {}) {
56
56
  const state = { playing: false, animating: false };
57
57
  const render = () => onRender({ ...state });
58
+ // The promise of whatever onTick() call is currently in flight, or null
59
+ // between ticks — reset() awaits this (see below) rather than bailing out
60
+ // when a click lands mid-tick, so a reset can never be silently dropped.
61
+ let inFlight = null;
58
62
 
59
63
  /** Advance exactly one step, honoring `hasNext`/`animating` guards. Returns
60
64
  * whether it actually advanced. */
@@ -62,7 +66,9 @@ export function createTicker({
62
66
  if (state.animating || !hasNext()) return false;
63
67
  state.animating = true;
64
68
  render();
65
- await onTick();
69
+ inFlight = Promise.resolve(onTick());
70
+ await inFlight;
71
+ inFlight = null;
66
72
  state.animating = false;
67
73
  render();
68
74
  return true;
@@ -95,9 +101,16 @@ export function createTicker({
95
101
  render();
96
102
  }
97
103
 
104
+ /** Stop and rewind. `state.playing` drops immediately, even mid-tick, so
105
+ * `play()`'s own loop can never start one more tick once this has been
106
+ * called — a reset clicked while a tick is animating used to return here
107
+ * before touching `playing` at all, which left the loop running right
108
+ * past it. If a tick IS in flight, this waits for it to actually settle
109
+ * (never calling `onReset` while `onTick` might still be touching the
110
+ * same state/DOM) before rewinding, rather than dropping the reset. */
98
111
  async function reset() {
99
- if (state.animating) return;
100
112
  state.playing = false;
113
+ if (inFlight) await inFlight;
101
114
  if (onReset) await onReset();
102
115
  render();
103
116
  }