@polycode-projects/the-mechanical-code-talker 2.8.0 → 2.8.3

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.
@@ -18,14 +18,16 @@
18
18
  // input. scripts/build-demo-site.mjs calls it directly and writes the result
19
19
  // to public/spider-fly.html, after building the sibling bundle.
20
20
  //
21
- // Three runtime pieces are spliced into the page's own inlined script via
21
+ // Runtime pieces are spliced into the page's own inlined script via
22
22
  // `.toString()` — exactly ledger-viz.mjs's own `facetCounts`/
23
23
  // `resolveAnsweredTerm` pattern — because they are genuinely UI-only, with no
24
24
  // reason to live inside the engine bundle: `createTicker` (viz-ticker.mjs,
25
- // the shared play/pause/step/reset primitive), `classOfAgentId` and
26
- // `threadCellsForSpiderPlan` (below — the silk-thread reconstruction, kept as
27
- // real, independently-tested exports rather than raw inline-script text, the
28
- // same discipline ledger-viz.mjs holds its own spliced helpers to). Sprite
25
+ // the shared play/pause/step/reset primitive), `classOfAgentId`,
26
+ // `threadCellsForSpiderPlan` (the silk-thread reconstruction), `nextCorpses`
27
+ // (the dying-agent bookkeeping behind the dusty-corner corpse pile) and
28
+ // `facingDegreesFor` (plan-driven sprite orientation) all kept as real,
29
+ // independently-tested exports rather than raw inline-script text, the same
30
+ // discipline ledger-viz.mjs holds its own spliced helpers to. Sprite
29
31
  // resolution, grid geometry and the chat turn engine all come from the
30
32
  // bundle's own real ES exports instead, since (unlike ledger-viz, which
31
33
  // reuses a FIXED shared bundle it can't extend for one page's own needs) this
@@ -33,13 +35,22 @@
33
35
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
34
36
  import { createTicker } from "./viz-ticker.mjs";
35
37
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
36
- import { FLY_INITIAL_MASS, SPIDER_INITIAL_MASS } from "./spider-fly.mjs";
38
+ import { FLY_INITIAL_MASS, EGG_LAY_MASS_THRESHOLD } from "./spider-fly.mjs";
39
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
37
40
 
38
- const CELL_PX = 44;
41
+ // A larger cell than this page's first cut (44px) — the board now fills
42
+ // noticeably more of the stage's own width, closing most of the dead gap a
43
+ // fixed-260px side column used to leave next to a small fixed-440px board.
44
+ const CELL_PX = 54;
39
45
  const BOARD_PX = CELL_PX * GRID_SIZE;
40
46
  const DEFAULT_TITLE = "tmct — the spider and the fly";
41
47
  const PREVIEW_MAX_TURNS = 40;
42
48
  const TICK_WAIT_MS = 700;
49
+ // How many turns a corpse lingers at the bottom of its own board column
50
+ // before the carcass "rots" (stops being drawn at all) — a page-local
51
+ // constant, not a game-config knob: purely cosmetic, never read by the
52
+ // engine, so a visitor's carrying/hatch mechanics never depend on it.
53
+ const CORPSE_LINGER_TURNS = 4;
43
54
 
44
55
  function webCellIds() {
45
56
  const out = [];
@@ -95,6 +106,57 @@ export function threadCellsForSpiderPlan(agents, geometry) {
95
106
  return null;
96
107
  }
97
108
 
109
+ /** The sprite-facing rotation (degrees) for one agent this tick, driven by
110
+ * its CURRENT plan's first step (spider-fly.mjs's own `agents[id].plan`),
111
+ * never its actual next move — the two usually coincide, but re-planning
112
+ * fresh every tick means they can visibly diverge as a plan gets clobbered
113
+ * and replaced, which is the intended, honest demonstration of "plans get
114
+ * clobbered under partial/unreliable knowledge," not a glitch to smooth
115
+ * over. A held agent (`plan` empty/absent — no direction to face) keeps
116
+ * `previousDegrees` unchanged rather than snapping back to a default, so
117
+ * holding still never spins the sprite; a brand-new agent with no prior
118
+ * facing at all defaults to 0 (the art's own default north/up pose).
119
+ * Self-contained (no outer refs), `.toString()`-splice safe — the
120
+ * direction -> degrees table lives INSIDE the function body on purpose:
121
+ * spider.toml and fly.toml both draw the head/thorax at the TOP of the
122
+ * viewBox (facing north by default), matching spider-fly-world.mjs's own
123
+ * DIRECTION_DELTA (north decreases y — up on screen). */
124
+ export function facingDegreesFor(plan, previousDegrees) {
125
+ const FACING_DEGREES = { north: 0, east: 90, south: 180, west: 270 };
126
+ const direction = plan && plan[0];
127
+ if (direction && FACING_DEGREES[direction] !== undefined) return FACING_DEGREES[direction];
128
+ return previousDegrees ?? 0;
129
+ }
130
+
131
+ /**
132
+ * The updated corpse set for one redraw (§A.2.5 — visual-only, entirely
133
+ * client-side; the actual starve/eat removal already happened in the
134
+ * engine). Every id present in `prevAgents` but absent from `agents` died
135
+ * THIS tick — eaten or starved are the only two ways an agent ever leaves
136
+ * the engine's own returned roster — and is added at its last-known cell
137
+ * and class; every corpse already older than `lingerTurns` past its own
138
+ * death turn is dropped first, so the set never grows without bound.
139
+ * Returns a plain `{ [id]: { cls, cell, diedAtTurn } }` map. Pure.
140
+ * `lingerTurns` defaults to a literal 4 (not the module-level
141
+ * CORPSE_LINGER_TURNS constant) so this function stays fully
142
+ * `.toString()`-splice safe — every real caller (both the inlined page and
143
+ * CORPSE_LINGER_TURNS's own callers elsewhere in this module) passes it
144
+ * explicitly anyway.
145
+ */
146
+ export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns = 4) {
147
+ const out = {};
148
+ for (const [id, corpse] of Object.entries(prevCorpses || {})) {
149
+ if (turn - corpse.diedAtTurn <= lingerTurns) out[id] = corpse;
150
+ }
151
+ for (const id of Object.keys(prevAgents || {})) {
152
+ if (agents[id] || out[id]) continue;
153
+ const cell = prevAgents[id]?.cell;
154
+ if (!cell) continue;
155
+ out[id] = { cls: classOfAgentId(id), cell, diedAtTurn: turn };
156
+ }
157
+ return out;
158
+ }
159
+
98
160
  /** The self-contained spider-and-fly page. Pure — the same output for the
99
161
  * same `title`/`spriteTemplates` every time; every other piece of state
100
162
  * this page shows is computed live in the browser once the sibling bundle
@@ -117,8 +179,15 @@ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE, spriteTemplates = [
117
179
  cellPx: CELL_PX,
118
180
  previewMaxTurns: PREVIEW_MAX_TURNS,
119
181
  tickWaitMs: TICK_WAIT_MS,
182
+ corpseLingerTurns: CORPSE_LINGER_TURNS,
120
183
  maxFlyMass: FLY_INITIAL_MASS,
121
- maxSpiderMass: SPIDER_INITIAL_MASS,
184
+ // The spider's mass bar now scales against the EGG-LAY threshold, not
185
+ // its own starting mass — "how close to laying" is the meaningful cap
186
+ // to visualize under the new mass-gated lay mechanic (§A.2.2); the old
187
+ // denominator (a flat starting mass) said nothing about progress toward
188
+ // the spider's actual goal.
189
+ maxSpiderMass: EGG_LAY_MASS_THRESHOLD,
190
+ defaultConfig: DEFAULT_GAME_CONFIG.spiderFly,
122
191
  spriteTemplates,
123
192
  });
124
193
 
@@ -137,32 +206,58 @@ ${THEME_TOKENS_CSS}
137
206
  html { background: var(--bg); }
138
207
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
139
208
  .mono { font-family: ${MONO_STACK}; }
140
- main { max-width: 980px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
209
+ main { max-width: 1120px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
141
210
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
142
211
  h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
143
212
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
144
213
  button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
145
- .stage { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 1rem; align-items: start; }
214
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 1.2rem; align-items: start; }
146
215
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
147
- .board-frame { position: relative; width: ${BOARD_PX}px; max-width: 100%; aspect-ratio: 1 / 1; background: var(--card); border: 1px solid var(--line); }
216
+ /* A dusty window corner: a soft light glow near the top-left (WEB_HOME
217
+ already sits near that corner — spider-fly-world.mjs's own header
218
+ comment), and a faint diagonal weave standing in for dust/silk caught
219
+ in the light. Decoration only — the 10x10 game grid itself is drawn by
220
+ drawBoard() on the canvas beneath, unchanged. */
221
+ .board-frame {
222
+ position: relative; width: ${BOARD_PX}px; max-width: 100%; aspect-ratio: 1 / 1;
223
+ background:
224
+ radial-gradient(140% 140% at 6% 6%, rgba(255, 241, 199, .55), transparent 52%),
225
+ repeating-linear-gradient(115deg, rgba(120, 110, 90, .06) 0 1px, transparent 1px 30px),
226
+ var(--card);
227
+ border: 1px solid var(--line);
228
+ box-shadow: inset 0 0 0 6px var(--bg), inset 0 0 0 7px var(--line);
229
+ }
230
+ @media (prefers-color-scheme: dark) { .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(130, 112, 60, .32), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); } }
231
+ :root[data-theme="dark"] .board-frame { background: radial-gradient(140% 140% at 6% 6%, rgba(130, 112, 60, .32), transparent 52%), repeating-linear-gradient(115deg, rgba(255, 255, 255, .045) 0 1px, transparent 1px 30px), var(--card); }
148
232
  .board-frame canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
149
233
  .sprite-layer { position: absolute; inset: 0; }
150
234
  .sprite { position: absolute; width: 7.6%; height: 7.6%; transform: translate(-50%, -50%); transition: left .25s ease, top .25s ease; }
151
- @media (prefers-reduced-motion: reduce) { .sprite { transition: none; } }
152
- .sprite svg { width: 100%; height: 100%; display: block; }
235
+ @media (prefers-reduced-motion: reduce) { .sprite, .sprite-face { transition: none !important; } }
236
+ .sprite-face { width: 100%; height: 100%; transition: transform .25s ease; }
237
+ .sprite-face svg { width: 100%; height: 100%; display: block; }
153
238
  .sprite[data-cls="spider"] { color: var(--taught); }
154
239
  .sprite[data-cls="fly"] { color: var(--fly); }
155
240
  .sprite[data-cls="egg"] { color: var(--muted); }
156
241
  .sprite.dimmed { opacity: .28; }
242
+ /* A corpse never faces anywhere in particular (the rot has no plan) and
243
+ never rotates — grayscale-and-fade at the bottom of its own column
244
+ until CORPSE_LINGER_TURNS passes and it stops being drawn at all. */
245
+ .sprite.corpse { filter: grayscale(1); opacity: .38; pointer-events: none; }
246
+ .sprite.corpse .sprite-face { transform: none !important; }
157
247
  .thread-tip { position: absolute; transform: translate(-50%, -130%); font-family: ${MONO_STACK}; font-size: .68rem; background: var(--ink); color: var(--bg); padding: .1rem .4rem; border-radius: 3px; pointer-events: none; white-space: nowrap; display: none; }
158
248
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
159
- .hud, .chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
160
- .hud h2, .chat h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
161
- .hud-row { display: flex; flex-direction: column; gap: .1rem; padding: .3rem 0; border-top: 1px solid var(--line); }
249
+ .hud, .chat, .tuning { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
250
+ .hud h2, .chat h2, .tuning h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
251
+ /* Fixed-height, internally-scrolling a hatch can mint several spiders
252
+ at once now, so the agent count (and a naive card list's own height)
253
+ can jump sharply; the panel must never grow the page underneath it. */
254
+ .hud-list { max-height: 420px; overflow-y: auto; }
255
+ .hud-row { display: flex; flex-direction: column; gap: .1rem; padding: .4rem 0; border-top: 1px solid var(--line); }
162
256
  .hud-row:first-of-type { border-top: none; }
163
257
  .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; }
164
258
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
165
259
  .hud-goal { font-size: .85rem; }
260
+ .hud-plan, .hud-belief { font-family: ${MONO_STACK}; font-size: .66rem; color: var(--muted); margin-top: .2rem; line-height: 1.4; }
166
261
  .mass-track { height: 4px; margin-top: .3rem; background: var(--line); border-radius: 2px; overflow: hidden; }
167
262
  .mass-fill { height: 100%; background: var(--taught); }
168
263
  .mass-fill.fly { background: var(--fly); }
@@ -182,13 +277,33 @@ ${THEME_TOKENS_CSS}
182
277
  .pill:hover:not(:disabled) { border-color: var(--taught); }
183
278
  .pill:disabled { opacity: .45; cursor: default; }
184
279
  .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
280
+ /* The dynamic deception-pill rail (§A.2.4): a true/false tag shown ONLY
281
+ here, via border style/color and a small human-facing glyph — the
282
+ submitted sentence itself (data-sentence, filled into #chatq on click)
283
+ never carries the tag, so a clicked pill is indistinguishable from a
284
+ hand-typed claim once it's in the input. */
285
+ .dynpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
286
+ .dynpills:empty { display: none; padding-top: 0; border-top: none; }
287
+ .pill[data-role="dyn-addr"][data-active="1"] { border-color: var(--taught); color: var(--taught); }
288
+ .pill[data-role="dyn-claim"][data-truth="true"] { border-color: var(--taught-t2, var(--taught)); }
289
+ .pill[data-role="dyn-claim"][data-truth="true"]::before { content: "✓ "; opacity: .5; }
290
+ .pill[data-role="dyn-claim"][data-truth="false"] { border-style: dashed; border-color: var(--alert); }
291
+ .pill[data-role="dyn-claim"][data-truth="false"]::before { content: "✕ "; opacity: .6; }
292
+ .tuning-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1rem; }
293
+ .tuning-col h3 { font-size: .74rem; margin: 0 0 .4rem; font-weight: 600; }
294
+ .tuning-col.spider h3 { color: var(--taught); } .tuning-col.fly h3 { color: var(--fly); }
295
+ .tuning-col label { display: block; font-size: .68rem; color: var(--muted); margin-bottom: .6rem; }
296
+ .tuning-col .tuning-val { font-family: ${MONO_STACK}; color: var(--ink); float: right; }
297
+ .tuning-col input[type="range"] { display: block; width: 100%; margin-top: .2rem; accent-color: var(--taught); }
298
+ .tuning-col.fly input[type="range"] { accent-color: var(--fly); }
299
+ .tuning-col input:disabled { opacity: .45; }
185
300
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
186
301
  .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
187
302
  .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
188
303
  .controls-row button:disabled { opacity: .4; cursor: default; }
189
304
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
190
305
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; }
191
- body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
306
+ body.preview .side, body.preview .controls-row, body.preview .status, body.preview .tuning { display: none; }
192
307
  body.preview main { padding: 0; max-width: none; }
193
308
  body.preview .stage { display: block; }
194
309
  body.preview .eyebrow, body.preview h1 { display: none; }
@@ -208,7 +323,7 @@ ${THEME_TOKENS_CSS}
208
323
  <aside class="side" aria-label="Agents and chat">
209
324
  <div class="hud">
210
325
  <h2>agents</h2>
211
- <div id="hud"></div>
326
+ <div class="hud-list" id="hud"></div>
212
327
  </div>
213
328
  <div class="chat">
214
329
  <h2>tell the spider or the fly something</h2>
@@ -225,9 +340,33 @@ ${THEME_TOKENS_CSS}
225
340
  <button type="button" class="pill" data-role="dir" data-direction="east" disabled>the fly is east</button>
226
341
  <button type="button" class="pill" data-role="dir" data-direction="west" disabled>the fly is west</button>
227
342
  </div>
343
+ <div class="dynpills" id="dynamicPills" role="group" aria-label="address one individual and feed it a true or false position claim"></div>
228
344
  </div>
229
345
  </aside>
230
346
  </div>
347
+ <div class="tuning" id="tuning">
348
+ <h2>live tuning &mdash; mass loss, spawn rate, vision, per class</h2>
349
+ <div class="tuning-grid">
350
+ <div class="tuning-col spider">
351
+ <h3>spider</h3>
352
+ <label>mass lost/turn <span class="tuning-val" id="tvSpiderMass"></span>
353
+ <input type="range" id="ctlSpiderMass" min="0.1" max="3" step="0.1" disabled></label>
354
+ <label>hatchlings per egg <span class="tuning-val" id="tvSpiderSpawn"></span>
355
+ <input type="range" id="ctlSpiderSpawn" min="1" max="5" step="1" disabled></label>
356
+ <label>vision radius <span class="tuning-val" id="tvSpiderVision"></span>
357
+ <input type="range" id="ctlSpiderVision" min="1" max="8" step="1" disabled></label>
358
+ </div>
359
+ <div class="tuning-col fly">
360
+ <h3>fly</h3>
361
+ <label>mass lost/turn <span class="tuning-val" id="tvFlyMass"></span>
362
+ <input type="range" id="ctlFlyMass" min="0.1" max="3" step="0.1" disabled></label>
363
+ <label>spawns every N turns <span class="tuning-val" id="tvFlySpawn"></span>
364
+ <input type="range" id="ctlFlySpawn" min="1" max="10" step="1" disabled></label>
365
+ <label>vision radius <span class="tuning-val" id="tvFlyVision"></span>
366
+ <input type="range" id="ctlFlyVision" min="1" max="8" step="1" disabled></label>
367
+ </div>
368
+ </div>
369
+ </div>
231
370
  <div class="controls-row">
232
371
  <button id="resetBtn" type="button" disabled>reset</button>
233
372
  <button id="playBtn" type="button" disabled>&#9654; play</button>
@@ -246,6 +385,8 @@ const SPIDERFLY = ${gridData};
246
385
  const createTicker = ${createTicker.toString()};
247
386
  const classOfAgentId = ${classOfAgentId.toString()};
248
387
  const threadCellsForSpiderPlan = ${threadCellsForSpiderPlan.toString()};
388
+ const facingDegreesFor = ${facingDegreesFor.toString()};
389
+ const nextCorpses = ${nextCorpses.toString()};
249
390
  const esc = ${escapeHtml.toString()};
250
391
  const el = (id) => document.getElementById(id);
251
392
  const boardFrame = el("boardFrame");
@@ -260,11 +401,20 @@ const SPIDERFLY = ${gridData};
260
401
  const chatpillsEl = el("chatpills");
261
402
  const addressPillEls = [...chatpillsEl.querySelectorAll('[data-role="addr"]')];
262
403
  const directionPillEls = [...chatpillsEl.querySelectorAll('[data-role="dir"]')];
404
+ const dynamicPillsEl = el("dynamicPills");
263
405
  const statusEl = el("status");
264
406
  const turnLabelEl = el("turnLabel");
265
407
  const resetBtn = el("resetBtn");
266
408
  const playBtn = el("playBtn");
267
409
  const stepBtn = el("stepBtn");
410
+ const TUNING_CONTROLS = [
411
+ { input: "ctlSpiderMass", out: "tvSpiderMass", key: "spiderMassDecrementPerTurn" },
412
+ { input: "ctlSpiderSpawn", out: "tvSpiderSpawn", key: "eggHatchCount" },
413
+ { input: "ctlSpiderVision", out: "tvSpiderVision", key: "spiderVisionRadius" },
414
+ { input: "ctlFlyMass", out: "tvFlyMass", key: "flyMassDecrementPerTurn" },
415
+ { input: "ctlFlySpawn", out: "tvFlySpawn", key: "flySpawnIntervalTurns" },
416
+ { input: "ctlFlyVision", out: "tvFlyVision", key: "flyVisionRadius" },
417
+ ].map((c) => ({ ...c, inputEl: el(c.input), outEl: el(c.out) }));
268
418
 
269
419
  const params = new URLSearchParams(location.search);
270
420
  const preview = params.get("preview") === "1";
@@ -296,12 +446,23 @@ const SPIDERFLY = ${gridData};
296
446
  let lastTurn = 0;
297
447
  const goalById = {};
298
448
  const spriteEls = {};
449
+ const facingByAgent = {};
450
+ let corpses = {};
451
+ const corpseEls = {};
452
+ let selectedAddresseeId = null;
299
453
  let povAgentId = null;
300
454
  let threadHits = [];
455
+ // Populated once the live session boots (session.getConfig()) and kept in
456
+ // sync with whatever the tuning sliders below are currently set to — the
457
+ // POV overlay's own per-class vision radius reads this instead of a fixed
458
+ // constant, so dragging a slider changes what a toggled POV shows too.
459
+ let liveConfig = {};
301
460
 
302
461
  function removeStaleSprites(agents) {
303
462
  for (const id of Object.keys(spriteEls)) {
304
- if (!agents[id]) { spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; }
463
+ if (!agents[id]) {
464
+ spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; delete facingByAgent[id];
465
+ }
305
466
  }
306
467
  }
307
468
 
@@ -329,7 +490,13 @@ const SPIDERFLY = ${gridData};
329
490
  const sprite = window.tmctSpiderFly
330
491
  ? tmctSpiderFly.resolveSpriteAsset(cls, (session && session.taxonomyRows) || [], [], SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
331
492
  : "";
332
- node.innerHTML = sprite;
493
+ // The sprite SVG lives in its own inner wrapper so plan-driven facing
494
+ // (a CSS rotate on THIS wrapper) never fights the outer .sprite node's
495
+ // own translate(-50%,-50%) positioning transform.
496
+ const face = document.createElement("div");
497
+ face.className = "sprite-face";
498
+ face.innerHTML = sprite;
499
+ node.appendChild(face);
333
500
  if (!preview) {
334
501
  node.tabIndex = 0;
335
502
  node.setAttribute("role", "button");
@@ -354,11 +521,54 @@ const SPIDERFLY = ${gridData};
354
521
  const pct = cellCenterPct(parsed.x, parsed.y);
355
522
  node.style.left = pct.leftPct + "%";
356
523
  node.style.top = pct.topPct + "%";
524
+ // Facing is driven by the agent's own CURRENT plan's first step, not
525
+ // its actual next move — the two usually agree, but a fresh plan is
526
+ // computed every tick, so facing can visibly flip as a plan gets
527
+ // clobbered and replaced under partial/unreliable knowledge (the
528
+ // whole point of this page, not a glitch).
529
+ facingByAgent[id] = facingDegreesFor(a.plan, facingByAgent[id]);
530
+ const face = node.querySelector(".sprite-face");
531
+ if (face) face.style.transform = "rotate(" + facingByAgent[id] + "deg)";
357
532
  if (a.goal) goalById[id] = a.goal;
358
533
  }
359
534
  lastAgents = agents;
360
535
  }
361
536
 
537
+ // ---- corpses (§A.2.5): visual-only — the actual starve/eat removal
538
+ // already happened in the engine before this redraw ever sees "agents".
539
+ // A corpse sinks to the bottom row of the SAME board column it died in
540
+ // ("drops to the bottom" — spider-fly-world.mjs's own x/y convention,
541
+ // y = GRID_SIZE is the bottom row) and fades out once CORPSE_LINGER_TURNS
542
+ // passes, drawn in the same sprite layer as every live sprite, just
543
+ // grayscaled and non-interactive (see the .sprite.corpse CSS rule).
544
+ function renderCorpses() {
545
+ for (const id of Object.keys(corpseEls)) {
546
+ if (!corpses[id]) { corpseEls[id].remove(); delete corpseEls[id]; }
547
+ }
548
+ for (const [id, corpse] of Object.entries(corpses)) {
549
+ let node = corpseEls[id];
550
+ if (!node) {
551
+ node = document.createElement("div");
552
+ node.className = "sprite corpse";
553
+ node.dataset.cls = corpse.cls;
554
+ node.setAttribute("aria-hidden", "true");
555
+ const face = document.createElement("div");
556
+ face.className = "sprite-face";
557
+ face.innerHTML = window.tmctSpiderFly
558
+ ? tmctSpiderFly.resolveSpriteAsset(corpse.cls, (session && session.taxonomyRows) || [], [], SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
559
+ : "";
560
+ node.appendChild(face);
561
+ spriteLayer.appendChild(node);
562
+ corpseEls[id] = node;
563
+ }
564
+ const deathCell = tmctSpiderFly.parseCellId(corpse.cell);
565
+ if (!deathCell) continue;
566
+ const pct = cellCenterPct(deathCell.x, SPIDERFLY.gridSize);
567
+ node.style.left = pct.leftPct + "%";
568
+ node.style.top = pct.topPct + "%";
569
+ }
570
+ }
571
+
362
572
  function massBarHtml(cls, mass) {
363
573
  const maxMass = cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
364
574
  if (typeof mass !== "number" || !maxMass) return "";
@@ -366,14 +576,41 @@ const SPIDERFLY = ${gridData};
366
576
  return '<div class="mass-track"><div class="mass-fill ' + esc(cls) + '" style="width:' + pct + '%"></div></div>';
367
577
  }
368
578
 
579
+ // The last-created plan as a plain arrow chain ("west → west"), or
580
+ // "holding." when the agent's plan this tick is empty — mirrors exactly
581
+ // what drove this tick's sprite facing (facingDegreesFor reads the same
582
+ // plan[0]), so the HUD line and the sprite's own orientation never
583
+ // disagree about what the agent is "about to do".
584
+ function planLineHtml(plan) {
585
+ const text = plan && plan.length ? plan.map((d) => esc(d)).join(" \\u2192 ") : "holding";
586
+ return '<div class="hud-plan">plan: ' + text + ".</div>";
587
+ }
588
+
589
+ // The agent's own current world-knowledge-graph snapshot (§A.2.7) — every
590
+ // OTHER live individual it believes it knows the position of, or
591
+ // "unseen" when it has no belief at all. Deliberately never ground truth:
592
+ // this is what the agent would actually ACT on, which a false pill or a
593
+ // told fact can make visibly wrong compared to where that individual
594
+ // really is — the gap IS the demonstration.
595
+ function beliefLineHtml(belief) {
596
+ const entries = Object.entries(belief || {});
597
+ if (!entries.length) return "";
598
+ const text = entries.map(([id, cell]) => esc(id) + (cell ? " @ " + esc(cell) : " unseen")).join(" \\u00b7 ");
599
+ return '<div class="hud-belief">believes: ' + text + "</div>";
600
+ }
601
+
369
602
  function renderHud() {
370
603
  const ids = Object.keys(lastAgents).sort();
371
604
  if (!ids.length) { hudEl.innerHTML = '<div class="hud-empty">no agents on the board.</div>'; return; }
372
605
  hudEl.innerHTML = ids.map((id) => {
373
606
  const cls = classOfAgentId(id);
607
+ const a = lastAgents[id];
374
608
  return '<div class="hud-row"><span class="hud-id ' + esc(cls) + '">' + esc(id) + '</span>'
375
609
  + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span>"
376
- + massBarHtml(cls, lastAgents[id].mass) + "</div>";
610
+ + massBarHtml(cls, a.mass)
611
+ + planLineHtml(a.plan)
612
+ + beliefLineHtml(a.belief)
613
+ + "</div>";
377
614
  }).join("");
378
615
  }
379
616
 
@@ -435,7 +672,14 @@ const SPIDERFLY = ${gridData};
435
672
  const agent = lastAgents[povAgentId];
436
673
  if (!agent) { povAgentId = null; return; }
437
674
  const p = tmctSpiderFly.parseCellId(agent.cell);
438
- const visible = new Set(tmctSpiderFly.visibleCells(p.x, p.y, tmctSpiderFly.DEFAULT_VISION_RADIUS));
675
+ // The live, slider-adjustable per-class radius (falling back to the
676
+ // engine's own shipped default before the session has finished
677
+ // booting) — a POV toggle always reflects whatever vision range this
678
+ // class currently actually has, not a fixed constant.
679
+ const radius = classOfAgentId(povAgentId) === "spider"
680
+ ? (liveConfig.spiderVisionRadius ?? tmctSpiderFly.DEFAULT_VISION_RADIUS)
681
+ : (liveConfig.flyVisionRadius ?? tmctSpiderFly.DEFAULT_VISION_RADIUS);
682
+ const visible = new Set(tmctSpiderFly.visibleCells(p.x, p.y, radius));
439
683
  povCtx.fillStyle = "rgba(0,0,0,.55)";
440
684
  for (let gy = 1; gy <= SPIDERFLY.gridSize; gy += 1) {
441
685
  for (let gx = 1; gx <= SPIDERFLY.gridSize; gx += 1) {
@@ -464,8 +708,13 @@ const SPIDERFLY = ${gridData};
464
708
  boardFrame.addEventListener("mouseleave", () => { threadTip.style.display = "none"; });
465
709
 
466
710
  function redraw(agents, turn, activeWebs) {
711
+ // nextCorpses compares the OLD lastAgents against the NEW agents, so it
712
+ // must run before applyAgents overwrites lastAgents below.
713
+ corpses = nextCorpses(corpses, lastAgents, agents, turn, SPIDERFLY.corpseLingerTurns);
467
714
  applyAgents(agents);
715
+ renderCorpses();
468
716
  renderHud();
717
+ renderDynamicPills();
469
718
  lastTurn = turn;
470
719
  lastActiveWebs = activeWebs || [];
471
720
  turnLabelEl.textContent = "turn: " + turn;
@@ -529,6 +778,67 @@ const SPIDERFLY = ${gridData};
529
778
  chatqEl.addEventListener("input", refreshPills);
530
779
  refreshPills();
531
780
 
781
+ // ---- deception pills (§A.2.4): a SEPARATE dynamic rail, alongside (never
782
+ // replacing) the static one above. tmctSpiderFly.pillsForSpiderFly is the
783
+ // exact same pure function spider-fly-turn.mjs exports — this page never
784
+ // reimplements the true/false claim logic, only renders its output and
785
+ // fills #chatq on click, same click-to-fill discipline as every other
786
+ // pill on this page (never auto-submits).
787
+ function renderDynamicPills() {
788
+ if (!session || !Object.keys(lastAgents).length) { dynamicPillsEl.innerHTML = ""; return; }
789
+ const result = tmctSpiderFly.pillsForSpiderFly(lastAgents, selectedAddresseeId, {});
790
+ selectedAddresseeId = result.addresseeId;
791
+ const addrHtml = result.addressPills.map((p) =>
792
+ '<button type="button" class="pill" data-role="dyn-addr" data-id="' + esc(p.id) + '"'
793
+ + (p.id === selectedAddresseeId ? ' data-active="1"' : "") + ">" + esc(p.label) + "</button>"
794
+ ).join("");
795
+ const claimHtml = result.claimPills.map((p) =>
796
+ '<button type="button" class="pill" data-role="dyn-claim" data-truth="' + (p.truth ? "true" : "false")
797
+ + '" data-sentence="' + esc(p.sentence) + '">' + esc(p.text) + "</button>"
798
+ ).join("");
799
+ dynamicPillsEl.innerHTML = addrHtml + claimHtml;
800
+ }
801
+ dynamicPillsEl.addEventListener("click", (e) => {
802
+ const btn = e.target.closest(".pill");
803
+ if (!btn) return;
804
+ if (btn.dataset.role === "dyn-addr") {
805
+ selectedAddresseeId = btn.dataset.id;
806
+ renderDynamicPills();
807
+ return;
808
+ }
809
+ if (btn.dataset.role === "dyn-claim") {
810
+ chatqEl.value = btn.dataset.sentence;
811
+ refreshPills();
812
+ chatqEl.focus();
813
+ }
814
+ });
815
+
816
+ // ---- live tuning (mass-loss-rate / spawn-rate / vision-radius, per
817
+ // class): each slider writes straight through session.setConfig, which
818
+ // every future tick()/turn() call reads — a change never rewinds a value
819
+ // already written to a past turn's facts, same posture as editing
820
+ // tmct.toml between sessions, just live and per-class.
821
+ function applyTuningValue(control, value) {
822
+ liveConfig[control.key] = value;
823
+ control.outEl.textContent = String(value);
824
+ control.inputEl.value = String(value);
825
+ if (session) session.setConfig({ [control.key]: value });
826
+ }
827
+ function initTuning(config) {
828
+ liveConfig = { ...config };
829
+ for (const control of TUNING_CONTROLS) {
830
+ applyTuningValue(control, liveConfig[control.key]);
831
+ control.inputEl.disabled = false;
832
+ }
833
+ }
834
+ for (const control of TUNING_CONTROLS) {
835
+ control.inputEl.addEventListener("input", () => {
836
+ const value = Number(control.inputEl.value);
837
+ applyTuningValue(control, value);
838
+ drawPov(); // a vision-radius change should reflect immediately in an open POV overlay
839
+ });
840
+ }
841
+
532
842
  // ---- serialize every engine-touching call: the ticker and the chat dock
533
843
  // share one in-memory store, and an overlapping tick()/turn() pair could
534
844
  // race against the same @turnN write.
@@ -539,8 +849,17 @@ const SPIDERFLY = ${gridData};
539
849
  return run;
540
850
  }
541
851
 
852
+ let tuningInitialized = false;
542
853
  async function boot() {
543
854
  session = await tmctSpiderFly.createSpiderFlySession();
855
+ // A reset mints a brand-new session (fresh board, fresh config) — the
856
+ // FIRST boot seeds the sliders from the engine's own shipped defaults;
857
+ // every boot after that re-applies whatever the visitor already had the
858
+ // sliders set to, so tuning survives a reset instead of silently
859
+ // reverting.
860
+ if (!tuningInitialized) { initTuning(session.getConfig()); tuningInitialized = true; }
861
+ else session.setConfig(liveConfig);
862
+ selectedAddresseeId = null;
544
863
  redraw(session.initial.agents, session.initial.turn, session.initial.activeWebs);
545
864
  statusEl.textContent = session.opening;
546
865
  chatqEl.disabled = false;