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

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.
@@ -497,32 +497,41 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
497
497
  <button type="button" class="deck-info-btn" id="deckInfoBtn" aria-expanded="false" aria-controls="deckInfoPopup" aria-label="about this demo">?</button>
498
498
  <span class="mono deck-turns" id="globalTurnCount">turns: 0</span>
499
499
  </div>
500
- <div class="deck-sliders">
501
- <label class="deck-slider">foxes
502
- <input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
503
- value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
504
- list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
505
- <datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
506
- <span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
507
- </label>
508
- <label class="deck-slider">goblins
509
- <input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
510
- value="${DEFAULT_NPC_COUNT}"
511
- list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
512
- <datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
513
- const n = NPC_COUNT_MIN + i;
514
- return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
515
- }).join("")}</datalist>
516
- <span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
517
- </label>
518
- <label class="deck-slider">delay
519
- <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
520
- <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
521
- </label>
522
- <label class="deck-slider">max turns
523
- <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
524
- <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
525
- </label>
500
+ <div class="deck-body">
501
+ <div class="deck-sliders">
502
+ <label class="deck-slider">foxes
503
+ <input type="range" id="playerCountSlider" min="0" max="${PLAYER_COUNTS.length - 1}" step="1"
504
+ value="${Math.max(0, PLAYER_COUNTS.indexOf(DEFAULT_PLAYER_COUNT))}"
505
+ list="playerCountTicks" aria-valuetext="${DEFAULT_PLAYER_COUNT} foxes">
506
+ <datalist id="playerCountTicks">${PLAYER_COUNTS.map((n, i) => `<option value="${i}" label="${n}"></option>`).join("")}</datalist>
507
+ <span class="mono" id="playerCountValue">${DEFAULT_PLAYER_COUNT}</span>
508
+ </label>
509
+ <label class="deck-slider">goblins
510
+ <input type="range" id="npcCountSlider" min="${NPC_COUNT_MIN}" max="${NPC_COUNT_MAX}" step="1"
511
+ value="${DEFAULT_NPC_COUNT}"
512
+ list="npcCountTicks" aria-valuetext="${DEFAULT_NPC_COUNT} goblins">
513
+ <datalist id="npcCountTicks">${Array.from({ length: NPC_COUNT_MAX - NPC_COUNT_MIN + 1 }, (_, i) => {
514
+ const n = NPC_COUNT_MIN + i;
515
+ return NPC_COUNT_LABELLED.includes(n) ? `<option value="${n}" label="${n}"></option>` : `<option value="${n}"></option>`;
516
+ }).join("")}</datalist>
517
+ <span class="mono" id="npcCountValue">${DEFAULT_NPC_COUNT}</span>
518
+ </label>
519
+ <label class="deck-slider">delay
520
+ <input type="range" id="delaySlider" min="80" max="2000" step="20" value="${DEFAULT_DELAY_MS}">
521
+ <span class="mono" id="delayValue">${DEFAULT_DELAY_MS}ms</span>
522
+ </label>
523
+ <label class="deck-slider">max turns
524
+ <input type="range" id="maxTurnsSlider" min="20" max="2000" step="20" value="${DEFAULT_MAX_TURNS}">
525
+ <span class="mono" id="maxTurnsValue">${DEFAULT_MAX_TURNS}</span>
526
+ </label>
527
+ </div>
528
+ <section class="map-panel" id="mapPanel" aria-label="the town square, from above">
529
+ <div class="map-panel-head">
530
+ <span class="map-panel-title">the square, from above</span>
531
+ <span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
532
+ </div>
533
+ <div class="map-panel-board" id="mapPanelBoard"></div>
534
+ </section>
526
535
  </div>
527
536
  <div class="deck-camera">
528
537
  <label class="deck-slider">follow
@@ -542,13 +551,6 @@ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${es
542
551
  <button type="button" class="deck-info-popup-close" id="deckInfoClose" aria-label="close">&times;</button>
543
552
  </div>
544
553
  </section>
545
- <section class="map-panel" id="mapPanel" aria-label="the town square, from above">
546
- <div class="map-panel-head">
547
- <span class="map-panel-title">the square, from above</span>
548
- <span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
549
- </div>
550
- <div class="map-panel-board" id="mapPanelBoard"></div>
551
- </section>
552
554
  </div>
553
555
  <section class="scene-stage" id="sceneStage" aria-label="the town square, in three dimensions">
554
556
  <canvas id="sceneCanvas"></canvas>
@@ -628,7 +630,7 @@ const MUDIII_STYLE = `
628
630
  }
629
631
  .mudiii-topbar-help:hover { border-color: var(--square-accent); }
630
632
 
631
- .deck-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: stretch; margin-bottom: 1rem; }
633
+ .deck-row { margin-bottom: 1rem; }
632
634
  .deck {
633
635
  position: relative;
634
636
  background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
@@ -656,9 +658,10 @@ const MUDIII_STYLE = `
656
658
  .deck-play { background: var(--square-ink) !important; color: var(--parchment); border-color: var(--square-ink) !important; padding: .38rem 1.1rem !important; }
657
659
  .deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
658
660
  .deck-turns { margin-left: auto; font-size: .74rem; color: var(--square-stone-dark); background: var(--square-stone-dark); background: rgba(43,35,24,.9); color: var(--square-accent); border-radius: 2px; padding: .1rem .5rem; }
659
- .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; }
660
- .deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
661
- .deck-slider input[type="range"] { accent-color: var(--square-accent); width: 8rem; max-width: 34vw; }
661
+ .deck-body { display: flex; gap: .7rem; align-items: flex-start; }
662
+ .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; flex: 1 1 auto; min-width: 0; }
663
+ .deck-slider { display: flex; align-items: center; gap: .35rem; font-family: ${MONO_STACK}; font-size: .62rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); min-width: 0; }
664
+ .deck-slider input[type="range"] { accent-color: var(--square-accent); flex: 1 1 4rem; min-width: 2.5rem; width: auto; max-width: 8rem; }
662
665
  .camera-mode { display: inline-flex; gap: .25rem; }
663
666
  .camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
664
667
  .deck-info-popup {
@@ -678,13 +681,14 @@ const MUDIII_STYLE = `
678
681
 
679
682
  .map-panel {
680
683
  background: var(--square-stone-dark); color: var(--parchment);
681
- border: 1px solid var(--square-accent); border-radius: 4px; padding: .55rem .65rem .6rem;
682
- display: flex; flex-direction: column; gap: .4rem; min-width: 0;
684
+ border: 1px solid var(--square-accent); border-radius: 4px; padding: .5rem .55rem .55rem;
685
+ display: flex; flex-direction: column; gap: .35rem; min-width: 0;
686
+ flex: 0 0 50%; max-width: 50%;
683
687
  }
684
- .map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; }
685
- .map-panel-title { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .12em; opacity: .85; }
686
- .map-panel-turn { font-size: .62rem; opacity: .7; }
687
- .map-panel-board { position: relative; flex: 1; min-height: 200px; background: rgba(124,154,91,.25); border: 1px solid rgba(233,217,182,.35); border-radius: 3px; }
688
+ .map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .4rem; }
689
+ .map-panel-title { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
690
+ .map-panel-turn { font-size: .58rem; opacity: .7; }
691
+ .map-panel-board { position: relative; flex: 1; min-height: 110px; aspect-ratio: 1; background: rgba(124,154,91,.25); border: 1px solid rgba(233,217,182,.35); border-radius: 3px; }
688
692
  .map-dot { position: absolute; width: .55rem; height: .55rem; margin: -.28rem 0 0 -.28rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
689
693
  .map-dot-predator { background: var(--square-predator); }
690
694
  .map-dot-prey { background: var(--square-prey); }
@@ -716,7 +720,6 @@ const MUDIII_STYLE = `
716
720
 
717
721
  .edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; margin-bottom: 1rem; }
718
722
  body.editing .mudiii-chat, body.editing .scene-stage, body.editing .hud-row, body.editing .deck-row .map-panel { display: none; }
719
- body.editing .deck-row { grid-template-columns: 1fr; }
720
723
  body.editing .edit-stage { display: grid; }
721
724
  #editModeBtn[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); }
722
725
  .edit-text, .edit-panel {
@@ -769,10 +772,22 @@ const MUDIII_STYLE = `
769
772
  .pill.affordance[aria-pressed="true"] { background: var(--square-accent); border-style: solid; }
770
773
 
771
774
  @media (max-width: 900px) {
772
- .deck-row { grid-template-columns: 1fr; }
773
775
  .edit-stage { grid-template-columns: 1fr; }
774
776
  #editorText { min-height: 16rem; }
775
777
  }
778
+
779
+ /* A landscape phone and a narrow desktop window are both under 900px but
780
+ want different slider/map arrangements, so the split has to key off
781
+ orientation as well as width. */
782
+ @media (max-width: 900px) and (orientation: landscape) {
783
+ .deck-sliders { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem 1rem; }
784
+ .deck-body { align-items: stretch; }
785
+ .map-panel { flex-basis: 33%; max-width: 33%; }
786
+ /* The square aspect-ratio that suits a tall portrait column would blow
787
+ the map back up to full column width in a short landscape viewport —
788
+ here it follows the two-row slider stack's own height instead. */
789
+ .map-panel-board { aspect-ratio: auto; min-height: 90px; }
790
+ }
776
791
  `;
777
792
 
778
793
  /** The inlined page script, spliced the same way mud-viz.mjs's own
@@ -813,6 +828,7 @@ function pageScript() {
813
828
  const el = (id) => document.getElementById(id);
814
829
  let scenarioIndex = 0;
815
830
  const scenario = function () { return DATA.scenarios[scenarioIndex]; };
831
+ const gridSizeOf = function () { return scenario().gridSize || DATA.gridSize; };
816
832
  const rosterOf = function (s, role) {
817
833
  return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
818
834
  };
@@ -881,6 +897,9 @@ function pageScript() {
881
897
 
882
898
  function applyTickResult(result) {
883
899
  if (!result) return;
900
+ // The engine owns the count. Anything that advances a turn — the deck, a
901
+ // chat frame — lands here, so the page never keeps a rival tally.
902
+ if (typeof result.turn === "number") globalTurn = result.turn;
884
903
  if (result.agents) agentsById = result.agents;
885
904
  if (result.items) itemsById = result.items;
886
905
  callScene("applyTick", { agents: result.agents, items: result.items, ecology: result.ecology });
@@ -892,8 +911,7 @@ function pageScript() {
892
911
  async function runOneTick() {
893
912
  return serializeTick(async function () {
894
913
  if (!session) return null;
895
- globalTurn += 1;
896
- const result = await session.tick(globalTurn);
914
+ const result = await session.tick();
897
915
  applyTickResult(result);
898
916
  renderAll();
899
917
  return result;
@@ -921,8 +939,16 @@ function pageScript() {
921
939
  function sendCommand(line) {
922
940
  appendChat("u", line);
923
941
  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) {
942
+ // A chat line can run a real turn. An addressed told-fact does, and the
943
+ // visitor should watch the lie land, so the board is read back in the same
944
+ // queue slot. board() spends no turn, so a line that ran none costs
945
+ // nothing. It reports no goal or plan either, because a resting board has
946
+ // decided nothing, which is the blank boot() already draws at turn 0.
947
+ return serializeTick(async function () {
948
+ const res = await tmct.turn(line);
949
+ const board = await session.board();
925
950
  appendChat("a", res.answer);
951
+ applyTickResult(board);
926
952
  renderAll();
927
953
  return res;
928
954
  });
@@ -1027,7 +1053,7 @@ function pageScript() {
1027
1053
 
1028
1054
  // ---- the top-down map panel ---------------------------------------------
1029
1055
  function renderMapPanel() {
1030
- const dots = mapDotsFor(agentsList(), itemsList(), DATA.gridSize);
1056
+ const dots = mapDotsFor(agentsList(), itemsList(), gridSizeOf());
1031
1057
  el("mapPanelBoard").innerHTML = dots.map(function (d) {
1032
1058
  return '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
1033
1059
  }).join("");
@@ -1216,7 +1242,7 @@ function pageScript() {
1216
1242
  agentsById = {};
1217
1243
  itemsById = {};
1218
1244
  el("chatInput").disabled = false;
1219
- await callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: DATA.gridSize, cellSize: 1 });
1245
+ await callScene("boot", { propPlacements: props, assetManifest: DATA.assetManifest, gridSize: gridSizeOf(), cellSize: 1 });
1220
1246
 
1221
1247
  // The opening board, drawn through the very path a tick takes. Without
1222
1248
  // this the page's first sight of where anything stands is the first tick,
@@ -1225,7 +1251,6 @@ function pageScript() {
1225
1251
  // the cells both come back from it rather than being guessed here.
1226
1252
  const opening = await session.board();
1227
1253
  if (seq !== bootSeq) return;
1228
- globalTurn = opening.turn || 0;
1229
1254
  camera.selectedId = Object.keys(opening.agents || {}).sort()[0] || null;
1230
1255
  applyTickResult(opening);
1231
1256
  renderAll();
@@ -58,6 +58,12 @@ const PLACEMENT_PREDICATE = "mgx:currently-in";
58
58
  const MASS_PREDICATE = "mgx:hasMass";
59
59
  const MOOD_PREDICATE = "mgx:feels";
60
60
  const FACING_PREDICATE = "mgx:facing";
61
+ // The facing a visitor's own hand turned an agent to, written only on a driven
62
+ // turn. It folds into the same facing map as the ordinary row below, which is
63
+ // what makes a hand turn hold while the agent stands still and lose the moment
64
+ // the planner takes a step of its own: the step's own facing row is stamped
65
+ // with a later turn, and a later turn outranks.
66
+ const DRIVEN_FACING_PREDICATE = "mgx:driven-facing";
61
67
  const EATEN_BY_PREDICATE = "mgx:eaten-by";
62
68
  const STARVED_PREDICATE = "mgx:starved";
63
69
  const PLACED_BY_PREDICATE = "mgx:placed-by";
@@ -86,7 +92,7 @@ const TURN_PLAYED_PREDICATE = "mgx:turn-played";
86
92
  const BOARD_SUBJECT = "square";
87
93
 
88
94
  const MUDIII_STATE_PREDICATE_SET = new Set([
89
- PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
95
+ PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE, DRIVEN_FACING_PREDICATE,
90
96
  EATEN_BY_PREDICATE, STARVED_PREDICATE, PLACED_BY_PREDICATE,
91
97
  MODEL_PREDICATE, ROTATION_PREDICATE, TURN_PLAYED_PREDICATE,
92
98
  CARRYING_PREDICATE, PREY_EATEN_PREDICATE, WEB_BUILT_PREDICATE,
@@ -208,6 +214,7 @@ export function foldTownSquareState(factRows) {
208
214
  if (snap && rowEpoch === epoch) tickCount = Math.max(tickCount, turn);
209
215
  break;
210
216
  case FACING_PREDICATE:
217
+ case DRIVEN_FACING_PREDICATE:
211
218
  if (outranks(rowEpoch, turn, facing.get(base))) facing.set(base, { value: row.object, turn, epoch: rowEpoch });
212
219
  break;
213
220
  case EATEN_BY_PREDICATE:
@@ -448,6 +455,29 @@ function stepPlan(fromCell, toCell) {
448
455
  return direction ? [direction] : [];
449
456
  }
450
457
 
458
+ /**
459
+ * The hand-driven move `entry` asks of an agent standing at `fromCell`, as
460
+ * `{ cell, facing }` with `cell` a parsed cell — or null when nothing was
461
+ * asked, or when what was asked is not a legal one-step move.
462
+ *
463
+ * `entry` is a target cell id, or `{ cell, facing }` with both parts optional:
464
+ * no cell holds the agent where it stands, which is what a turn on the spot
465
+ * is, and no facing leaves the facing to the step itself.
466
+ *
467
+ * The legality table is oneStepOptions — the very list the wander, evade and
468
+ * chase rungs pick from — so a wall or a prop is a missing exit here too,
469
+ * never a second notion of blocked, and a refusal reads as "no such move"
470
+ * rather than as a rule this seam invented. Pure.
471
+ */
472
+ function acceptedManualMove(entry, fromCell, applyActions) {
473
+ if (!entry) return null;
474
+ const request = typeof entry === "string" ? { cell: entry } : entry;
475
+ const facing = typeof request.facing === "string" && request.facing ? request.facing : null;
476
+ const target = typeof request.cell === "string" && request.cell ? request.cell : cellId(fromCell.x, fromCell.y);
477
+ const cell = oneStepOptions(fromCell, applyActions).find((option) => cellId(option.x, option.y) === target);
478
+ return cell ? { cell, facing } : null;
479
+ }
480
+
451
481
  const round2 = (n) => Math.round(n * 100) / 100;
452
482
 
453
483
  // ---- the goal line and the mood word -------------------------------------------
@@ -457,8 +487,9 @@ const round2 = (n) => Math.round(n * 100) / 100;
457
487
  // is scared, anything wandering or foraging is calm, and anything that just ate
458
488
  // is happy.
459
489
 
460
- function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false } = {}) {
490
+ function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false, facing = null, held = false } = {}) {
461
491
  switch (kind) {
492
+ case "driven": return held ? `driven by hand — holding at ${cell}, facing ${facing}.` : `driven by hand — stepping to ${cell}.`;
462
493
  case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
463
494
  case "chase":
464
495
  if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
@@ -980,11 +1011,20 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
980
1011
  * to where the predator was when it looked), while eating resolves on the
981
1012
  * post-move ones (it is caught where the predator actually ends up).
982
1013
  *
1014
+ * `manualMoves` is the one place a visitor's own hand reaches the world:
1015
+ * `{ agentId: cellId }`, or `{ agentId: { cell, facing } }`, checked before
1016
+ * that agent's belief chain runs. A legal request moves the agent under the
1017
+ * `driven` rung; an illegal one is refused and that agent decides for itself
1018
+ * this turn, so a rejected press never freezes it. A driven turn spends a
1019
+ * turn like any other: the ecology pass runs, and every other agent decides
1020
+ * and moves in this same call.
1021
+ *
983
1022
  * Returns `{ turn, epoch, agents, items, ecology, rungs, activeWebs, writes }`.
984
1023
  * `agents` and `items` and `ecology` are the frozen render payload — see
985
1024
  * townSquareTickPayload, which projects exactly those three plus the turn.
986
- * `rungs` is the decision each live agent reached this turn ("chase", "evade",
987
- * "forage", "avoid", "wander", and — on a cast that carries or spins webs —
1025
+ * `rungs` is the decision each live agent reached this turn ("driven" for a
1026
+ * hand-driven move, then "chase", "evade", "forage", "avoid", "wander", and —
1027
+ * on a cast that carries or spins webs —
988
1028
  * "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
989
1029
  * that decided and then died still has a rung and no longer has an `agents`
990
1030
  * entry, which is the difference between a decision and a survivor.
@@ -993,6 +1033,7 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
993
1033
  */
994
1034
  export async function runTownSquareTick(memoryDir, {
995
1035
  layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
1036
+ manualMoves = {},
996
1037
  } = {}) {
997
1038
  const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
998
1039
  if (!lay) throw new Error(`runTownSquareTick: no such layout "${layout}"`);
@@ -1031,6 +1072,11 @@ export async function runTownSquareTick(memoryDir, {
1031
1072
 
1032
1073
  const decide = (agentId, role) => {
1033
1074
  const fromCell = parseCellId(state.placements.get(agentId).cell);
1075
+ // The visitor's hand, checked before any of the belief chain below. A
1076
+ // refused request leaves `driven` null, and the chain then runs exactly as
1077
+ // it would have on a turn nobody touched.
1078
+ const driven = acceptedManualMove(manualMoves?.[agentId], fromCell, applyActions);
1079
+ const restingFacing = state.facing.get(agentId)?.value ?? DEFAULT_FACING;
1034
1080
  const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
1035
1081
  const beliefOpts = { visionRadius, toldFacts };
1036
1082
  // True unless a caller explicitly turns it off. A config object built by
@@ -1045,13 +1091,14 @@ export async function runTownSquareTick(memoryDir, {
1045
1091
  // lookup below stays on beliefOpts either way: this switch is about food.
1046
1092
  const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
1047
1093
  const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
1048
- const threat = nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1094
+ const threat = driven ? null : nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
1049
1095
 
1050
1096
  let rung;
1051
1097
  let nextCell;
1052
1098
  let plan;
1053
1099
  let goal;
1054
1100
  let mood;
1101
+ let drivenFacing = null;
1055
1102
  // A carried prey and its captor both leave the ordinary chain. A carrying
1056
1103
  // predator never drops its catch to dodge a rival or chase a second one:
1057
1104
  // nothing here eats a predator, so "avoid" is contention, not survival.
@@ -1060,7 +1107,18 @@ export async function runTownSquareTick(memoryDir, {
1060
1107
  const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
1061
1108
  let beliefCell = fromCell;
1062
1109
 
1063
- if (isCarrying && webbedAt(fromCell)) {
1110
+ if (driven) {
1111
+ rung = "driven";
1112
+ nextCell = driven.cell;
1113
+ plan = stepPlan(fromCell, nextCell);
1114
+ drivenFacing = driven.facing;
1115
+ goal = goalLine("driven", {
1116
+ cell: cellId(nextCell.x, nextCell.y),
1117
+ facing: driven.facing ?? restingFacing,
1118
+ held: plan.length === 0,
1119
+ });
1120
+ mood = "calm";
1121
+ } else if (isCarrying && webbedAt(fromCell)) {
1064
1122
  // Already standing in a web: hold, so the pass's own eat gate reads this
1065
1123
  // same cell and resolves the delivery on this exact tick.
1066
1124
  rung = "deliver";
@@ -1166,7 +1224,10 @@ export async function runTownSquareTick(memoryDir, {
1166
1224
  const foodCandidates = beliefCandidates.filter((id) => foodIds.has(id));
1167
1225
  Object.assign(belief, beliefSnapshotFor(agentId, beliefCell, foodCandidates, state, foodBeliefOpts));
1168
1226
  }
1169
- const facing = plan[0] ?? state.facing.get(agentId)?.value ?? DEFAULT_FACING;
1227
+ // A hand-picked facing beats the step it came with, which is what lets the
1228
+ // page walk an agent backwards without spinning it round.
1229
+ const facing = drivenFacing ?? plan[0] ?? restingFacing;
1230
+ if (rung === "driven") movementWrites.push({ subject: stamp(agentId), predicate: DRIVEN_FACING_PREDICATE, object: facing });
1170
1231
  postMovePlacements.set(agentId, nextCell);
1171
1232
  rungs[agentId] = rung;
1172
1233
  agents[agentId] = { role, cell: cellId(nextCell.x, nextCell.y), facing, goal, mood, plan, mass: 0, belief };
@@ -1,18 +1,17 @@
1
- // spider-fly-viz.mjs — the spider-and-fly full-screen page (PLAN_SPIDER_FLY.md
2
- // §8/§9/§11): a self-contained document shaped exactly like ledger-viz.mjs/
3
- // plan-viz.mjs — one inlined <style> (importing viz-theme.mjs's shared
4
- // tokens), behaviour as an inlined IIFE but unlike those two, almost
5
- // nothing is embedded as build-time data: the whole game is LIVE client-side
6
- // state (spider-fly-browser-entry.mjs's createSpiderFlySession), so
7
- // renderSpiderFlyHtml only needs the page's own static grid geometry (which
8
- // never changes) plus a title. The engine, sprite resolver and chat turn
9
- // engine all arrive via ./spider-fly-browser.bundle.js, referenced with a
10
- // plain same-origin <script src> the same sibling-file arrangement
11
- // index.html already uses for chat-browser.bundle.js (both are the FULL
12
- // turn engine, both generated fresh per build, neither meant to be
13
- // committed), not memory-ask-browser.bundle.js's inlined-text arrangement
14
- // (that bundle is small and inlined specifically so ledger.html stays
15
- // portable on its own — that reason doesn't apply here).
1
+ // spider-fly-viz.mjs — the spider-and-fly full-screen page: a self-contained
2
+ // document shaped exactly like ledger-viz.mjs/plan-viz.mjs — one inlined <style>
3
+ // (importing viz-theme.mjs's shared tokens), behaviour as an inlined IIFE — but
4
+ // unlike those two, almost nothing is embedded as build-time data: the whole
5
+ // game is LIVE client-side state (spider-fly-browser-entry.mjs's
6
+ // createSpiderFlySession), so renderSpiderFlyHtml only needs the page's own
7
+ // static grid geometry (which never changes) plus a title. The engine, sprite
8
+ // resolver and chat turn engine all arrive via ./spider-fly-browser.bundle.js,
9
+ // referenced with a plain same-origin <script src> — the same sibling-file
10
+ // arrangement index.html already uses for chat-browser.bundle.js (both are the
11
+ // FULL turn engine, both generated fresh per build, neither meant to be
12
+ // committed), not memory-ask-browser.bundle.js's inlined-text arrangement (that
13
+ // bundle is small and inlined specifically so ledger.html stays portable on its
14
+ // own that reason doesn't apply here).
16
15
  //
17
16
  // renderSpiderFlyHtml() is pure: no I/O, deterministic output for identical
18
17
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
@@ -125,19 +124,17 @@ export function facingDegreesFor(plan, previousDegrees) {
125
124
  }
126
125
 
127
126
  /**
128
- * The updated corpse set for one redraw (§A.2.5 — visual-only, entirely
129
- * client-side; the actual starve/eat removal already happened in the
130
- * engine). Every id present in `prevAgents` but absent from `agents` died
131
- * THIS tick — eaten or starved are the only two ways an agent ever leaves
132
- * the engine's own returned roster — and is added at its last-known cell
133
- * and class; every corpse already older than `lingerTurns` past its own
134
- * death turn is dropped first, so the set never grows without bound.
135
- * Returns a plain `{ [id]: { cls, cell, diedAtTurn } }` map. Pure.
136
- * `lingerTurns` defaults to a literal 4 (not the module-level
137
- * CORPSE_LINGER_TURNS constant) so this function stays fully
138
- * `.toString()`-splice safe every real caller (both the inlined page and
139
- * CORPSE_LINGER_TURNS's own callers elsewhere in this module) passes it
140
- * explicitly anyway.
127
+ * The updated corpse set for one redraw — visual-only, entirely client-side;
128
+ * the actual starve/eat removal already happened in the engine. Every id
129
+ * present in `prevAgents` but absent from `agents` died THIS tick — eaten or
130
+ * starved are the only two ways an agent ever leaves the engine's own returned
131
+ * roster — and is added at its last-known cell and class; every corpse already
132
+ * older than `lingerTurns` past its own death turn is dropped first, so the set
133
+ * never grows without bound. Returns a plain `{ [id]: { cls, cell, diedAtTurn }
134
+ * }` map. Pure. `lingerTurns` defaults to a literal 4 (not the module-level
135
+ * CORPSE_LINGER_TURNS constant) so this function stays fully `.toString()`-splice
136
+ * safe every real caller (both the inlined page and CORPSE_LINGER_TURNS's own
137
+ * callers elsewhere in this module) passes it explicitly anyway.
141
138
  */
142
139
  export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns = 4) {
143
140
  const out = {};
@@ -166,9 +163,9 @@ export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns =
166
163
  * back to the flat SPRITE_REGISTRY, unchanged from before this module
167
164
  * existed).
168
165
  * `?preview=1` on the page's own URL switches it into the small, auto-
169
- * playing, non-interactive mode the home page's hero iframe embeds (§11)
170
- * one file serves both the hero and the "open full-screen" link, matching
171
- * how ledger.html/plan.html are each one file embedded two ways.
166
+ * playing, non-interactive mode the home page's hero iframe embeds one file
167
+ * serves both the hero and the "open full-screen" link, matching how
168
+ * ledger.html/plan.html are each one file embedded two ways.
172
169
  * `engineBundleJs` (the built spider-fly-browser bundle's own text) inlines
173
170
  * the engine into the page instead of the sibling `<script src>`, for the
174
171
  * CLI's standalone export — one downloadable file that runs from file://
@@ -344,11 +341,11 @@ ${THEME_TOKENS_CSS}
344
341
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
345
342
  .hud-goal { font-size: .85rem; }
346
343
  .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .25rem; line-height: 1.4; padding-left: .5rem; border-left: 2px solid var(--chrome-accent); }
347
- /* the click-expand facts panel (§28): beside the clicked spider/fly's own
348
- row, never a separate popover or a second panel elsewhere on the page —
349
- the same believedCellOf/beliefSnapshotFor read path spider-fly-turn.mjs
350
- already computes every tick for planning, rendered here as full
351
- sentences instead of the compact believes:-line above. */
344
+ /* the click-expand facts panel: beside the clicked spider/fly's own row,
345
+ never a separate popover or a second panel elsewhere on the page — the same
346
+ believedCellOf/beliefSnapshotFor read path spider-fly-turn.mjs already
347
+ computes every tick for planning, rendered here as full sentences instead of
348
+ the compact believes:-line above. */
352
349
  .hud-detail { flex: 1 1 auto; min-width: 0; font-family: ${MONO_STACK}; font-size: .64rem; line-height: 1.5; color: var(--chrome-well-ink); background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 2px; padding: .35rem .5rem; }
353
350
  .hud-detail-title { text-transform: uppercase; letter-spacing: .06em; opacity: .75; margin-bottom: .2rem; }
354
351
  /* A stat-readout track: an inset "LCD" well, filled with a segmented pip
@@ -378,11 +375,11 @@ ${THEME_TOKENS_CSS}
378
375
  .pill:hover:not(:disabled) { border-color: var(--chrome-accent); }
379
376
  .pill:disabled { opacity: .45; cursor: default; }
380
377
  .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
381
- /* The dynamic deception-pill rail (§A.2.4): a true/false tag shown ONLY
382
- here, via border style/color and a small human-facing glyph — the
383
- submitted sentence itself (data-sentence, filled into #chatq on click)
384
- never carries the tag, so a clicked pill is indistinguishable from a
385
- hand-typed claim once it's in the input. */
378
+ /* The dynamic deception-pill rail: a true/false tag shown ONLY here, via
379
+ border style/color and a small human-facing glyph — the submitted sentence
380
+ itself (data-sentence, filled into #chatq on click) never carries the tag,
381
+ so a clicked pill is indistinguishable from a hand-typed claim once it's in
382
+ the input. */
386
383
  .dynpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--chrome-edge-lo); }
387
384
  .dynpills:empty { display: none; padding-top: 0; border-top: none; }
388
385
  .pill[data-role="dyn-addr"][data-active="1"] { border-color: var(--taught); color: var(--taught); }
@@ -745,13 +742,13 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
745
742
  lastAgents = agents;
746
743
  }
747
744
 
748
- // ---- corpses (§A.2.5): visual-only — the actual starve/eat removal
749
- // already happened in the engine before this redraw ever sees "agents".
750
- // A corpse sinks to the bottom row of the SAME board column it died in
751
- // ("drops to the bottom" — spider-fly-world.mjs's own x/y convention,
752
- // y = GRID_SIZE is the bottom row) and fades out once CORPSE_LINGER_TURNS
753
- // passes, drawn in the same sprite layer as every live sprite, just
754
- // grayscaled and non-interactive (see the .sprite.corpse CSS rule).
745
+ // ---- corpses: visual-only — the actual starve/eat removal already happened
746
+ // in the engine before this redraw ever sees "agents". A corpse sinks to the
747
+ // bottom row of the SAME board column it died in ("drops to the bottom" —
748
+ // spider-fly-world.mjs's own x/y convention, y = GRID_SIZE is the bottom row)
749
+ // and fades out once CORPSE_LINGER_TURNS passes, drawn in the same sprite
750
+ // layer as every live sprite, just grayscaled and non-interactive (see the
751
+ // .sprite.corpse CSS rule).
755
752
  function renderCorpses() {
756
753
  for (const id of Object.keys(corpseEls)) {
757
754
  if (!corpses[id]) { corpseEls[id].remove(); delete corpseEls[id]; }
@@ -793,12 +790,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
793
790
  return '<div class="hud-plan">plan: ' + text + ".</div>";
794
791
  }
795
792
 
796
- // The agent's own current world-knowledge-graph snapshot (§A.2.7) — every
797
- // OTHER live individual it believes it knows the position of, or
798
- // "unseen" when it has no belief at all. Deliberately never ground truth:
799
- // this is what the agent would actually ACT on, which a false pill or a
800
- // told fact can make visibly wrong compared to where that individual
801
- // really is — the gap IS the demonstration.
793
+ // The agent's own current world-knowledge-graph snapshot — every OTHER live
794
+ // individual it believes it knows the position of, or "unseen" when it has no
795
+ // belief at all. Deliberately never ground truth: this is what the agent
796
+ // would actually ACT on, which a false pill or a told fact can make visibly
797
+ // wrong compared to where that individual really is — the gap IS the
798
+ // demonstration.
802
799
  function beliefLineHtml(belief) {
803
800
  const entries = Object.entries(belief || {});
804
801
  if (!entries.length) return "";
@@ -1023,12 +1020,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1023
1020
  chatqEl.addEventListener("input", refreshPills);
1024
1021
  refreshPills();
1025
1022
 
1026
- // ---- deception pills (§A.2.4): a SEPARATE dynamic rail, alongside (never
1027
- // replacing) the static one above. tmct.page.pillsForSpiderFly is the
1028
- // exact same pure function spider-fly-turn.mjs exports — this page never
1029
- // reimplements the true/false claim logic, only renders its output and
1030
- // fills #chatq on click, same click-to-fill discipline as every other
1031
- // pill on this page (never auto-submits).
1023
+ // ---- deception pills: a SEPARATE dynamic rail, alongside (never replacing)
1024
+ // the static one above. tmct.page.pillsForSpiderFly is the exact same pure
1025
+ // function spider-fly-turn.mjs exports — this page never reimplements the
1026
+ // true/false claim logic, only renders its output and fills #chatq on click,
1027
+ // same click-to-fill discipline as every other pill on this page (never
1028
+ // auto-submits).
1032
1029
  function renderDynamicPills() {
1033
1030
  if (!session || !Object.keys(lastAgents).length) { dynamicPillsEl.innerHTML = ""; return; }
1034
1031
  const result = tmct.page.pillsForSpiderFly(lastAgents, selectedAddresseeId, {});