@polycode-projects/the-mechanical-code-talker 5.0.4 → 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
@@ -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 };