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

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.
@@ -8,13 +8,10 @@
8
8
  // mud.html's burrow survey.
9
9
  //
10
10
  // This module owns the PAGE SHELL only. The 3D scene itself — the actual
11
- // three.js renderer, the model loader, the per-frame camera update — is a
12
- // concurrent track's file, src/services/mudiii-scene.mjs, reached through
13
- // exactly one frozen function: `mudiiiSceneScript(opts) -> string`, a
14
- // standalone inline <script> this page embeds next to its own. That module
15
- // does not exist in every worktree yet (see the guarded import below), so
16
- // this file also ships a "" stub until it lands — no edit needed here when
17
- // it does.
11
+ // three.js renderer, the model loader, the per-frame camera update — lives in
12
+ // src/services/mudiii-scene.mjs, reached through exactly one frozen function:
13
+ // `mudiiiSceneScript(opts) -> string`, a standalone inline <script> this page
14
+ // embeds next to its own.
18
15
  //
19
16
  // The contract runs both ways. Scene -> shell: the scene script calls
20
17
  // `window.mudiiiHandleSceneClick(cellId)` on a raycast hit. Shell -> scene:
@@ -49,23 +46,21 @@
49
46
  // was actually asking for.
50
47
  import {
51
48
  THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, scenarioLabel,
52
- rowsForWorld, appendLogLine,
49
+ rowsForWorld, appendLogLine, wordBeforeCursor, demoEyebrowHtml, EYEBROW_LINKS_CSS,
53
50
  } from "./viz-theme.mjs";
54
- import { createTicker, createSerialQueue } from "./viz-ticker.mjs";
51
+ import { createTicker, createSerialQueue, prefersReducedMotion } from "./viz-ticker.mjs";
55
52
  import { renderMudEditorText, gridWorldEditorState } from "./mud-editor.mjs";
56
53
  import {
57
54
  pillCandidates, matchPills, pillCompleteMarkup, createPillComplete, PILL_COMPLETE_CSS,
58
55
  } from "./pill-complete.mjs";
59
56
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
57
+ import { believedFactSentence } from "./mudiii-turn.mjs";
60
58
 
61
59
  // The scene module and this one import each other: this file embeds the
62
60
  // scene's generated IIFE, and the scene splices this file's pure geometry
63
- // helpers into it. A static cycle is fine here because every binding crossing
64
- // it is a hoisted function declaration that nothing calls at module-evaluation
65
- // time — the same shape world-teach.mjs and adventure.mjs already rely on. It
66
- // must NOT be a top-level `await import()`: two modules awaiting each other at
67
- // evaluation time never settle, and the failure is a silent hang rather than
68
- // an error.
61
+ // helpers into it. The cycle holds because every binding crossing it is a
62
+ // hoisted function declaration that nothing calls at module-evaluation time —
63
+ // the same shape world-teach.mjs and adventure.mjs already rely on.
69
64
  import { mudiiiSceneScript } from "./mudiii-scene.mjs";
70
65
 
71
66
  const DEFAULT_TITLE = "tmct — mudiii";
@@ -89,6 +84,23 @@ const DEFAULT_MAX_TURNS = 400;
89
84
  const DEFAULT_GRID_SIZE = 12;
90
85
  const DEFAULT_FACING = "south";
91
86
  const CAMERA_MODES = ["follow", "pov", "overhead"];
87
+ // The ring reads ABSOLUTE, not relative to whichever way an agent happens to
88
+ // face: every other direction word on this page is a compass point (a told
89
+ // fact says "the goblin is east", the map is north-up), and driveRequest takes
90
+ // a compass point directly — a cardinal steps and faces that way, an
91
+ // intercardinal turns on the spot. Ordered north-first, clockwise.
92
+ const RING_POINTS = [
93
+ "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest",
94
+ ];
95
+ // mud.html's own glyph vocabulary, widened to the four diagonals. The reading
96
+ // differs from mud's: its ring lights every available exit, because a room has
97
+ // a fixed handful. An open grid grants a step almost everywhere, so lighting
98
+ // what is available would light nearly all of it and say nothing. The one lit
99
+ // glyph here is the followed agent's own facing.
100
+ const DIR_GLYPH = Object.freeze({
101
+ north: "▲ N", northeast: "↗", east: "E ▶", southeast: "↘",
102
+ south: "▼ S", southwest: "↙", west: "◀ W", northwest: "↖",
103
+ });
92
104
 
93
105
  const MUDIII_NOTE_LINES = [
94
106
  "One fox and a handful of goblins share a town square, rendered in three dimensions rather than mud.html's flat rooms. The foxes slider picks how many predators are cast; the goblins slider adds more prey. Nothing here is a player either — you watch from whichever camera you pick.",
@@ -336,6 +348,30 @@ export function mapDotsFor(agents, items, gridSize) {
336
348
  return dots;
337
349
  }
338
350
 
351
+ /** Every static prop's own filled cell for the 2D map panel, as percentage
352
+ * coordinates within the same square board `mapDotsFor` draws into
353
+ * (`{ id, xPct, yPct, sizePct }[]`). `props` is `propPlacementsFrom`'s own
354
+ * output. A block is drawn from the cell's own top-left corner and fills it,
355
+ * so the offset is `- 1` where `mapDotsFor`'s dot, centred on the cell, takes
356
+ * `- 0.5`. A placement with no parseable cell is dropped rather than drawn at
357
+ * a guessed position. Pure, self-contained. */
358
+ export function mapBlocksFor(props, gridSize) {
359
+ const size = Number(gridSize);
360
+ if (!Number.isFinite(size) || size <= 0) return [];
361
+ const blocks = [];
362
+ for (const prop of props || []) {
363
+ const match = /^cell-(\d+)-(\d+)$/.exec(String(prop && prop.cell != null ? prop.cell : ""));
364
+ if (!match) continue;
365
+ blocks.push({
366
+ id: prop.id,
367
+ xPct: ((Number(match[1]) - 1) / size) * 100,
368
+ yPct: ((Number(match[2]) - 1) / size) * 100,
369
+ sizePct: 100 / size,
370
+ });
371
+ }
372
+ return blocks;
373
+ }
374
+
339
375
  /** One HUD card's own field set, read off `agent` (`{ id, role, goal, mood,
340
376
  * plan, mass, belief }`, this turn's slice of the tick payload) and a
341
377
  * resolved `mudiiiConfig` (DEFAULT_GAME_CONFIG.mudiii's own shape). `massPct`
@@ -381,6 +417,7 @@ export function clipForAction(role, action, clipMap) {
381
417
  const kindFor = {
382
418
  wander: "walk",
383
419
  forage: "walk",
420
+ driven: "walk",
384
421
  chase: "run",
385
422
  evade: "run",
386
423
  "eat-agent": role === "predator" ? "attack" : "death",
@@ -412,7 +449,11 @@ export function agentCardMarkup(slot) {
412
449
  <div class="hud-meter" id="${w}-meter"><div class="hud-meter-fill" id="${w}-meter-fill"></div></div>
413
450
  <p class="hud-goal" id="${w}-goal"></p>
414
451
  <p class="hud-plan mono" id="${w}-plan"></p>
415
- <p class="hud-belief mono" id="${w}-belief"></p>
452
+ <button type="button" class="hud-belief-toggle" id="${w}-belief-toggle"
453
+ aria-expanded="false" aria-controls="${w}-detail" hidden>
454
+ <span class="hud-belief mono" id="${w}-belief"></span>
455
+ </button>
456
+ <div class="hud-detail mono" id="${w}-detail" hidden></div>
416
457
  </div>`;
417
458
  }
418
459
 
@@ -481,7 +522,7 @@ ${PILL_COMPLETE_CSS}
481
522
  <body>
482
523
  <main>
483
524
  <header class="mudiii-topbar">
484
- <h1 class="eyebrow">tmct &middot; mudiii</h1>
525
+ <h1 class="eyebrow">${demoEyebrowHtml("mudiii", "mudiii")}</h1>
485
526
  <a class="mudiii-topbar-help" href="./help.html" target="_blank" rel="noopener"
486
527
  title="how this demo works, in a new tab" aria-label="how this demo works, opens in a new tab">?</a>
487
528
  </header>
@@ -531,20 +572,32 @@ ${scenarioList.map((s, i) => ` <option value="${i}"${i === 0 ? " select
531
572
  <span class="mono map-panel-turn" id="mapPanelTurn">turn 0</span>
532
573
  </div>
533
574
  <div class="map-panel-board" id="mapPanelBoard"></div>
575
+ <div class="map-legend">
576
+ <span class="map-key"><i class="map-swatch map-swatch-predator"></i>predator</span>
577
+ <span class="map-key"><i class="map-swatch map-swatch-prey"></i>prey</span>
578
+ <span class="map-key"><i class="map-swatch map-swatch-food"></i>food</span>
579
+ <span class="map-key"><i class="map-swatch map-swatch-prop"></i>building</span>
580
+ </div>
534
581
  </section>
535
582
  </div>
536
583
  <div class="deck-camera">
537
584
  <label class="deck-slider">follow
538
- <select id="agentSelect" class="deck-select" aria-label="which agent to follow">
585
+ <select id="agentSelect" class="deck-select" aria-label="which agent to follow"
586
+ aria-describedby="agentSelectHint">
539
587
  ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${escapeHtml(a.id)}</option>`).join("\n")}
540
588
  </select>
541
589
  </label>
590
+ <span class="deck-hint" id="agentSelectHint" hidden>pause to swap</span>
542
591
  <div class="camera-mode" id="cameraMode" role="group" aria-label="camera mode">
543
592
  <button type="button" data-mode="follow" aria-pressed="true">follow</button>
544
593
  <button type="button" data-mode="pov" aria-pressed="false">pov</button>
545
594
  <button type="button" data-mode="overhead" aria-pressed="false">overhead</button>
546
595
  </div>
547
596
  <button type="button" class="pill affordance" id="foodPill" data-command="place food" aria-pressed="false">place food</button>
597
+ <label class="deck-teach" title="With this on, a sentence like &quot;The fox is at cell-3-4.&quot; writes a fact into the square instead of running as a command.">
598
+ <input type="checkbox" id="teachToggle">
599
+ teach
600
+ </label>
548
601
  </div>
549
602
  <div class="deck-info-popup mudiii-note" id="deckInfoPopup" role="dialog" aria-label="about this demo" hidden>
550
603
  ${MUDIII_NOTE_LINES.map((line) => `<p>${escapeHtml(line)}</p>`).join("\n ")}
@@ -554,9 +607,11 @@ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${es
554
607
  </div>
555
608
  <section class="scene-stage" id="sceneStage" aria-label="the town square, in three dimensions">
556
609
  <canvas id="sceneCanvas"></canvas>
610
+ <div class="dir-ring" id="driveRing" role="group" aria-label="walk the agent the camera follows" hidden>
611
+ ${RING_POINTS.map((point) => ` <span class="dir-slot dir-${point}"><button type="button" class="dir-pill" data-drive="${point}" title="walk ${point}" aria-label="walk ${point}" aria-pressed="false">${escapeHtml(DIR_GLYPH[point])}</button></span>`).join("\n")}
612
+ </div>
557
613
  <p class="scene-status" id="sceneStatus" role="status"></p>
558
614
  </section>
559
- <div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
560
615
  <div class="edit-stage" id="mudiiiEditStage" aria-label="the square's own facts, in plain sentences">
561
616
  <section class="edit-text" aria-label="the world's facts as editable sentences">
562
617
  <h2>the square, in plain sentences</h2>
@@ -586,11 +641,12 @@ ${openingAgents.map((a) => ` <option value="${escapeHtml(a.id)}">${es
586
641
  <span class="prompt mono">tmct&gt;</span>
587
642
  ${pillCompleteMarkup({
588
643
  inputId: "chatInput",
589
- inputHtml: '<input id="chatInput" type="text" placeholder="@fox-1 look" aria-label="type a command" disabled>',
644
+ inputHtml: '<input id="chatInput" type="text" placeholder="@fox the goblin is east" aria-label="type a command" disabled>',
590
645
  })}
591
646
  </form>
592
647
  </div>
593
648
  </section>
649
+ <div class="hud-row" id="hudRow" aria-label="every agent's own status"></div>
594
650
  </main>
595
651
  <script>
596
652
  const MUDIII_PAGE_DATA = ${pageData};
@@ -617,6 +673,7 @@ const MUDIII_STYLE = `
617
673
  .mono { font-family: ${MONO_STACK}; }
618
674
  main { max-width: 1280px; margin: 0 auto; padding: 1.1rem 1.2rem 2.4rem; }
619
675
  .eyebrow { font-family: ${MONO_STACK}; font-weight: 500; font-size: .72rem; letter-spacing: .16em; text-transform: uppercase; color: var(--square-ink); opacity: .85; margin: 0; }
676
+ ${EYEBROW_LINKS_CSS}
620
677
  h2 { font-family: ${DISPLAY_STACK}; font-size: 1rem; margin: 0; }
621
678
  h3 { font-family: ${MONO_STACK}; font-size: .58rem; margin: 0 0 .3rem; text-transform: uppercase; letter-spacing: .12em; color: var(--square-stone-dark); }
622
679
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
@@ -653,8 +710,17 @@ const MUDIII_STYLE = `
653
710
  font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
654
711
  padding: .32rem .7rem; border: 1px solid var(--square-stone-dark); border-radius: 3px;
655
712
  background: rgba(255,255,255,.5); color: var(--square-ink);
713
+ /* A select is as wide as its longest option, and a square's label runs to
714
+ "town square (12x12, 1 fox, 3 goblins)" — on a phone that alone made the
715
+ whole page scroll sideways. */
716
+ min-width: 0; max-width: 100%;
656
717
  }
718
+ #scenarioSelect { flex: 1 1 9rem; }
657
719
  .deck-select:hover { border-color: var(--square-accent); }
720
+ .deck-select:disabled { opacity: .45; cursor: default; }
721
+ .deck-select:disabled:hover { border-color: var(--square-stone-dark); }
722
+ .deck-hint { font-family: ${MONO_STACK}; font-size: .58rem; text-transform: uppercase; letter-spacing: .08em; color: var(--square-stone-dark); }
723
+ .deck-hint[hidden] { display: none; }
658
724
  .deck-play { background: var(--square-ink) !important; color: var(--parchment); border-color: var(--square-ink) !important; padding: .38rem 1.1rem !important; }
659
725
  .deck-play[aria-pressed="true"] { background: var(--square-accent) !important; border-color: var(--square-accent) !important; color: var(--square-ink); }
660
726
  .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; }
@@ -662,6 +728,8 @@ const MUDIII_STYLE = `
662
728
  .deck-sliders { display: flex; flex-wrap: wrap; gap: 1rem; flex: 1 1 auto; min-width: 0; }
663
729
  .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
730
  .deck-slider input[type="range"] { accent-color: var(--square-accent); flex: 1 1 4rem; min-width: 2.5rem; width: auto; max-width: 8rem; }
731
+ .deck-teach { display: flex; align-items: center; gap: .3rem; font-family: ${MONO_STACK}; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--square-stone-dark); cursor: pointer; }
732
+ .deck-teach input[type="checkbox"] { accent-color: var(--square-accent); }
665
733
  .camera-mode { display: inline-flex; gap: .25rem; }
666
734
  .camera-mode button[aria-pressed="true"] { background: var(--square-accent); border-color: var(--square-accent); color: var(--square-ink); }
667
735
  .deck-info-popup {
@@ -688,11 +756,35 @@ const MUDIII_STYLE = `
688
756
  .map-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: .4rem; }
689
757
  .map-panel-title { font-family: ${MONO_STACK}; font-size: .54rem; text-transform: uppercase; letter-spacing: .1em; opacity: .85; }
690
758
  .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; }
759
+ .map-panel-board {
760
+ position: relative; flex: 1; min-height: 110px; aspect-ratio: 1;
761
+ --map-cell-pct: 8.3333%;
762
+ background-color: rgba(124,154,91,.25);
763
+ background-image:
764
+ repeating-linear-gradient(90deg, rgba(233,217,182,.22) 0 1px, transparent 1px var(--map-cell-pct)),
765
+ repeating-linear-gradient(180deg, rgba(233,217,182,.22) 0 1px, transparent 1px var(--map-cell-pct));
766
+ border: 1px solid rgba(233,217,182,.35); border-radius: 3px;
767
+ }
768
+ .map-block { position: absolute; box-sizing: border-box; background: rgba(89,80,63,.9); border: 1px solid rgba(0,0,0,.35); border-radius: 1px; }
692
769
  .map-dot { position: absolute; width: .55rem; height: .55rem; margin: -.28rem 0 0 -.28rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
693
770
  .map-dot-predator { background: var(--square-predator); }
694
771
  .map-dot-prey { background: var(--square-prey); }
695
772
  .map-dot-crumb, .map-dot-morsel, .map-dot-item { background: var(--square-accent); width: .34rem; height: .34rem; margin: -.17rem 0 0 -.17rem; }
773
+ .map-label {
774
+ position: absolute; margin: -.66rem 0 0 .26rem; font-size: .44rem; line-height: 1; letter-spacing: .02em;
775
+ color: var(--parchment); text-shadow: 0 1px 2px rgba(0,0,0,.85); white-space: nowrap; pointer-events: none;
776
+ }
777
+ .map-label-left { margin-left: -.3rem; transform: translateX(-100%); }
778
+ /* Plain inline-block swatches, never .map-dot: that class is absolutely
779
+ positioned with a centring margin, so a legend reusing it would position
780
+ against the board and disappear. */
781
+ .map-legend { display: flex; flex-wrap: wrap; gap: .12rem .5rem; font-family: ${MONO_STACK}; font-size: .5rem; text-transform: uppercase; letter-spacing: .08em; opacity: .85; }
782
+ .map-key { display: inline-flex; align-items: center; gap: .24rem; }
783
+ .map-swatch { display: inline-block; width: .45rem; height: .45rem; border-radius: 50%; border: 1px solid rgba(0,0,0,.4); }
784
+ .map-swatch-predator { background: var(--square-predator); }
785
+ .map-swatch-prey { background: var(--square-prey); }
786
+ .map-swatch-food { background: var(--square-accent); }
787
+ .map-swatch-prop { background: rgba(89,80,63,.9); border-radius: 1px; }
696
788
 
697
789
  .scene-stage { position: relative; margin-bottom: 1rem; border: 1px solid var(--square-stone-dark); border-radius: 4px; overflow: hidden; background: #10161B; min-height: 360px; }
698
790
  .scene-stage canvas { display: block; width: 100%; height: 360px; }
@@ -703,7 +795,31 @@ const MUDIII_STYLE = `
703
795
  }
704
796
  .scene-status:empty { display: none; }
705
797
 
706
- .hud-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-bottom: 1rem; }
798
+ /* Each press sits where it points, mud.html's own ring idiom. */
799
+ .dir-ring { position: absolute; inset: .3rem; pointer-events: none; }
800
+ .dir-ring[hidden] { display: none; }
801
+ .dir-slot { position: absolute; pointer-events: auto; }
802
+ .dir-north { top: 0; left: 50%; transform: translateX(-50%); }
803
+ .dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
804
+ .dir-west { left: 0; top: 50%; transform: translateY(-50%); }
805
+ .dir-east { right: 0; top: 50%; transform: translateY(-50%); }
806
+ .dir-northwest { top: 0; left: 0; }
807
+ .dir-northeast { top: 0; right: 0; }
808
+ .dir-southwest { bottom: 0; left: 0; }
809
+ .dir-southeast { bottom: 0; right: 0; }
810
+ .dir-pill {
811
+ font-family: ${MONO_STACK}; font-size: .58rem; letter-spacing: .06em; line-height: 1;
812
+ padding: .24rem .42rem; border-radius: 2px; border: 1px solid var(--square-stone-dark);
813
+ background: var(--parchment); color: var(--square-ink); white-space: nowrap;
814
+ }
815
+ .dir-pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
816
+ .dir-pill:disabled { opacity: .35; cursor: default; }
817
+ .dir-pill[aria-pressed="true"] {
818
+ background: var(--square-accent); border-color: var(--square-stone-dark);
819
+ box-shadow: 0 0 0 2px rgba(217,138,43,.4);
820
+ }
821
+
822
+ .hud-row { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 1rem; }
707
823
  .hud-card {
708
824
  flex: 1 1 220px; min-width: 200px; max-width: 320px;
709
825
  background: var(--parchment); border: 1px solid var(--square-stone-dark); border-radius: 4px;
@@ -716,6 +832,14 @@ const MUDIII_STYLE = `
716
832
  .hud-meter-fill { height: 100%; background: var(--square-accent); width: 0%; transition: width .3s ease; }
717
833
  .hud-goal { margin: 0; font-size: .74rem; }
718
834
  .hud-plan, .hud-belief { margin: 0; font-size: .62rem; color: var(--square-stone-dark); }
835
+ .hud-belief-toggle { display: flex; align-items: baseline; gap: .25rem; width: 100%; text-align: left; padding: 0; border: 0; background: none; }
836
+ .hud-belief-toggle[hidden] { display: none; }
837
+ .hud-belief-toggle .hud-belief { flex: 1; min-width: 0; }
838
+ .hud-belief-toggle::after { content: "\\25BE"; font-size: .55rem; color: var(--square-stone-dark); }
839
+ .hud-belief-toggle[aria-expanded="true"]::after { content: "\\25B4"; }
840
+ .hud-belief-toggle:hover .hud-belief, .hud-belief-toggle:hover::after { color: var(--square-ink); }
841
+ .hud-detail { display: flex; flex-direction: column; gap: .1rem; font-size: .6rem; color: var(--square-stone-dark); border-top: 1px solid rgba(0,0,0,.12); padding-top: .25rem; }
842
+ .hud-detail[hidden] { display: none; }
719
843
  @media (prefers-reduced-motion: reduce) { .hud-meter-fill { transition: none; } }
720
844
 
721
845
  .edit-stage { display: none; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 1rem; align-items: start; margin-bottom: 1rem; }
@@ -770,6 +894,14 @@ const MUDIII_STYLE = `
770
894
  .pill:hover:not(:disabled) { border-color: var(--square-accent); background: var(--square-accent); }
771
895
  .pill.affordance { border-style: dashed; border-color: var(--square-stone); }
772
896
  .pill.affordance[aria-pressed="true"] { background: var(--square-accent); border-style: solid; }
897
+ /* The tick and the cross live in ::before so the tag stays on the screen and
898
+ out of the submitted sentence — a clicked lie must be indistinguishable
899
+ from a typed one by the time the lane reads it. */
900
+ .pill[data-role="dyn-addr"][data-active="1"] { border-color: var(--taught); color: var(--taught); }
901
+ .pill[data-role="dyn-claim"][data-truth="true"] { border-color: var(--taught); }
902
+ .pill[data-role="dyn-claim"][data-truth="true"]::before { content: "\\2713 "; opacity: .55; }
903
+ .pill[data-role="dyn-claim"][data-truth="false"] { border-style: dashed; border-color: var(--alert); }
904
+ .pill[data-role="dyn-claim"][data-truth="false"]::before { content: "\\2715 "; opacity: .6; }
773
905
 
774
906
  @media (max-width: 900px) {
775
907
  .edit-stage { grid-template-columns: 1fr; }
@@ -788,6 +920,27 @@ const MUDIII_STYLE = `
788
920
  here it follows the two-row slider stack's own height instead. */
789
921
  .map-panel-board { aspect-ratio: auto; min-height: 90px; }
790
922
  }
923
+
924
+ /* Half the deck is right on a phone and absurd on a 2000px window: a
925
+ percentage has no ceiling, so a square board grew to roughly 950px tall
926
+ and pushed the 3D view off the screen. Wide viewports get an absolute
927
+ size instead, and keep the square — there is room for it here.
928
+
929
+ The map is also taller than the sliders beside it, which left a tall
930
+ empty stripe of parchment under them. On a wide screen the deck becomes
931
+ one grid so the map can stand beside the sliders AND the camera row at
932
+ once, and the space under the controls closes up. display:contents lifts
933
+ .deck-sliders and .map-panel out of .deck-body so both are grid items of
934
+ the deck itself. */
935
+ @media (min-width: 901px) {
936
+ .deck { display: grid; grid-template-columns: minmax(0, 1fr) 240px; grid-template-rows: auto 1fr auto; column-gap: .8rem; }
937
+ .deck-controls { grid-column: 1 / -1; grid-row: 1; }
938
+ .deck-body { display: contents; }
939
+ .deck-sliders { grid-column: 1; grid-row: 2; align-self: start; }
940
+ .deck-camera { grid-column: 1; grid-row: 3; align-self: end; }
941
+ .map-panel { grid-column: 2; grid-row: 2 / 4; flex: 0 0 auto; max-width: none; align-self: start; }
942
+ }
943
+
791
944
  `;
792
945
 
793
946
  /** The inlined page script, spliced the same way mud-viz.mjs's own
@@ -803,12 +956,14 @@ function pageScript() {
803
956
  const DATA = MUDIII_PAGE_DATA;
804
957
  const createTicker = ${createTicker.toString()};
805
958
  const createSerialQueue = ${createSerialQueue.toString()};
959
+ const prefersReducedMotion = ${prefersReducedMotion.toString()};
806
960
  const escapeHtml = ${escapeHtml.toString()};
807
961
  const esc = escapeHtml;
808
962
  const appendLogLine = ${appendLogLine.toString()};
809
963
  const rowsForWorld = ${rowsForWorld.toString()};
810
964
  const renderMudEditorText = ${renderMudEditorText.toString()};
811
965
  const gridWorldEditorState = ${gridWorldEditorState.toString()};
966
+ const wordBeforeCursor = ${wordBeforeCursor.toString()};
812
967
  const pillCandidates = ${pillCandidates.toString()};
813
968
  const matchPills = ${matchPills.toString()};
814
969
  const createPillComplete = ${createPillComplete.toString()};
@@ -821,29 +976,35 @@ function pageScript() {
821
976
  const cameraRigFor = ${cameraRigFor.toString()};
822
977
  const nextCameraSelection = ${nextCameraSelection.toString()};
823
978
  const mapDotsFor = ${mapDotsFor.toString()};
979
+ const mapBlocksFor = ${mapBlocksFor.toString()};
824
980
  const hudCardFieldsFor = ${hudCardFieldsFor.toString()};
825
981
  const clipForAction = ${clipForAction.toString()};
826
982
  const agentCardMarkup = ${agentCardMarkup.toString()};
983
+ const believedFactSentence = ${believedFactSentence.toString()};
827
984
 
828
985
  const el = (id) => document.getElementById(id);
986
+ const SEED_COMMANDS = ["tick", "what does the fox see", "where is the goblin", "what can I do"];
829
987
  let scenarioIndex = 0;
830
988
  const scenario = function () { return DATA.scenarios[scenarioIndex]; };
831
989
  const gridSizeOf = function () { return scenario().gridSize || DATA.gridSize; };
832
- const rosterOf = function (s, role) {
833
- return (s.agents || []).filter(function (a) { return !role || a.role === role; }).map(function (a) { return a.id; });
834
- };
835
990
 
836
- // ---- roster picking: the same shuffle-and-slice mud-browser-entry.mjs's
837
- // own pickMudRoster performs, kept here rather than in the bundle because
838
- // it needs no engine at all and this page's own tests exercise it through
839
- // the rendered controls, never directly.
840
- function pickRoster(roster, count) {
841
- const pool = [...(roster || [])];
842
- for (let i = pool.length - 1; i > 0; i -= 1) {
843
- const j = Math.floor(Math.random() * (i + 1));
844
- const tmp = pool[i]; pool[i] = pool[j]; pool[j] = tmp;
845
- }
846
- return pool.slice(0, Math.min(count, pool.length));
991
+ // ---- roster minting -----------------------------------------------------
992
+ // The page asks for a COUNT and names the ids it is about to get back: the
993
+ // engine mints <prefix>-1..N at seeded cells, so a slider can call for more
994
+ // animals than the scenario's own opening cast carries and still be met. The
995
+ // prefix is read off the scenario's own first agent of that role, so a square
996
+ // that casts something other than foxes and goblins still names its cast
997
+ // correctly. Drawing from the scenario's list instead capped every square at
998
+ // whatever its layout happened to build, and a shuffled draw would leave two
999
+ // loads of the same square with different casts.
1000
+ function rosterPrefixFor(s, role) {
1001
+ const first = (s.agents || []).find(function (a) { return a && a.role === role; });
1002
+ return first ? roleOfAgentId(first.id) : role;
1003
+ }
1004
+ function mintRoster(prefix, count) {
1005
+ const ids = [];
1006
+ for (let i = 1; i <= count; i += 1) ids.push(prefix + "-" + i);
1007
+ return ids;
847
1008
  }
848
1009
 
849
1010
  let cast = [];
@@ -857,8 +1018,14 @@ function pageScript() {
857
1018
  let tickQueue = createSerialQueue();
858
1019
  function serializeTick(fn) { return tickQueue.run(fn); }
859
1020
  let camera = { mode: "follow", selectedId: null, status: null };
1021
+ // The mode a despawn fallback took away, held until the visitor picks
1022
+ // another agent. Without it, choosing someone new after a fox ate your
1023
+ // goblin leaves the camera overhead and the follow button unlit.
1024
+ let cameraModeBeforeFallback = null;
860
1025
  let foodArmed = false;
861
1026
  let livePills = [];
1027
+ let selectedAddresseeId = null;
1028
+ const expandedAgents = new Set();
862
1029
  let pillComplete = null;
863
1030
  let autoOn = false;
864
1031
  let editing = false;
@@ -902,8 +1069,12 @@ function pageScript() {
902
1069
  if (typeof result.turn === "number") globalTurn = result.turn;
903
1070
  if (result.agents) agentsById = result.agents;
904
1071
  if (result.items) itemsById = result.items;
905
- callScene("applyTick", { agents: result.agents, items: result.items, ecology: result.ecology });
906
- camera = nextCameraSelection(camera, agentsList(), result.ecology || []);
1072
+ callScene("applyTick", {
1073
+ agents: result.agents, items: result.items, ecology: result.ecology, rungs: result.rungs,
1074
+ });
1075
+ const nextCamera = nextCameraSelection(camera, agentsList(), result.ecology || []);
1076
+ if (nextCamera.status && camera.mode !== "overhead") cameraModeBeforeFallback = camera.mode;
1077
+ camera = nextCamera;
907
1078
  callScene("setCamera", camera);
908
1079
  if (camera.status) setSceneStatus(camera.status);
909
1080
  }
@@ -926,6 +1097,12 @@ function pageScript() {
926
1097
  const playBtn = el("autoToggle");
927
1098
  playBtn.setAttribute("aria-pressed", state.playing ? "true" : "false");
928
1099
  playBtn.textContent = state.playing ? "\\u23F8 pause" : "\\u25B6 play";
1100
+ // The follow control reads the ticker's own state, never a second
1101
+ // "am I playing" the page keeps for itself, so the two can never
1102
+ // disagree. It closes while the board plays because a redraw lands
1103
+ // on top of the open dropdown and loses the pick.
1104
+ el("agentSelect").disabled = state.playing;
1105
+ el("agentSelectHint").hidden = !state.playing;
929
1106
  },
930
1107
  hasNext: hasNext,
931
1108
  wait: liveWait,
@@ -971,20 +1148,72 @@ function pageScript() {
971
1148
  el("chatLogPopupClose").addEventListener("click", function () { el("chatLogPopup").hidden = true; });
972
1149
 
973
1150
  // ---- the pill rail and its typeahead ----------------------------------
1151
+ // The deception rail is tmct.page.pillsForMudiii's own output, rendered and
1152
+ // nothing more: an address pill per live agent, then a true and a false
1153
+ // claim about every individual the addressee could act on. The false cell is
1154
+ // the board's own point reflection, so a lie is always in bounds and never
1155
+ // accidentally true. Which one a pill carries is shown by a glyph in CSS
1156
+ // ::before, never in the submitted text — a clicked lie reads exactly like a
1157
+ // typed one once it is in the input.
1158
+ //
1159
+ // The fixed seeds ahead of it are the town square's OWN verbs, checked
1160
+ // against the lane's regexes rather than borrowed from another page: this
1161
+ // world has no "look".
974
1162
  function renderChatPills() {
975
- const pills = [{ command: "look", label: "look" }];
976
- for (const id of Object.keys(agentsById)) pills.push({ command: "@" + id + " look", label: "@" + id + " look" });
977
- livePills = pills;
978
- el("chatPills").innerHTML = pills.map(function (p) {
1163
+ const seeds = SEED_COMMANDS.map(function (c) { return { command: c, label: c }; });
1164
+ const seedHtml = seeds.map(function (p) {
979
1165
  return '<button type="button" class="pill" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
980
1166
  }).join("");
981
- const buttons = el("chatPills").querySelectorAll(".pill");
982
- for (let i = 0; i < buttons.length; i += 1) {
983
- buttons[i].addEventListener("click", function (e) { sendCommand(e.currentTarget.getAttribute("data-command")); });
984
- }
1167
+ const rail = window.tmct.page.pillsForMudiii(agentsById, itemsById, selectedAddresseeId, { gridSize: gridSizeOf() });
1168
+ selectedAddresseeId = rail.addresseeId;
1169
+ const addrHtml = rail.addressPills.map(function (p) {
1170
+ return '<button type="button" class="pill" data-role="dyn-addr" data-id="' + esc(p.id) + '"'
1171
+ + (p.id === selectedAddresseeId ? ' data-active="1"' : "") + ">" + esc(p.label) + "</button>";
1172
+ }).join("");
1173
+ const claims = rail.claimPills.map(function (p) {
1174
+ return { command: p.sentence, label: p.text, truth: p.truth };
1175
+ });
1176
+ const claimHtml = claims.map(function (p) {
1177
+ return '<button type="button" class="pill" data-role="dyn-claim" data-truth="' + (p.truth ? "true" : "false")
1178
+ + '" data-command="' + esc(p.command) + '">' + esc(p.label) + "</button>";
1179
+ }).join("");
1180
+ livePills = seeds.concat(claims);
1181
+ el("chatPills").innerHTML = seedHtml + addrHtml + claimHtml;
985
1182
  if (pillComplete) pillComplete.refresh();
986
1183
  }
987
1184
 
1185
+ // A pill APPENDS rather than replacing, so two clicks compose one line. The
1186
+ // second click of a double is what submits: the text it would have appended
1187
+ // went in on the first click of that same pair, which is why nothing is
1188
+ // appended again here.
1189
+ function appendToChatInput(text) {
1190
+ const input = el("chatInput");
1191
+ const head = input.value.replace(/\\s+$/, "");
1192
+ input.value = (head ? head + " " : "") + text;
1193
+ input.focus();
1194
+ input.setSelectionRange(input.value.length, input.value.length);
1195
+ }
1196
+
1197
+ el("chatPills").addEventListener("click", function (e) {
1198
+ const btn = e.target.closest(".pill");
1199
+ if (!btn) return;
1200
+ if (btn.getAttribute("data-role") === "dyn-addr") {
1201
+ selectedAddresseeId = btn.getAttribute("data-id");
1202
+ renderChatPills();
1203
+ return;
1204
+ }
1205
+ const command = btn.getAttribute("data-command");
1206
+ if (!command) return;
1207
+ if (e.detail > 1) {
1208
+ const input = el("chatInput");
1209
+ const line = input.value.trim();
1210
+ input.value = "";
1211
+ if (line) sendCommand(line);
1212
+ return;
1213
+ }
1214
+ appendToChatInput(command);
1215
+ });
1216
+
988
1217
  function wirePillComplete() {
989
1218
  pillComplete = createPillComplete({
990
1219
  input: el("chatInput"),
@@ -1015,7 +1244,8 @@ function pageScript() {
1015
1244
  // still refused entirely client-side — nothing is written, and the food
1016
1245
  // pill stays armed for another try.
1017
1246
  window.mudiiiHandleSceneClick = function (cellId) {
1018
- if (!foodArmed || !session) return;
1247
+ if (!session || editing) return;
1248
+ if (!foodArmed) { walkFollowedTo(cellId); return; }
1019
1249
  const reason = blockedCellReason(cellId, props, agentsList());
1020
1250
  if (reason) { setSceneStatus(reason); return; }
1021
1251
  sendCommand("put food at " + cellId).then(function () {
@@ -1024,6 +1254,82 @@ function pageScript() {
1024
1254
  });
1025
1255
  };
1026
1256
 
1257
+ // ---- driving one agent by hand ------------------------------------------
1258
+ // Every press here spends a turn: driveAgent runs the SAME whole-world tick
1259
+ // autoplay runs, so the ecology pass runs and every other agent decides and
1260
+ // moves with it. That is what the status line has to say, or the ring reads
1261
+ // as a free nudge that costs nothing.
1262
+ function followedAgentId() {
1263
+ const id = camera.selectedId;
1264
+ return id && agentsById[id] ? id : null;
1265
+ }
1266
+
1267
+ // One lit glyph, and it is the followed agent's own facing — the reading
1268
+ // that makes sense on an open grid, where nearly every step is available and
1269
+ // lighting what is available would say nothing. With nobody followed there
1270
+ // is no facing to show and nothing to walk, so the ring goes away.
1271
+ function renderDriveRing() {
1272
+ const followed = followedAgentId();
1273
+ const ring = el("driveRing");
1274
+ ring.hidden = !followed;
1275
+ if (!followed) return;
1276
+ const facing = agentsById[followed].facing || DATA.defaultFacing;
1277
+ const buttons = ring.querySelectorAll("[data-drive]");
1278
+ for (let i = 0; i < buttons.length; i += 1) {
1279
+ buttons[i].setAttribute("aria-pressed", buttons[i].getAttribute("data-drive") === facing ? "true" : "false");
1280
+ }
1281
+ }
1282
+
1283
+ function drivePress(direction) {
1284
+ const followed = followedAgentId();
1285
+ if (!session || !followed) { setSceneStatus("pick an agent to follow \\u2014 the ring walks whoever the camera is on."); return; }
1286
+ // A hand-driven turn is a deliberate one, so autoplay stands down rather
1287
+ // than racing the press.
1288
+ autoOn = false;
1289
+ if (ticker) ticker.pause();
1290
+ return serializeTick(async function () {
1291
+ const result = await session.driveAgent(followed, direction);
1292
+ applyTickResult(result);
1293
+ renderAll();
1294
+ const driven = result.driven || {};
1295
+ setSceneStatus(driven.accepted
1296
+ ? followed + " went " + driven.direction + " to " + driven.cell + " \\u2014 turn " + result.turn + ", and the whole square moved with it."
1297
+ : followed + " could not go " + direction + " \\u2014 the turn was spent anyway, and the whole square moved.");
1298
+ return result;
1299
+ });
1300
+ }
1301
+
1302
+ el("driveRing").addEventListener("click", function (e) {
1303
+ const btn = e.target.closest("[data-drive]");
1304
+ if (!btn || btn.disabled) return;
1305
+ drivePress(btn.getAttribute("data-drive"));
1306
+ });
1307
+
1308
+ // A ground click with nothing armed walks the followed agent one step along
1309
+ // the route to the cell, and draws the whole route it is heading down. The
1310
+ // route comes from the world's own exit search, so a cell behind a building
1311
+ // is declined rather than drawn as a line through the wall.
1312
+ async function walkFollowedTo(target) {
1313
+ const followed = followedAgentId();
1314
+ if (!followed) { setSceneStatus("pick an agent to follow \\u2014 a click on the ground walks whoever the camera is on."); return; }
1315
+ const from = agentsById[followed].cell;
1316
+ if (target === from) { setSceneStatus(followed + " is already at " + target + "."); return; }
1317
+ callScene("flashCell", target);
1318
+ const snap = await session.snapshot();
1319
+ const route = window.tmct.page.routeBetweenCells(snap.rows, from, target);
1320
+ if (!route || !route.directions.length) {
1321
+ callScene("clearRoute");
1322
+ setSceneStatus("no way through to " + target + " from " + from + ".");
1323
+ return;
1324
+ }
1325
+ callScene("showRoute", route.cells);
1326
+ await drivePress(route.directions[0]);
1327
+ const left = route.directions.length - 1;
1328
+ setSceneStatus(left
1329
+ ? followed + " is heading for " + target + " \\u2014 " + left + " more step" + (left === 1 ? "" : "s") + ", one turn each."
1330
+ : followed + " reached " + target + ".");
1331
+ }
1332
+
1027
1333
  // ---- the HUD row --------------------------------------------------------
1028
1334
  function renderHudRow() {
1029
1335
  const ids = Object.keys(agentsById).sort();
@@ -1043,19 +1349,69 @@ function pageScript() {
1043
1349
  card.querySelector(".hud-meter-fill").style.width = (fields.massPct === null ? 0 : fields.massPct) + "%";
1044
1350
  card.querySelector(".hud-goal").textContent = fields.goal;
1045
1351
  card.querySelector(".hud-plan").textContent = "plan: " + fields.planText;
1046
- card.querySelector(".hud-belief").textContent = fields.beliefEntries.length
1047
- ? "believes: " + fields.beliefEntries.map(function (entry) {
1048
- return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
1049
- }).join(" \\u00b7 ")
1050
- : "";
1352
+ renderBelief(card, id, fields.beliefEntries);
1051
1353
  }
1052
1354
  }
1053
1355
 
1356
+ // A belief map grows with the cast, so the card shows the first three and a
1357
+ // count and keeps the rest behind a toggle. Which cards are open is held
1358
+ // against the AGENT ID, never the DOM: renderHudRow only rebuilds its
1359
+ // markup when the card count changes, and card slots are positional while
1360
+ // agents re-bind by sorted id, so state left in a card would follow the
1361
+ // slot rather than the animal.
1362
+ const BELIEF_SUMMARY_LIMIT = 3;
1363
+ function renderBelief(card, id, entries) {
1364
+ const toggle = card.querySelector(".hud-belief-toggle");
1365
+ const detail = card.querySelector(".hud-detail");
1366
+ const expanded = expandedAgents.has(id);
1367
+ const shown = entries.slice(0, BELIEF_SUMMARY_LIMIT).map(function (entry) {
1368
+ return entry[0] + (entry[1] ? " @ " + entry[1] : " unseen");
1369
+ }).join(" \\u00b7 ");
1370
+ const rest = entries.length - BELIEF_SUMMARY_LIMIT;
1371
+ card.querySelector(".hud-belief").textContent = entries.length
1372
+ ? "believes: " + shown + (rest > 0 ? " +" + rest + " more" : "")
1373
+ : "";
1374
+ toggle.hidden = entries.length === 0;
1375
+ toggle.setAttribute("aria-expanded", expanded && entries.length ? "true" : "false");
1376
+ detail.hidden = !expanded || entries.length === 0;
1377
+ detail.innerHTML = entries.map(function (entry) {
1378
+ return '<div class="hud-detail-line">' + esc(believedFactSentence(entry[0], entry[1])) + "</div>";
1379
+ }).join("");
1380
+ }
1381
+
1382
+ el("hudRow").addEventListener("click", function (e) {
1383
+ const toggle = e.target.closest(".hud-belief-toggle");
1384
+ if (!toggle) return;
1385
+ const card = toggle.closest(".hud-card");
1386
+ const id = card && card.getAttribute("data-agent");
1387
+ if (!id) return;
1388
+ if (expandedAgents.has(id)) expandedAgents.delete(id); else expandedAgents.add(id);
1389
+ renderHudRow();
1390
+ });
1391
+
1054
1392
  // ---- the top-down map panel ---------------------------------------------
1055
1393
  function renderMapPanel() {
1056
- const dots = mapDotsFor(agentsList(), itemsList(), gridSizeOf());
1057
- el("mapPanelBoard").innerHTML = dots.map(function (d) {
1058
- return '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
1394
+ const board = el("mapPanelBoard");
1395
+ const size = gridSizeOf();
1396
+ // The cell divisions are two gradients stepped by this, so the drawn grid
1397
+ // and the dots' own percentages read off the same board size.
1398
+ board.style.setProperty("--map-cell-pct", (100 / size) + "%");
1399
+ const blocks = mapBlocksFor(props, size);
1400
+ const dots = mapDotsFor(agentsList(), itemsList(), size);
1401
+ // Blocks first, dots second: a live agent standing beside a building has
1402
+ // to sit on top of it, not under it.
1403
+ board.innerHTML = blocks.map(function (b) {
1404
+ return '<span class="map-block" style="left:' + b.xPct + '%;top:' + b.yPct + '%;width:' + b.sizePct
1405
+ + '%;height:' + b.sizePct + '%" title="' + esc(b.id) + '"></span>';
1406
+ }).join("") + dots.map(function (d) {
1407
+ const dot = '<span class="map-dot map-dot-' + esc(d.kind) + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%" title="' + esc(d.id) + '"></span>';
1408
+ // Items are named by their colour in the key; only the cast, which the
1409
+ // HUD and the follow control both name, carries its id on the board.
1410
+ if (d.kind !== "predator" && d.kind !== "prey") return dot;
1411
+ // A label on a dot near the right edge would run off the board, so
1412
+ // those hang to the left of their dot instead.
1413
+ const side = d.xPct > 70 ? " map-label-left" : "";
1414
+ return dot + '<span class="map-label mono' + side + '" style="left:' + d.xPct + '%;top:' + d.yPct + '%">' + esc(d.id) + "</span>";
1059
1415
  }).join("");
1060
1416
  el("mapPanelTurn").textContent = "turn " + globalTurn;
1061
1417
  }
@@ -1071,7 +1427,11 @@ function pageScript() {
1071
1427
  }
1072
1428
  el("agentSelect").addEventListener("change", function () {
1073
1429
  const id = el("agentSelect").value || null;
1074
- camera = { mode: camera.mode, selectedId: id, status: null };
1430
+ const mode = cameraModeBeforeFallback || camera.mode;
1431
+ cameraModeBeforeFallback = null;
1432
+ camera = { mode: mode, selectedId: id, status: null };
1433
+ renderCameraButtons();
1434
+ renderDriveRing();
1075
1435
  callScene("setCamera", camera);
1076
1436
  });
1077
1437
 
@@ -1084,6 +1444,7 @@ function pageScript() {
1084
1444
  el("cameraMode").addEventListener("click", function (e) {
1085
1445
  const btn = e.target.closest("button[data-mode]");
1086
1446
  if (!btn) return;
1447
+ cameraModeBeforeFallback = null;
1087
1448
  camera = { mode: btn.getAttribute("data-mode"), selectedId: camera.selectedId, status: null };
1088
1449
  renderCameraButtons();
1089
1450
  callScene("setCamera", camera);
@@ -1119,7 +1480,7 @@ function pageScript() {
1119
1480
  el("playerCountSlider").addEventListener("change", function () { boot(); });
1120
1481
  el("npcCountSlider").addEventListener("input", function () { showGoblinCount(chosenGoblinCount()); });
1121
1482
  el("npcCountSlider").addEventListener("change", function () { boot(); });
1122
- el("resetBtn").addEventListener("click", function () { boot(); });
1483
+ el("resetBtn").addEventListener("click", function () { resetBoard(); });
1123
1484
  const scenarioSelect = el("scenarioSelect");
1124
1485
  if (scenarioSelect) {
1125
1486
  scenarioSelect.addEventListener("change", function () {
@@ -1154,6 +1515,9 @@ function pageScript() {
1154
1515
  // graph.
1155
1516
  function worldOnlyRows(rows) { return rowsForWorld(rows, scenario().worldPayload.name); }
1156
1517
  let editRows = [];
1518
+ // The FULL store, not the world's own rows: a term's synonyms and its is-a
1519
+ // chain mostly live in the background corpus, not in the square's vocabulary.
1520
+ let allStoreRows = [];
1157
1521
 
1158
1522
  function renderEditPlacements() {
1159
1523
  const placements = {};
@@ -1178,11 +1542,12 @@ function pageScript() {
1178
1542
  el("editModeBtn").textContent = "back to playing";
1179
1543
  el("editModeBtn").setAttribute("aria-pressed", "true");
1180
1544
  const snap = await session.snapshot();
1545
+ allStoreRows = snap.rows;
1181
1546
  editRows = worldOnlyRows(snap.rows);
1182
1547
  el("editorText").value = renderMudEditorText(editRows, gridWorldEditorState(snap.state));
1183
1548
  el("editorStatus").className = "edit-status";
1184
1549
  el("editorStatus").textContent = "";
1185
- el("editorPills").innerHTML = "";
1550
+ renderSuggestionPills();
1186
1551
  renderEditPlacements();
1187
1552
  }
1188
1553
 
@@ -1193,9 +1558,74 @@ function pageScript() {
1193
1558
  el("editModeBtn").setAttribute("aria-pressed", "false");
1194
1559
  }
1195
1560
 
1561
+ // The lateral SKOS neighbourhood plus the vertical is-a chain for whatever
1562
+ // word the cursor sits behind. Nothing found is nothing shown — an honest
1563
+ // miss, never a guessed suggestion.
1564
+ function renderSuggestionPills() {
1565
+ const box = el("editorPills");
1566
+ const term = wordBeforeCursor(el("editorText").value, el("editorText").selectionStart);
1567
+ if (!term || !window.tmct) { box.innerHTML = ""; return; }
1568
+ const related = window.tmct.page.relatedForTerm ? window.tmct.page.relatedForTerm(allStoreRows, term) : null;
1569
+ const chain = window.tmct.page.classAncestorChain ? window.tmct.page.classAncestorChain(term, allStoreRows) : [];
1570
+ const seen = {};
1571
+ seen[term] = true;
1572
+ const out = [];
1573
+ const push = function (label) { if (label && !seen[label]) { seen[label] = true; out.push(label); } };
1574
+ if (related) {
1575
+ related.synonyms.forEach(push);
1576
+ related.related.forEach(function (r) { push(r.prefLabel); });
1577
+ }
1578
+ chain.slice(1).forEach(push);
1579
+ box.innerHTML = out.slice(0, 8).map(function (s) {
1580
+ return '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>";
1581
+ }).join("");
1582
+ }
1583
+
1584
+ el("editorPills").addEventListener("click", function (e) {
1585
+ const btn = e.target.closest(".pill");
1586
+ if (!btn) return;
1587
+ const area = el("editorText");
1588
+ const pos = area.selectionStart;
1589
+ const word = wordBeforeCursor(area.value, pos);
1590
+ const insert = btn.getAttribute("data-insert");
1591
+ area.value = area.value.slice(0, pos - word.length) + insert + area.value.slice(pos);
1592
+ const next = pos - word.length + insert.length;
1593
+ area.setSelectionRange(next, next);
1594
+ area.focus();
1595
+ onEditorChanged();
1596
+ });
1597
+
1598
+ let suggestTimer = null;
1196
1599
  let syncTimer = null;
1600
+ function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
1197
1601
  function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(applyEditorText, 450); }
1198
- el("editorText").addEventListener("input", scheduleSync);
1602
+ function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
1603
+ el("editorText").addEventListener("input", onEditorChanged);
1604
+ el("editorText").addEventListener("click", scheduleSuggestions);
1605
+ el("editorText").addEventListener("keyup", function (e) {
1606
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
1607
+ });
1608
+
1609
+ // An edit changes the facts, so the meshes have to move with them —
1610
+ // otherwise a deleted well stands in the square until the next Reset.
1611
+ // boot() drops the camera back to overhead and empties the scene's own
1612
+ // agent groups, so the visitor's camera is put back and the live cast is
1613
+ // redrawn straight after: autoplay is paused in edit mode, so nothing else
1614
+ // would repopulate them.
1615
+ async function rebuildSceneFromEdit(result) {
1616
+ props = propPlacementsFrom(editRows, DATA.assetManifest);
1617
+ // A prop may now stand where an animal was, so a change re-casts onto the
1618
+ // edited board. An edit that wrote and retracted nothing leaves the cast
1619
+ // exactly where it stood.
1620
+ if (result && (result.added || result.removed)) {
1621
+ applyTickResult(await session.recast({ agents: cast }));
1622
+ }
1623
+ await callScene("boot", {
1624
+ propPlacements: props, assetManifest: DATA.assetManifest, gridSize: gridSizeOf(), cellSize: 1,
1625
+ });
1626
+ callScene("setCamera", camera);
1627
+ callScene("applyTick", { agents: agentsById, items: itemsById, ecology: [] });
1628
+ }
1199
1629
 
1200
1630
  async function applyEditorText() {
1201
1631
  if (!session) return;
@@ -1204,7 +1634,9 @@ function pageScript() {
1204
1634
  status.textContent = "reading the square\\u2026";
1205
1635
  const result = await serializeTick(function () { return session.applyEdit(el("editorText").value); });
1206
1636
  const snap = await session.snapshot();
1637
+ allStoreRows = snap.rows;
1207
1638
  editRows = worldOnlyRows(snap.rows);
1639
+ await rebuildSceneFromEdit(result);
1208
1640
  if (result && result.unrecognized && result.unrecognized.length) {
1209
1641
  status.className = "edit-status pending";
1210
1642
  status.textContent = result.unrecognized.length + " line" + (result.unrecognized.length === 1 ? "" : "s")
@@ -1219,8 +1651,11 @@ function pageScript() {
1219
1651
  }
1220
1652
 
1221
1653
  // ---- booting ---------------------------------------------------------
1222
- // Nothing plays on load: booting draws the opening state and stops there.
1223
- // Ticking waits for the deck's own play control, exactly like mud.html.
1654
+ // The board opens playing: a square standing still reads as broken, and the
1655
+ // first thing anyone does is press play anyway. A visitor who asked for
1656
+ // reduced motion gets the opening board drawn and left still — the play
1657
+ // control is right there — because an autoplaying board is exactly the
1658
+ // unasked-for movement that setting is about.
1224
1659
  let bootSeq = 0;
1225
1660
  async function boot() {
1226
1661
  const seq = bootSeq += 1;
@@ -1229,14 +1664,20 @@ function pageScript() {
1229
1664
  globalTurn = 0;
1230
1665
  tickQueue = createSerialQueue();
1231
1666
  camera = { mode: "follow", selectedId: null, status: null };
1667
+ cameraModeBeforeFallback = null;
1668
+ expandedAgents.clear();
1232
1669
  const s = scenario();
1233
- const foxes = pickRoster(rosterOf(s, "predator"), chosenFoxCount());
1234
- const goblins = pickRoster(rosterOf(s, "prey"), chosenGoblinCount());
1670
+ const foxes = mintRoster(rosterPrefixFor(s, "predator"), chosenFoxCount());
1671
+ const goblins = mintRoster(rosterPrefixFor(s, "prey"), chosenGoblinCount());
1235
1672
  cast = foxes.concat(goblins);
1236
1673
  showFoxCount(foxes.length);
1237
1674
  showGoblinCount(goblins.length);
1238
1675
  props = propPlacementsFrom((s.worldPayload && s.worldPayload.facts) || [], DATA.assetManifest);
1239
- const opened = await window.tmct.open(s.worldPayload, { agents: cast, epoch: 0 });
1676
+ const opened = await window.tmct.open(s.worldPayload, {
1677
+ agents: cast,
1678
+ epoch: 0,
1679
+ getTeachEnabled: function () { return el("teachToggle").checked; },
1680
+ });
1240
1681
  if (seq !== bootSeq) return;
1241
1682
  session = opened;
1242
1683
  agentsById = {};
@@ -1254,6 +1695,40 @@ function pageScript() {
1254
1695
  camera.selectedId = Object.keys(opening.agents || {}).sort()[0] || null;
1255
1696
  applyTickResult(opening);
1256
1697
  renderAll();
1698
+ if (!prefersReducedMotion()) {
1699
+ autoOn = true;
1700
+ ensureTicker().play();
1701
+ }
1702
+ }
1703
+
1704
+ // Reset re-casts the store it already has rather than opening a new one:
1705
+ // the world's facts, everything taught into it and every editor change all
1706
+ // stand, and only the animals are minted again. Re-opening would throw the
1707
+ // taught facts away with the cast, which is not what "reset the board" says.
1708
+ // The engine owns the turn count, so a re-cast does not rewind it — the
1709
+ // status line says as much, because a counter that keeps climbing after a
1710
+ // Reset otherwise reads as a bug.
1711
+ async function resetBoard() {
1712
+ if (!session) return boot();
1713
+ autoOn = false;
1714
+ if (ticker) { ticker.pause(); ticker = null; }
1715
+ expandedAgents.clear();
1716
+ const s = scenario();
1717
+ const foxes = mintRoster(rosterPrefixFor(s, "predator"), chosenFoxCount());
1718
+ const goblins = mintRoster(rosterPrefixFor(s, "prey"), chosenGoblinCount());
1719
+ cast = foxes.concat(goblins);
1720
+ showFoxCount(foxes.length);
1721
+ showGoblinCount(goblins.length);
1722
+ const board = await serializeTick(function () { return session.recast({ agents: cast }); });
1723
+ camera = { mode: "follow", selectedId: Object.keys(board.agents || {}).sort()[0] || null, status: null };
1724
+ cameraModeBeforeFallback = null;
1725
+ applyTickResult(board);
1726
+ renderAll();
1727
+ setSceneStatus("re-cast \\u2014 the square's own facts stand, and its clock keeps running.");
1728
+ if (!prefersReducedMotion()) {
1729
+ autoOn = true;
1730
+ ensureTicker().play();
1731
+ }
1257
1732
  }
1258
1733
 
1259
1734
  function renderAll() {
@@ -1261,6 +1736,7 @@ function pageScript() {
1261
1736
  renderMapPanel();
1262
1737
  renderAgentSelect();
1263
1738
  renderCameraButtons();
1739
+ renderDriveRing();
1264
1740
  renderChatPills();
1265
1741
  el("globalTurnCount").textContent = "turns: " + globalTurn;
1266
1742
  }