@polycode-projects/the-mechanical-code-talker 2.7.2 → 2.7.4

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.
@@ -0,0 +1,492 @@
1
+ // spider-fly-viz.mjs — the spider-and-fly full-screen page (PLAN_SPIDER_FLY.md
2
+ // §8/§9/§11): a self-contained document shaped exactly like ledger-viz.mjs/
3
+ // plan-viz.mjs — one inlined <style> (importing viz-theme.mjs's shared
4
+ // tokens), behaviour as an inlined IIFE — but unlike those two, almost
5
+ // nothing is embedded as build-time data: the whole game is LIVE client-side
6
+ // state (spider-fly-browser-entry.mjs's createSpiderFlySession), so
7
+ // renderSpiderFlyHtml only needs the page's own static grid geometry (which
8
+ // never changes) plus a title. The engine, sprite resolver and chat turn
9
+ // engine all arrive via ./spider-fly-browser.bundle.js, referenced with a
10
+ // plain same-origin <script src> — the same sibling-file arrangement
11
+ // index.html already uses for chat-browser.bundle.js (both are the FULL
12
+ // turn engine, both generated fresh per build, neither meant to be
13
+ // committed), not memory-ask-browser.bundle.js's inlined-text arrangement
14
+ // (that bundle is small, committed, and inlined specifically so ledger.html
15
+ // stays portable on its own — neither reason applies here).
16
+ //
17
+ // renderSpiderFlyHtml() is pure: no I/O, deterministic output for identical
18
+ // input. scripts/build-demo-site.mjs calls it directly and writes the result
19
+ // to public/spider-fly.html, after building the sibling bundle.
20
+ //
21
+ // Three runtime pieces are spliced into the page's own inlined script via
22
+ // `.toString()` — exactly ledger-viz.mjs's own `facetCounts`/
23
+ // `resolveAnsweredTerm` pattern — because they are genuinely UI-only, with no
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
29
+ // resolution, grid geometry and the chat turn engine all come from the
30
+ // bundle's own real ES exports instead, since (unlike ledger-viz, which
31
+ // reuses a FIXED shared bundle it can't extend for one page's own needs) this
32
+ // page ships its own dedicated bundle and can just export what it needs.
33
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
34
+ import { createTicker } from "./viz-ticker.mjs";
35
+ import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
36
+
37
+ const CELL_PX = 44;
38
+ const BOARD_PX = CELL_PX * GRID_SIZE;
39
+ const DEFAULT_TITLE = "tmct — the spider and the fly";
40
+ const PREVIEW_MAX_TURNS = 40;
41
+ const TICK_WAIT_MS = 700;
42
+
43
+ function webCellIds() {
44
+ const out = [];
45
+ for (let y = 1; y <= GRID_SIZE; y += 1) {
46
+ for (let x = 1; x <= GRID_SIZE; x += 1) {
47
+ if (isInWebBlock(x, y)) out.push(cellId(x, y));
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** An agent id's class for sprite/HUD purposes — "spider-2" -> "spider",
54
+ * "fly-10" -> "fly", "egg-1" -> "egg". Self-contained (no outer refs),
55
+ * `.toString()`-splice safe. */
56
+ export function classOfAgentId(id) {
57
+ return String(id).replace(/-\d+$/, "");
58
+ }
59
+
60
+ /**
61
+ * Reconstruct the spider's remaining silk-thread path — the sequence of
62
+ * cell ids from its CURRENT cell (after this tick's one executed step) to
63
+ * wherever `findActionPath` was aiming — from spider-fly.mjs's own
64
+ * `agents[spiderId]` shape ({ cell, plan }), where `plan` is the FULL
65
+ * direction list `findActionPath` returned (the step already taken this
66
+ * tick, `plan[0]`, plus every step still to come). Only a spider with a
67
+ * REAL multi-step plan draws a thread at all: spider-fly.mjs's own
68
+ * `planSpiderPath` only ever returns one when the believed fly cell sits
69
+ * inside the web block (its `isGoal`'s own requirement) — most ticks the
70
+ * spider is greedily closing distance with no such plan, and this
71
+ * correctly returns null then, same as "no proof chain to draw" elsewhere
72
+ * in this project's viz pages.
73
+ *
74
+ * `geometry` is `{ parseCellId, cellId, directionDelta }` — the exact three
75
+ * grid-geometry primitives spider-fly-world.mjs exports, injected rather
76
+ * than imported so this function stays `.toString()`-splice safe (the
77
+ * inlined page calls it with `window.tmctSpiderFly`'s own re-exports of the
78
+ * same three). Returns the ordered cell-id array (length > 1) or null.
79
+ */
80
+ export function threadCellsForSpiderPlan(agents, geometry) {
81
+ const { parseCellId, cellId: toCellId, directionDelta } = geometry;
82
+ for (const [id, agent] of Object.entries(agents || {})) {
83
+ if (!/^spider-/.test(id) || !agent.plan || !agent.plan.length) continue;
84
+ const cells = [agent.cell];
85
+ let cur = parseCellId(agent.cell);
86
+ for (const direction of agent.plan.slice(1)) {
87
+ const delta = directionDelta[direction];
88
+ if (!delta || !cur) break;
89
+ cur = { x: cur.x + delta.dx, y: cur.y + delta.dy };
90
+ cells.push(toCellId(cur.x, cur.y));
91
+ }
92
+ if (cells.length > 1) return cells;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** The self-contained spider-and-fly page. Pure — the same output for the
98
+ * same `title` every time; every other piece of state this page shows is
99
+ * computed live in the browser once the sibling bundle loads. `?preview=1`
100
+ * on the page's own URL switches it into the small, auto-playing,
101
+ * non-interactive mode the home page's hero iframe embeds (§11) — one file
102
+ * serves both the hero and the "open full-screen" link, matching how
103
+ * ledger.html/plan.html are each one file embedded two ways. */
104
+ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE } = {}) {
105
+ const gridData = embedJson({
106
+ gridSize: GRID_SIZE,
107
+ webCells: webCellIds(),
108
+ webHome: WEB_HOME,
109
+ webRadius: WEB_RADIUS,
110
+ boardPx: BOARD_PX,
111
+ cellPx: CELL_PX,
112
+ previewMaxTurns: PREVIEW_MAX_TURNS,
113
+ tickWaitMs: TICK_WAIT_MS,
114
+ });
115
+
116
+ return `<!doctype html>
117
+ <html lang="en">
118
+ <head>
119
+ <meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width, initial-scale=1">
121
+ <title>${escapeHtml(title)}</title>
122
+ <style>
123
+ ${THEME_TOKENS_CSS}
124
+ :root { --fly: #A6791F; }
125
+ @media (prefers-color-scheme: dark) { :root { --fly: #D9A94B; } }
126
+ :root[data-theme="dark"] { --fly: #D9A94B; }
127
+ :root[data-theme="light"] { --fly: #A6791F; }
128
+ html { background: var(--bg); }
129
+ body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
130
+ .mono { font-family: ${MONO_STACK}; }
131
+ main { max-width: 980px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
132
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
133
+ h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
134
+ button { font: inherit; color: inherit; background: none; cursor: pointer; }
135
+ button:focus-visible, input:focus-visible, .sprite:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
136
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 1rem; align-items: start; }
137
+ @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
138
+ .board-frame { position: relative; width: ${BOARD_PX}px; max-width: 100%; aspect-ratio: 1 / 1; background: var(--card); border: 1px solid var(--line); }
139
+ .board-frame canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
140
+ .sprite-layer { position: absolute; inset: 0; }
141
+ .sprite { position: absolute; width: 7.6%; height: 7.6%; transform: translate(-50%, -50%); transition: left .25s ease, top .25s ease; }
142
+ @media (prefers-reduced-motion: reduce) { .sprite { transition: none; } }
143
+ .sprite svg { width: 100%; height: 100%; display: block; }
144
+ .sprite[data-cls="spider"] { color: var(--taught); }
145
+ .sprite[data-cls="fly"] { color: var(--fly); }
146
+ .sprite[data-cls="egg"] { color: var(--muted); }
147
+ .sprite.dimmed { opacity: .28; }
148
+ .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; }
149
+ .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
150
+ .hud, .chat { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
151
+ .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; }
152
+ .hud-row { display: flex; flex-direction: column; gap: .1rem; padding: .3rem 0; border-top: 1px solid var(--line); }
153
+ .hud-row:first-of-type { border-top: none; }
154
+ .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; }
155
+ .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
156
+ .hud-goal { font-size: .85rem; }
157
+ .hud-empty { color: var(--muted); font-size: .85rem; }
158
+ .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 220px; overflow-y: auto; margin-bottom: .5rem; }
159
+ .chatlog:empty { display: none; margin-bottom: 0; }
160
+ .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
161
+ .chatlog .u::before { content: "tmct> "; color: var(--taught); }
162
+ .chatlog .a { font-size: .88rem; line-height: 1.4; white-space: pre-wrap; }
163
+ .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
164
+ .chatlog:empty + .chatask { border-top: none; padding-top: 0; }
165
+ .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
166
+ .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
167
+ .chatask input:disabled { opacity: .5; }
168
+ .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
169
+ .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
170
+ .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
171
+ .controls-row button:disabled { opacity: .4; cursor: default; }
172
+ .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
173
+ .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .5rem; }
174
+ body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
175
+ body.preview main { padding: 0; max-width: none; }
176
+ body.preview .stage { display: block; }
177
+ body.preview .eyebrow, body.preview h1 { display: none; }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <main>
182
+ <div class="eyebrow">tmct &middot; spider and fly</div>
183
+ <h1>A spider in its web, a fly on the board — each planning against the other</h1>
184
+ <div class="stage">
185
+ <div class="board-frame" id="boardFrame">
186
+ <canvas id="board" width="${BOARD_PX}" height="${BOARD_PX}" aria-label="the 10x10 board"></canvas>
187
+ <canvas id="pov" width="${BOARD_PX}" height="${BOARD_PX}" aria-hidden="true"></canvas>
188
+ <div class="sprite-layer" id="spriteLayer"></div>
189
+ <div class="thread-tip" id="threadTip"></div>
190
+ </div>
191
+ <aside class="side" aria-label="Agents and chat">
192
+ <div class="hud">
193
+ <h2>agents</h2>
194
+ <div id="hud"></div>
195
+ </div>
196
+ <div class="chat">
197
+ <h2>tell the spider or the fly something</h2>
198
+ <div class="chatlog" id="chatlog" aria-live="polite"></div>
199
+ <form class="chatask" id="chatform">
200
+ <span class="prompt mono">tmct&gt;</span>
201
+ <input id="chatq" type="text" placeholder="@spider the fly is east" aria-label="Address the spider or the fly" disabled>
202
+ </form>
203
+ </div>
204
+ </aside>
205
+ </div>
206
+ <div class="controls-row">
207
+ <button id="resetBtn" type="button" disabled>reset</button>
208
+ <button id="playBtn" type="button" disabled>&#9654; play</button>
209
+ <button id="stepBtn" type="button" disabled>step</button>
210
+ <span class="turn mono" id="turnLabel">turn: 0</span>
211
+ </div>
212
+ <div class="status" id="status">loading the engine&hellip;</div>
213
+ </main>
214
+ <script>
215
+ const SPIDERFLY = ${gridData};
216
+ </script>
217
+ <script src="./spider-fly-browser.bundle.js"></script>
218
+ <script>
219
+ (function () {
220
+ "use strict";
221
+ const createTicker = ${createTicker.toString()};
222
+ const classOfAgentId = ${classOfAgentId.toString()};
223
+ const threadCellsForSpiderPlan = ${threadCellsForSpiderPlan.toString()};
224
+ const esc = ${escapeHtml.toString()};
225
+ const el = (id) => document.getElementById(id);
226
+ const boardFrame = el("boardFrame");
227
+ const boardCanvas = el("board");
228
+ const povCanvas = el("pov");
229
+ const spriteLayer = el("spriteLayer");
230
+ const threadTip = el("threadTip");
231
+ const hudEl = el("hud");
232
+ const chatlogEl = el("chatlog");
233
+ const chatformEl = el("chatform");
234
+ const chatqEl = el("chatq");
235
+ const statusEl = el("status");
236
+ const turnLabelEl = el("turnLabel");
237
+ const resetBtn = el("resetBtn");
238
+ const playBtn = el("playBtn");
239
+ const stepBtn = el("stepBtn");
240
+
241
+ const params = new URLSearchParams(location.search);
242
+ const preview = params.get("preview") === "1";
243
+ document.body.classList.toggle("preview", preview);
244
+
245
+ const dpr = window.devicePixelRatio || 1;
246
+ const boardCtx = boardCanvas.getContext("2d");
247
+ const povCtx = povCanvas.getContext("2d");
248
+ function sizeCanvas(canvas, ctx) {
249
+ canvas.width = SPIDERFLY.boardPx * dpr;
250
+ canvas.height = SPIDERFLY.boardPx * dpr;
251
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
252
+ }
253
+ sizeCanvas(boardCanvas, boardCtx);
254
+ sizeCanvas(povCanvas, povCtx);
255
+
256
+ const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
257
+
258
+ // ---- board geometry ---------------------------------------------------
259
+ const cellSize = SPIDERFLY.boardPx / SPIDERFLY.gridSize;
260
+ function cellCenterPct(x, y) {
261
+ return { leftPct: ((x - 0.5) / SPIDERFLY.gridSize) * 100, topPct: ((y - 0.5) / SPIDERFLY.gridSize) * 100 };
262
+ }
263
+
264
+ // ---- state shared across redraws --------------------------------------
265
+ let session = null;
266
+ let lastAgents = {};
267
+ let lastTurn = 0;
268
+ const goalById = {};
269
+ const spriteEls = {};
270
+ let povAgentId = null;
271
+ let threadHits = [];
272
+
273
+ function removeStaleSprites(agents) {
274
+ for (const id of Object.keys(spriteEls)) {
275
+ if (!agents[id]) { spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; }
276
+ }
277
+ }
278
+
279
+ function togglePov(id) {
280
+ povAgentId = povAgentId === id ? null : id;
281
+ if (!povAgentId) for (const node of Object.values(spriteEls)) node.classList.remove("dimmed");
282
+ drawPov();
283
+ }
284
+ document.addEventListener("keydown", (e) => {
285
+ if (e.key === "Escape" && povAgentId) togglePov(povAgentId);
286
+ });
287
+
288
+ function ensureSpriteEl(id, cls) {
289
+ let node = spriteEls[id];
290
+ if (node) return node;
291
+ node = document.createElement("div");
292
+ node.className = "sprite";
293
+ node.dataset.cls = cls;
294
+ const sprite = window.tmctSpiderFly
295
+ ? tmctSpiderFly.resolveSpriteForClass(cls, (session && session.taxonomyRows) || [], tmctSpiderFly.SPRITE_REGISTRY)
296
+ : "";
297
+ node.innerHTML = sprite;
298
+ if (!preview) {
299
+ node.tabIndex = 0;
300
+ node.setAttribute("role", "button");
301
+ node.setAttribute("aria-label", "show " + id + "\\u2019s point of view");
302
+ node.addEventListener("click", () => togglePov(id));
303
+ node.addEventListener("keydown", (e) => {
304
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); togglePov(id); }
305
+ });
306
+ }
307
+ spriteLayer.appendChild(node);
308
+ spriteEls[id] = node;
309
+ return node;
310
+ }
311
+
312
+ function applyAgents(agents) {
313
+ removeStaleSprites(agents);
314
+ for (const [id, a] of Object.entries(agents)) {
315
+ const cls = classOfAgentId(id);
316
+ const node = ensureSpriteEl(id, cls);
317
+ const parsed = tmctSpiderFly.parseCellId(a.cell);
318
+ if (!parsed) continue;
319
+ const pct = cellCenterPct(parsed.x, parsed.y);
320
+ node.style.left = pct.leftPct + "%";
321
+ node.style.top = pct.topPct + "%";
322
+ if (a.goal) goalById[id] = a.goal;
323
+ }
324
+ lastAgents = agents;
325
+ }
326
+
327
+ function renderHud() {
328
+ const ids = Object.keys(lastAgents).sort();
329
+ if (!ids.length) { hudEl.innerHTML = '<div class="hud-empty">no agents on the board.</div>'; return; }
330
+ hudEl.innerHTML = ids.map((id) => {
331
+ const cls = classOfAgentId(id);
332
+ return '<div class="hud-row"><span class="hud-id ' + esc(cls) + '">' + esc(id) + '</span>'
333
+ + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span></div>";
334
+ }).join("");
335
+ }
336
+
337
+ const threadGeometry = {
338
+ parseCellId: (id) => tmctSpiderFly.parseCellId(id),
339
+ cellId: (x, y) => tmctSpiderFly.cellId(x, y),
340
+ directionDelta: tmctSpiderFly.DIRECTION_DELTA,
341
+ };
342
+
343
+ function drawBoard(agents) {
344
+ const w = SPIDERFLY.boardPx, h = SPIDERFLY.boardPx;
345
+ boardCtx.clearRect(0, 0, w, h);
346
+ boardCtx.fillStyle = cssVar("--taught-soft") || "rgba(46,125,79,.12)";
347
+ for (const wc of SPIDERFLY.webCells) {
348
+ const p = tmctSpiderFly.parseCellId(wc);
349
+ boardCtx.fillRect((p.x - 1) * cellSize, (p.y - 1) * cellSize, cellSize, cellSize);
350
+ }
351
+ boardCtx.strokeStyle = cssVar("--line") || "#DDD9D0";
352
+ boardCtx.lineWidth = 1;
353
+ for (let i = 0; i <= SPIDERFLY.gridSize; i += 1) {
354
+ boardCtx.beginPath(); boardCtx.moveTo(i * cellSize, 0); boardCtx.lineTo(i * cellSize, h); boardCtx.stroke();
355
+ boardCtx.beginPath(); boardCtx.moveTo(0, i * cellSize); boardCtx.lineTo(w, i * cellSize); boardCtx.stroke();
356
+ }
357
+ threadHits = [];
358
+ const thread = threadCellsForSpiderPlan(agents, threadGeometry);
359
+ if (thread) {
360
+ boardCtx.strokeStyle = cssVar("--taught") || "#2E7D4F";
361
+ boardCtx.lineWidth = 2;
362
+ boardCtx.beginPath();
363
+ let prev = null;
364
+ thread.forEach((c, i) => {
365
+ const p = tmctSpiderFly.parseCellId(c);
366
+ const px = (p.x - 0.5) * cellSize, py = (p.y - 0.5) * cellSize;
367
+ if (i === 0) boardCtx.moveTo(px, py); else boardCtx.lineTo(px, py);
368
+ if (prev) threadHits.push({ x: (prev.x + px) / 2, y: (prev.y + py) / 2, step: i });
369
+ prev = { x: px, y: py };
370
+ });
371
+ boardCtx.stroke();
372
+ }
373
+ }
374
+
375
+ function drawPov() {
376
+ povCtx.clearRect(0, 0, SPIDERFLY.boardPx, SPIDERFLY.boardPx);
377
+ if (!povAgentId) return;
378
+ const agent = lastAgents[povAgentId];
379
+ if (!agent) { povAgentId = null; return; }
380
+ const p = tmctSpiderFly.parseCellId(agent.cell);
381
+ const visible = new Set(tmctSpiderFly.visibleCells(p.x, p.y, tmctSpiderFly.DEFAULT_VISION_RADIUS));
382
+ povCtx.fillStyle = "rgba(0,0,0,.55)";
383
+ for (let gy = 1; gy <= SPIDERFLY.gridSize; gy += 1) {
384
+ for (let gx = 1; gx <= SPIDERFLY.gridSize; gx += 1) {
385
+ if (visible.has(tmctSpiderFly.cellId(gx, gy))) continue;
386
+ povCtx.fillRect((gx - 1) * cellSize, (gy - 1) * cellSize, cellSize, cellSize);
387
+ }
388
+ }
389
+ for (const [id, node] of Object.entries(spriteEls)) {
390
+ const a = lastAgents[id];
391
+ node.classList.toggle("dimmed", id !== povAgentId && !!a && !visible.has(a.cell));
392
+ }
393
+ }
394
+
395
+ boardFrame.addEventListener("mousemove", (e) => {
396
+ if (!threadHits.length) { threadTip.style.display = "none"; return; }
397
+ const rect = boardFrame.getBoundingClientRect();
398
+ const scale = SPIDERFLY.boardPx / rect.width;
399
+ const mx = (e.clientX - rect.left) * scale, my = (e.clientY - rect.top) * scale;
400
+ const hit = threadHits.find((h) => (h.x - mx) ** 2 + (h.y - my) ** 2 <= 196);
401
+ if (!hit) { threadTip.style.display = "none"; return; }
402
+ threadTip.textContent = "step " + hit.step;
403
+ threadTip.style.left = ((hit.x / SPIDERFLY.boardPx) * 100) + "%";
404
+ threadTip.style.top = ((hit.y / SPIDERFLY.boardPx) * 100) + "%";
405
+ threadTip.style.display = "block";
406
+ });
407
+ boardFrame.addEventListener("mouseleave", () => { threadTip.style.display = "none"; });
408
+
409
+ function redraw(agents, turn) {
410
+ applyAgents(agents);
411
+ renderHud();
412
+ lastTurn = turn;
413
+ turnLabelEl.textContent = "turn: " + turn;
414
+ drawBoard(agents);
415
+ drawPov();
416
+ }
417
+
418
+ // ---- chat dock ---------------------------------------------------------
419
+ function addChatLine(cls, html) {
420
+ const d = document.createElement("div");
421
+ d.className = cls; d.innerHTML = html;
422
+ chatlogEl.appendChild(d); chatlogEl.scrollTop = chatlogEl.scrollHeight;
423
+ }
424
+ chatformEl.addEventListener("submit", (e) => {
425
+ e.preventDefault();
426
+ const q = chatqEl.value.trim();
427
+ if (!q || !session) return;
428
+ chatqEl.value = "";
429
+ addChatLine("u", esc(q));
430
+ withLock(async () => {
431
+ const result = await session.turn(q);
432
+ addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
433
+ const snap = await session.snapshot();
434
+ redraw(snap.agents, snap.turn);
435
+ });
436
+ });
437
+
438
+ // ---- serialize every engine-touching call: the ticker and the chat dock
439
+ // share one in-memory store, and an overlapping tick()/turn() pair could
440
+ // race against the same @turnN write.
441
+ let lock = Promise.resolve();
442
+ function withLock(fn) {
443
+ const run = lock.then(fn, fn);
444
+ lock = run.catch(() => {});
445
+ return run;
446
+ }
447
+
448
+ async function boot() {
449
+ session = await tmctSpiderFly.createSpiderFlySession();
450
+ redraw(session.initial.agents, session.initial.turn);
451
+ statusEl.textContent = session.opening;
452
+ chatqEl.disabled = false;
453
+ resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
454
+ }
455
+
456
+ let loopScheduled = false;
457
+ const ticker = createTicker({
458
+ onTick: () => withLock(async () => {
459
+ const result = await session.tick();
460
+ redraw(result.agents, result.turn);
461
+ }),
462
+ onRender: (state) => {
463
+ playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
464
+ playBtn.disabled = state.animating;
465
+ stepBtn.disabled = state.animating || state.playing;
466
+ resetBtn.disabled = state.animating;
467
+ if (preview && !state.playing && !state.animating && lastTurn >= SPIDERFLY.previewMaxTurns && !loopScheduled) {
468
+ loopScheduled = true;
469
+ setTimeout(() => { loopScheduled = false; ticker.reset().then(() => ticker.play()); }, 1200);
470
+ }
471
+ },
472
+ onReset: () => withLock(boot),
473
+ hasNext: () => !preview || lastTurn < SPIDERFLY.previewMaxTurns,
474
+ waitMs: SPIDERFLY.tickWaitMs,
475
+ });
476
+ playBtn.addEventListener("click", () => ticker.play());
477
+ stepBtn.addEventListener("click", () => ticker.stepOnce());
478
+ resetBtn.addEventListener("click", () => ticker.reset());
479
+
480
+ boot().then(() => { if (preview) ticker.play(); });
481
+
482
+ new MutationObserver(() => { drawBoard(lastAgents); drawPov(); })
483
+ .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
484
+ if (window.matchMedia) {
485
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { drawBoard(lastAgents); drawPov(); });
486
+ }
487
+ })();
488
+ </script>
489
+ </body>
490
+ </html>
491
+ `;
492
+ }