@polycode-projects/the-mechanical-code-talker 4.0.1 → 4.1.0

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.
Files changed (62) hide show
  1. package/README.md +2 -1
  2. package/corpus/sprites/src/sprite-facts.jsonl +375 -8
  3. package/package.json +1 -1
  4. package/src/adapters/memory/core.mjs +20 -0
  5. package/src/domain/ask-vocab.mjs +71 -0
  6. package/src/domain/ask.mjs +168 -0
  7. package/src/domain/game-config.mjs +11 -0
  8. package/src/domain/mud-facts.mjs +15 -0
  9. package/src/domain/router/drive.mjs +35 -9
  10. package/src/domain/router/registry.mjs +24 -4
  11. package/src/domain/router/resolver.mjs +102 -40
  12. package/src/domain/scene-compose.mjs +117 -0
  13. package/src/domain/spider-fly-world.mjs +36 -0
  14. package/src/domain/sprite-facts.mjs +0 -0
  15. package/src/domain/sprite-request.mjs +156 -0
  16. package/src/domain/sprite-templates.mjs +161 -14
  17. package/src/services/adventure-editor.mjs +8 -14
  18. package/src/services/adventure-viz.mjs +90 -121
  19. package/src/services/adventure.mjs +97 -35
  20. package/src/services/chat-page-viz.mjs +32 -16
  21. package/src/services/chat.mjs +101 -33
  22. package/src/services/code-explorer-viz.mjs +51 -49
  23. package/src/services/ingest-viz.mjs +15 -57
  24. package/src/services/ledger-viz.mjs +47 -27
  25. package/src/services/memory-panel-viz.mjs +38 -0
  26. package/src/services/mud-editor.mjs +10 -15
  27. package/src/services/mud-turn.mjs +6 -6
  28. package/src/services/mud-viz.mjs +87 -198
  29. package/src/services/p2p-room.mjs +90 -23
  30. package/src/services/plan-pddl.mjs +3 -1
  31. package/src/services/plan-viz.mjs +4 -3
  32. package/src/services/research-viz.mjs +10 -52
  33. package/src/services/spider-fly-turn.mjs +14 -22
  34. package/src/services/spider-fly-viz.mjs +79 -118
  35. package/src/services/spider-fly.mjs +69 -11
  36. package/src/services/sprite-catalog-viz.mjs +271 -221
  37. package/src/services/viz-boot.mjs +71 -0
  38. package/src/services/viz-room-graph.mjs +203 -0
  39. package/src/services/viz-theme.mjs +75 -1
  40. package/src/services/viz-ticker.mjs +22 -0
  41. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  42. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  43. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  44. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  45. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  46. package/src/surfaces/web/memory-ask-browser.bundle.js +128 -125
  47. package/src/surfaces/web/memory-stats.mjs +11 -0
  48. package/src/surfaces/web/mud-browser-entry.mjs +28 -28
  49. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  50. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  51. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  52. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  53. package/src/surfaces/web/turn-session.mjs +120 -0
  54. package/src/tools/definitions.mjs +30 -0
  55. package/src/tools/handlers/index.mjs +6 -3
  56. package/src/tools/handlers/kit.mjs +19 -2
  57. package/src/tools/handlers/tmct-ask.mjs +11 -6
  58. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  59. package/src/tools/handlers/tmct-related.mjs +4 -4
  60. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  61. package/src/tools/memory-fallthrough.mjs +9 -2
  62. package/src/tools/server.mjs +25 -1
@@ -18,7 +18,7 @@
18
18
  // inline script degrades honestly when that sibling script is absent or
19
19
  // fails to load — the live controls disable themselves rather than pretend
20
20
  // to work.
21
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
21
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, countLabel } from "./viz-theme.mjs";
22
22
  import { planToPddl } from "./plan-pddl.mjs";
23
23
 
24
24
  const BOARD_W = 640;
@@ -285,7 +285,7 @@ export function renderPlanHtml({ plan, rendersAs = {}, sizeOrder = [], title } =
285
285
  const pageData = planToPageData({ plan, rendersAs, sizeOrder });
286
286
  const embedded = embedJson(pageData);
287
287
  const pddlText = planToPddl(plan);
288
- const pageTitle = title || `tmct plan — ${actions.length} move${actions.length === 1 ? "" : "s"}`;
288
+ const pageTitle = title || `tmct plan — ${countLabel(actions.length, "move")}`;
289
289
 
290
290
  return `<!doctype html>
291
291
  <!-- data-theme is pinned to dark on purpose: this page reads as a track/
@@ -483,6 +483,7 @@ const PLAN = ${embedded};
483
483
  (function () {
484
484
  "use strict";
485
485
  const escapeHtml = ${escapeHtml.toString()};
486
+ const countLabel = ${countLabel.toString()};
486
487
  // Best-effort: a copy of this page opened without the sibling worker file
487
488
  // (a tmct --render plan --output file, a file:// open) just swallows the
488
489
  // registration failure and works exactly as before.
@@ -759,7 +760,7 @@ const PLAN = ${embedded};
759
760
  session = await tmctPlan.createPlanSession({ diskCount: n, maxDepth: d });
760
761
  if (session.plan) {
761
762
  applyPlan(session.plan);
762
- liveStatusEl.textContent = "live — " + n + " disk" + (n === 1 ? "" : "s") + ", max depth " + d + ".";
763
+ liveStatusEl.textContent = "live — " + countLabel(n, "disk", "disks") + ", max depth " + d + ".";
763
764
  } else {
764
765
  liveStatusEl.textContent = "no plan found within " + d + " moves — raise max search depth and try again.";
765
766
  liveStatusEl.classList.add("isError");
@@ -20,8 +20,10 @@
20
20
  // input. scripts/build-demo-site.mjs calls it directly and writes the result to
21
21
  // public/research.html, after research-browser.bundle.js already exists.
22
22
  import { THEME_TOKENS_CSS, MONO_STACK, escapeHtml } from "./viz-theme.mjs";
23
- import { fetchWithProgress } from "./memory-panel-viz.mjs";
23
+ import { fetchWithProgress, loadProgressLine, factTripleParts } from "./memory-panel-viz.mjs";
24
24
  import { createTicker, prefersReducedMotion } from "./viz-ticker.mjs";
25
+ import { loadWinkVendor } from "./viz-boot.mjs";
26
+ import { cloneMemoryPayload } from "../adapters/memory/core.mjs";
25
27
 
26
28
  const DEFAULT_TITLE = "the-mechanical-code-talker — research";
27
29
 
@@ -63,35 +65,6 @@ export function sourceLabelFor(source) {
63
65
  return { label: (BANDS[band] || band || "seed") + " (seed corpus)", tone: "seed" };
64
66
  }
65
67
 
66
- /** One learned fact as its three canonical cells plus a source tone, for the
67
- * highlights and history lists. Pure, `.toString()`-splice safe. */
68
- export function factTripleParts(fact) {
69
- return {
70
- subject: String((fact && fact.subject) || ""),
71
- predicate: String((fact && fact.predicate) || ""),
72
- object: String((fact && fact.object) || ""),
73
- source: String((fact && fact.source) || ""),
74
- };
75
- }
76
-
77
- /** The boot statusline while the big assets stream in — the same aggregator
78
- * ingest-viz.mjs's own loadProgressLine is. `parts` is an array of
79
- * { loaded, total } byte counts. Self-contained, `.toString()`-splice safe. */
80
- export function loadProgressLine(parts) {
81
- const mb = (n) => (n / 1048576).toFixed(1);
82
- let loaded = 0;
83
- let total = 0;
84
- let totalKnown = true;
85
- for (const p of parts || []) {
86
- loaded += (p && p.loaded) || 0;
87
- if (p && p.total > 0) total += p.total;
88
- else totalKnown = false;
89
- }
90
- return totalKnown && total > 0
91
- ? "loading the engine… " + mb(loaded) + " MB / " + mb(total) + " MB"
92
- : "loading the engine… " + mb(loaded) + " MB";
93
- }
94
-
95
68
  /** The self-contained research page. Pure — the same output for the same
96
69
  * input every time; every piece of state is computed live in the browser once
97
70
  * the sibling research bundle loads. `digestStructures` are the pre-parsed
@@ -136,7 +109,7 @@ ${DASH_DARK_CHROME_CSS}
136
109
  .brand { display: flex; flex-direction: column; gap: .3rem; max-width: 640px; }
137
110
  .eyebrow { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
138
111
  .subtitle { font-size: .92rem; color: var(--ink); opacity: .82; max-width: 58ch; }
139
- .statuspanel { display: flex; gap: 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
112
+ .statuspanel { display: flex; flex-wrap: wrap; gap: .5rem 1.1rem; background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: .5rem .9rem; }
140
113
  .statuspanel .stat { display: flex; flex-direction: column; gap: .14rem; min-width: 7rem; }
141
114
  .statuspanel .stat-label { font-family: ${MONO_STACK}; font-size: .6rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
142
115
  .statuspanel .stat-value { font-family: ${MONO_STACK}; font-size: .82rem; font-variant-numeric: tabular-nums; color: var(--ink); }
@@ -255,6 +228,7 @@ ${DASH_DARK_CHROME_CSS}
255
228
  .cols { grid-template-columns: 1fr; }
256
229
  .chip.tapchip { grid-template-columns: 1.5rem minmax(0,1fr) 3.2rem; }
257
230
  .chip.tapchip .track { display: none; }
231
+ .statuspanel .stat { min-width: 0; }
258
232
  }
259
233
  @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; } }
260
234
  @media (prefers-reduced-motion: no-preference) {
@@ -381,6 +355,8 @@ ${DASH_DARK_CHROME_CSS}
381
355
  const fetchWithProgress = ${fetchWithProgress.toString()};
382
356
  const createTicker = ${createTicker.toString()};
383
357
  const prefersReducedMotion = ${prefersReducedMotion.toString()};
358
+ const loadWinkVendor = ${loadWinkVendor.toString()};
359
+ const cloneMemoryPayload = ${cloneMemoryPayload.toString()};
384
360
  const el = (id) => document.getElementById(id);
385
361
  // The digest sentence-structure bank, pre-parsed at build time — the browser
386
362
  // has no TOML parser, so the page carries the table the client-side digest
@@ -409,7 +385,6 @@ ${DASH_DARK_CHROME_CSS}
409
385
  const checkedSources = new Set(); // source keys currently checked for the ask
410
386
 
411
387
  // ---- boot --------------------------------------------------------------
412
- const WINK_LOAD_TIMEOUT_MS = 8000;
413
388
  const progressParts = {};
414
389
  let progressActive = true;
415
390
  function noteProgress(key, loaded, total) {
@@ -417,20 +392,7 @@ ${DASH_DARK_CHROME_CSS}
417
392
  if (progressActive) statEngineEl.textContent = loadProgressLine(Object.values(progressParts));
418
393
  }
419
394
 
420
- async function tryLoadWink() {
421
- let settled = false;
422
- const timeout = new Promise((_, reject) => setTimeout(() => { if (!settled) reject(new Error("wink load stalled")); }, WINK_LOAD_TIMEOUT_MS));
423
- try {
424
- const mod = await Promise.race([import("./vendor/wink.js"), timeout]);
425
- settled = true;
426
- window.tmctResearch.registerWinkModel(() => ({ winkNLP: mod.winkNLP, model: mod.model }));
427
- return "loaded";
428
- } catch (err) {
429
- settled = true;
430
- console.warn("tmct research: the wink vendor asset failed to load", err);
431
- return "unavailable";
432
- }
433
- }
395
+ const loadWink = loadWinkVendor({ register: (factory) => window.tmctResearch.registerWinkModel(factory) });
434
396
 
435
397
  let seedPayload = null;
436
398
  let seedFacts = 0;
@@ -444,12 +406,8 @@ ${DASH_DARK_CHROME_CSS}
444
406
  console.warn("tmct research: chat-seed.json unavailable — starting unseeded", err);
445
407
  }
446
408
  }
447
- const cloneSeed = () => {
448
- if (!seedPayload) return null;
449
- try { return structuredClone(seedPayload); } catch { return JSON.parse(JSON.stringify(seedPayload)); }
450
- };
451
409
  function newSession() {
452
- return window.tmctResearch.createResearchSession({ seedPayload: cloneSeed(), vocabSeeded: Boolean(seedPayload), digestStructures: DIGEST_STRUCTURES });
410
+ return window.tmctResearch.createResearchSession({ seedPayload: cloneMemoryPayload(seedPayload), vocabSeeded: Boolean(seedPayload), digestStructures: DIGEST_STRUCTURES });
453
411
  }
454
412
 
455
413
  // The curated reference pack provider, same fetch seam chat.html registers,
@@ -481,7 +439,7 @@ ${DASH_DARK_CHROME_CSS}
481
439
  statEngineEl.textContent = "unavailable";
482
440
  return;
483
441
  }
484
- const [winkStatus] = await Promise.all([tryLoadWink(), fetchSeed()]);
442
+ const [winkStatus] = await Promise.all([loadWink(), fetchSeed()]);
485
443
  progressActive = false;
486
444
  window.tmctResearch.registerReferencePackProvider(fetchPackProvider);
487
445
  session = newSession();
@@ -17,6 +17,7 @@
17
17
 
18
18
  import {
19
19
  DIRECTION_DELTA, WORLD_NAME, cellId, parseCellId, inBounds, chebyshevDistance, oneStepDirectionBetween,
20
+ agentKindOf, liveIdsOfKind,
20
21
  } from "../domain/spider-fly-world.mjs";
21
22
  import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame, beliefSnapshotFor } from "./spider-fly.mjs";
22
23
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
@@ -121,11 +122,6 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig
121
122
 
122
123
  // ---- resolving an addressed agent / belief target ----------------------------
123
124
 
124
- const liveIdsOfKind = (kind, state) => {
125
- const re = new RegExp(`^${kind}-\\d+$`);
126
- return [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
127
- };
128
-
129
125
  /** An exact "kind-num" reference, or (no number given) the first live
130
126
  * individual of that kind — null when nothing live matches. */
131
127
  function resolveAgentId(kind, num, state) {
@@ -133,7 +129,7 @@ function resolveAgentId(kind, num, state) {
133
129
  const id = `${kind}-${num}`;
134
130
  return state.placements.has(id) && !state.removed.has(id) ? id : null;
135
131
  }
136
- const live = liveIdsOfKind(kind, state);
132
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
137
133
  return live[0] ?? null;
138
134
  }
139
135
 
@@ -143,7 +139,7 @@ function resolveAgentId(kind, num, state) {
143
139
  * than one spider or fly. */
144
140
  function resolveNearestAgentId(kind, num, state, nearCell) {
145
141
  if (num) return resolveAgentId(kind, num, state);
146
- const live = liveIdsOfKind(kind, state);
142
+ const live = liveIdsOfKind(state.placements, kind, state.removed);
147
143
  if (!live.length || !nearCell) return live[0] ?? null;
148
144
  let best = live[0];
149
145
  let bestDist = Infinity;
@@ -193,11 +189,6 @@ export { oneStepDirectionBetween };
193
189
  // claim, true or false alike. A pill's `truth` tag is for the human eye
194
190
  // only — it never rides along in the submitted text itself.
195
191
 
196
- const liveIdsOfKindFromAgents = (kind, agents) => {
197
- const re = new RegExp(`^${kind}-\\d+$`);
198
- return Object.keys(agents || {}).filter((id) => re.test(id)).sort();
199
- };
200
-
201
192
  /** "spider"/"fly" bare when exactly one individual of that kind is live
202
193
  * (nothing to disambiguate), else the individual's own numbered id — a
203
194
  * pill-set legibility choice, not a grammar restriction (the addressed
@@ -246,8 +237,8 @@ function reflectedCell(cell) {
246
237
  */
247
238
  export function pillsForSpiderFly(agents, explicitAddresseeId, opts = {}) {
248
239
  const { defaultKind = "spider" } = opts;
249
- const liveSpiders = liveIdsOfKindFromAgents("spider", agents);
250
- const liveFlies = liveIdsOfKindFromAgents("fly", agents);
240
+ const liveSpiders = liveIdsOfKind(agents, "spider");
241
+ const liveFlies = liveIdsOfKind(agents, "fly");
251
242
 
252
243
  const addressPills = [
253
244
  ...liveSpiders.map((id) => ({ id, kind: "spider", label: `@${agentPillLabel("spider", id, liveSpiders)}` })),
@@ -258,7 +249,7 @@ export function pillsForSpiderFly(agents, explicitAddresseeId, opts = {}) {
258
249
  const addresseeId = (explicitAddresseeId && agents[explicitAddresseeId]) ? explicitAddresseeId : fallbackAddresseeId;
259
250
  if (!addresseeId) return { addressPills, claimPills: [], addresseeId: null };
260
251
 
261
- const addresseeKind = /^spider-\d+$/.test(addresseeId) ? "spider" : "fly";
252
+ const addresseeKind = agentKindOf(addresseeId);
262
253
  const addresseeLabel = agentPillLabel(addresseeKind, addresseeId, addresseeKind === "spider" ? liveSpiders : liveFlies);
263
254
  const addresseeCell = parseCellId(agents[addresseeId].cell);
264
255
 
@@ -352,10 +343,11 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
352
343
 
353
344
  /** One `[id, cellId | null]` belief entry as a sentence: `"spider-1 is at
354
345
  * cell-3-4."` when observed/told, `"fly-2 has not been observed."`
355
- * otherwise — the same wording spider-fly-viz.mjs's own click-expand panel
356
- * (observedFactsHtml) renders, so the chat phrasing and the browser panel
357
- * never disagree about what an agent can see. */
358
- function observedFactSentence(id, believedCell) {
346
+ * otherwise — exported so spider-fly-viz.mjs's own click-expand panel
347
+ * (observedFactsHtml) renders from the SAME function instead of a
348
+ * hand-typed copy, so the chat phrasing and the browser panel can never
349
+ * drift apart. Pure, self-contained, `.toString()`-splice safe. */
350
+ export function believedFactSentence(id, believedCell) {
359
351
  return believedCell ? `${id} is at ${believedCell}.` : `${id} has not been observed.`;
360
352
  }
361
353
 
@@ -374,14 +366,14 @@ async function spiderFlyBeliefAnswer(match, { memoryDir, gameConfig = DEFAULT_GA
374
366
  const observerId = resolveAgentId(kind, num, state);
375
367
  if (!observerId) return noSuchAgentAnswer(kind, "addressee");
376
368
  const observerCell = parseCellId(state.placements.get(observerId).cell);
377
- const candidateIds = [...liveIdsOfKind("spider", state), ...liveIdsOfKind("fly", state)];
369
+ const candidateIds = [...liveIdsOfKind(state.placements, "spider", state.removed), ...liveIdsOfKind(state.placements, "fly", state.removed)];
378
370
  const visionRadius = kind === "spider"
379
371
  ? gameConfig?.spiderFly?.spiderVisionRadius
380
372
  : gameConfig?.spiderFly?.flyVisionRadius;
381
373
  const belief = beliefSnapshotFor(observerId, observerCell, candidateIds, state, { visionRadius });
382
374
  const entries = Object.entries(belief);
383
375
  const text = entries.length
384
- ? `${observerId} sees: ${entries.map(([id, cell]) => observedFactSentence(id, cell)).join(" ")}`
376
+ ? `${observerId} sees: ${entries.map(([id, cell]) => believedFactSentence(id, cell)).join(" ")}`
385
377
  : `${observerId} is alone on the board — nothing else to see.`;
386
378
  return {
387
379
  text,
@@ -446,7 +438,7 @@ const SF_GOAL_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:goal|objective|po
446
438
  const WATCHER_STANCE = 'you have no piece here — both agents move on their own. Watch, say "tick" to advance, or address one, e.g. "@spider the fly is east".';
447
439
 
448
440
  const positionsOfKind = (kind, state) =>
449
- liveIdsOfKind(kind, state).map((id) => `${id} at ${state.placements.get(id).cell}`);
441
+ liveIdsOfKind(state.placements, kind, state.removed).map((id) => `${id} at ${state.placements.get(id).cell}`);
450
442
 
451
443
  async function spiderFlyContextAnswer(line, { memoryDir }) {
452
444
  const l = String(line).trim();
@@ -22,7 +22,8 @@
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`,
25
+ // the shared play/pause/step/reset primitive), `agentKindOf` (the shared
26
+ // kind-from-id helper, defined once in spider-fly-world.mjs),
26
27
  // `threadCellsForSpiderPlan` (the silk-thread reconstruction), `nextCorpses`
27
28
  // (the dying-agent bookkeeping behind the dusty-corner corpse pile) and
28
29
  // `facingDegreesFor` (plan-driven sprite orientation) — all kept as real,
@@ -32,11 +33,14 @@
32
33
  // bundle's own real ES exports instead, since (unlike ledger-viz, which
33
34
  // reuses a FIXED shared bundle it can't extend for one page's own needs) this
34
35
  // page ships its own dedicated bundle and can just export what it needs.
35
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
36
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText, meterBarHtml } from "./viz-theme.mjs";
36
37
  import { createTicker } from "./viz-ticker.mjs";
37
- import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
38
+ import { loadWinkVendor } from "./viz-boot.mjs";
39
+ import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId, agentKindOf } from "../domain/spider-fly-world.mjs";
38
40
  import { FLY_INITIAL_MASS, EGG_LAY_MASS_THRESHOLD } from "./spider-fly.mjs";
39
- import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
41
+ import { believedFactSentence } from "./spider-fly-turn.mjs";
42
+ import { DEFAULT_GAME_CONFIG, massScaleFor } from "../domain/game-config.mjs";
43
+ import { resolveSpriteRequest } from "../domain/sprite-request.mjs";
40
44
 
41
45
  // A larger cell than this page's first cut (44px) — the board sits in its
42
46
  // own full-width block, so a bigger cell keeps it reading as the page's
@@ -62,13 +66,6 @@ function webCellIds() {
62
66
  return out;
63
67
  }
64
68
 
65
- /** An agent id's class for sprite/HUD purposes — "spider-2" -> "spider",
66
- * "fly-10" -> "fly", "egg-1" -> "egg". Self-contained (no outer refs),
67
- * `.toString()`-splice safe. */
68
- export function classOfAgentId(id) {
69
- return String(id).replace(/-\d+$/, "");
70
- }
71
-
72
69
  /**
73
70
  * Reconstruct the spider's remaining silk-thread path — the sequence of
74
71
  * cell ids from its CURRENT cell (after this tick's one executed step) to
@@ -128,54 +125,6 @@ export function facingDegreesFor(plan, previousDegrees) {
128
125
  return previousDegrees ?? 0;
129
126
  }
130
127
 
131
- /** The instance-level `mgx:feels` emotion for one spider-fly agent this
132
- * tick — a PURE derivation from state spider-fly.mjs's own `runSpiderFlyTick`
133
- * already returns on the agent (`goal`/`mass`), never a new persisted fact,
134
- * the same derive-don't-duplicate posture `hasActiveWebAt` already holds for
135
- * web state (PLAN_GAMES_UPLIFT_V3.md §B.2.4). Operator-confirmed mapping: a
136
- * spider that just ate is happy; a spider carrying an uncaught fly or
137
- * mid-chase (chasing, or co-located and about to catch) is angry (predatory
138
- * focus); a spider avoiding another spider is scared; a spider holding
139
- * position or building a web is calm. A fly evading a believed-visible
140
- * spider is scared, and so is the sharper form of the same fear — just
141
- * caught, or already being carried; a fly with no threat visible (wandering,
142
- * or trapped with none in sight) is calm.
143
- *
144
- * `agent.goal` is spider-fly.mjs's own fixed-template text (`goalLineFor`'s
145
- * literal phrasing) — the only reliable per-tick discriminator this
146
- * function's caller has, and every real goal string that engine can ever
147
- * produce is matched below by its own distinguishing prefix. A goal this
148
- * function doesn't recognize (a freshly hatched/spawned agent's "no goal
149
- * yet", or the transient "X is gone — re-evaluating" scrub once a named
150
- * agent dies) falls through to "calm" — the 6-word vocabulary's own
151
- * deliberate no-strong-emotion baseline (sprite-expressions.mjs's B.2.1
152
- * design), not a guess.
153
- *
154
- * `maxMass` is accepted for parity with this page's own per-class mass-bar
155
- * denominators (`SPIDERFLY.maxSpiderMass`/`maxFlyMass`) but unused today —
156
- * no state in the operator's own confirmed table keys off a mass ratio,
157
- * only off the goal text above. Self-contained (no outer refs),
158
- * `.toString()`-splice safe. */
159
- export function emotionFor(agent, kind, maxMass) {
160
- const goal = agent?.goal || "";
161
- if (kind === "spider") {
162
- if (goal.startsWith("just ate")) return "happy";
163
- if (goal.startsWith("carrying") || goal.startsWith("chasing") || goal.startsWith("co-located with")) return "angry";
164
- if (goal.startsWith("avoiding")) return "scared";
165
- return "calm";
166
- }
167
- if (kind === "fly") {
168
- if (
169
- goal.startsWith("evading")
170
- || goal.startsWith("being carried by")
171
- || goal.startsWith("just caught by")
172
- || goal.startsWith("trapped in an active web")
173
- ) return "scared";
174
- return "calm";
175
- }
176
- return "calm";
177
- }
178
-
179
128
  /**
180
129
  * The updated corpse set for one redraw (§A.2.5 — visual-only, entirely
181
130
  * client-side; the actual starve/eat removal already happened in the
@@ -200,7 +149,7 @@ export function nextCorpses(prevCorpses, prevAgents, agents, turn, lingerTurns =
200
149
  if (agents[id] || out[id]) continue;
201
150
  const cell = prevAgents[id]?.cell;
202
151
  if (!cell) continue;
203
- out[id] = { cls: classOfAgentId(id), cell, diedAtTurn: turn };
152
+ out[id] = { cls: agentKindOf(id), cell, diedAtTurn: turn };
204
153
  }
205
154
  return out;
206
155
  }
@@ -409,9 +358,9 @@ ${THEME_TOKENS_CSS}
409
358
  texture (repeating-linear-gradient) instead of a smooth gradient bar —
410
359
  discrete resource units, the same reading-at-a-glance language a 90s
411
360
  strategy game's own gold/mana meters use. */
412
- .mass-track { height: 7px; margin-top: .35rem; background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 1px; overflow: hidden; }
413
- .mass-fill { height: 100%; background: repeating-linear-gradient(90deg, var(--taught) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
414
- .mass-fill.fly { background: repeating-linear-gradient(90deg, var(--fly) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
361
+ .meter-track { height: 7px; margin-top: .35rem; background: var(--chrome-well); border: 1px solid var(--chrome-edge-lo); box-shadow: var(--chrome-shadow-inset); border-radius: 1px; overflow: hidden; }
362
+ .meter-fill { height: 100%; background: repeating-linear-gradient(90deg, var(--taught) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
363
+ .meter-fill.fly { background: repeating-linear-gradient(90deg, var(--fly) 0 6px, rgba(0, 0, 0, .25) 6px 7px); }
415
364
  .hud-empty { color: var(--muted); font-size: .85rem; }
416
365
  .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 220px; overflow-y: auto; margin-bottom: .5rem; }
417
366
  .chatlog:empty { display: none; margin-bottom: 0; }
@@ -591,12 +540,25 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
591
540
  (function () {
592
541
  "use strict";
593
542
  const createTicker = ${createTicker.toString()};
594
- const classOfAgentId = ${classOfAgentId.toString()};
543
+ const loadWinkVendor = ${loadWinkVendor.toString()};
544
+ const agentKindOf = ${agentKindOf.toString()};
595
545
  const threadCellsForSpiderPlan = ${threadCellsForSpiderPlan.toString()};
596
546
  const facingDegreesFor = ${facingDegreesFor.toString()};
597
- const emotionFor = ${emotionFor.toString()};
547
+ const resolveSpriteRequest = ${resolveSpriteRequest.toString()};
598
548
  const nextCorpses = ${nextCorpses.toString()};
599
- const esc = ${escapeHtml.toString()};
549
+ const massScaleFor = ${massScaleFor.toString()};
550
+ const believedFactSentence = ${believedFactSentence.toString()};
551
+ const escapeHtml = ${escapeHtml.toString()};
552
+ const meterBarHtml = ${meterBarHtml.toString()};
553
+ const esc = escapeHtml;
554
+ // The lemma/POS tier every sibling dock loads: this game's OWN commands
555
+ // (tick, address, stop, see) are closed regexes that never need it, but a
556
+ // line that doesn't match one of those still falls through to the
557
+ // ordinary chat/ask/teach lanes (spiderFlyTurn returns null), and those
558
+ // lanes read exactly as much better with wink loaded as they do on every
559
+ // other page. Fired eagerly here so it is likely settled well before a
560
+ // visitor's first ordinary (non-game) question.
561
+ loadWinkVendor({ register: (factory) => tmctSpiderFly.registerWinkModel(factory) })();
600
562
  const el = (id) => document.getElementById(id);
601
563
  const boardFrame = el("boardFrame");
602
564
  const boardCanvas = el("board");
@@ -660,7 +622,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
660
622
  const goalById = {};
661
623
  const spriteEls = {};
662
624
  const facingByAgent = {};
663
- const emotionByAgent = {};
625
+ const moodByAgent = {};
664
626
  let corpses = {};
665
627
  const corpseEls = {};
666
628
  let selectedAddresseeId = null;
@@ -675,30 +637,37 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
675
637
  function removeStaleSprites(agents) {
676
638
  for (const id of Object.keys(spriteEls)) {
677
639
  if (!agents[id]) {
678
- spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; delete facingByAgent[id]; delete emotionByAgent[id];
640
+ spriteEls[id].remove(); delete spriteEls[id]; delete goalById[id]; delete facingByAgent[id]; delete moodByAgent[id];
679
641
  }
680
642
  }
681
643
  }
682
644
 
683
- // maxMassFor/propertyFactsForAgent (emotionFor's own caller-side glue): only
684
- // spider/fly carry the mgx:feels parameter (data/sprites-large/*-with-
685
- // emotion.toml exists for those two classes only) every other class (egg,
686
- // and any class this page has no template for at all) keeps propertyFacts
687
- // empty, resolving through its plain template exactly as before this page
688
- // wired emotion through. maxMassFor mirrors massBarHtml's own per-class
689
- // denominator below so the HUD's mass bar and the sprite's own expression
690
- // read the same "how full" scale.
691
- function maxMassFor(cls) {
692
- return cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
693
- }
694
- function propertyFactsForAgent(agent, cls) {
695
- if (cls !== "spider" && cls !== "fly") return [];
696
- return [{ predicate: "mgx:feels", object: emotionFor(agent, cls, maxMassFor(cls)) }];
645
+ // The engine writes each agent's mood as a real mgx:feels fact every turn
646
+ // and hands the same word back on the agent, so this page reads it rather
647
+ // than deriving a mood of its own. Only spider/fly carry the mgx:feels
648
+ // parameter (data/sprites-large/*-with-emotion.toml exists for those two
649
+ // classes only) every other class (egg, and any class this page has no
650
+ // template for at all) asks for no expression at all, resolving through its
651
+ // plain template. A snapshot-sourced agent carries no mood (a chat-driven
652
+ // tick returns text, not the structured tick shape), so the last mood the
653
+ // engine reported stands, exactly as goalById already holds the last goal.
654
+ // The starting board has no reported mood at all, and calm is the mood the
655
+ // engine itself writes for a just-minted agent, so that is the baseline.
656
+ function expressionForAgent(id, agent, cls) {
657
+ if (cls !== "spider" && cls !== "fly") return null;
658
+ return (agent && agent.mood) || moodByAgent[id] || "calm";
697
659
  }
698
- function resolveSpriteFace(cls, propertyFacts) {
699
- return window.tmctSpiderFly
700
- ? tmctSpiderFly.resolveSpriteAsset(cls, (session && session.taxonomyRows) || [], propertyFacts, SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
701
- : "";
660
+ // One sprite request, in the same { class, expression } shape tmct_sprite
661
+ // takes, answered by the same pure resolver the tool wraps (sprite-request.mjs,
662
+ // spliced in above) the page holds the state and nothing else.
663
+ function resolveSpriteFace(spriteRequest) {
664
+ if (!window.tmctSpiderFly) return "";
665
+ return resolveSpriteRequest(spriteRequest, {
666
+ factRows: (session && session.taxonomyRows) || [],
667
+ templates: SPIDERFLY.spriteTemplates,
668
+ spriteRegistry: tmctSpiderFly.SPRITE_REGISTRY,
669
+ resolveSpriteAsset: tmctSpiderFly.resolveSpriteAsset,
670
+ }).svg || "";
702
671
  }
703
672
 
704
673
  function togglePov(id) {
@@ -717,18 +686,18 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
717
686
  node.className = "sprite";
718
687
  node.dataset.cls = cls;
719
688
  // Property-aware resolution (sprite-templates.mjs's resolveSpriteAsset):
720
- // a spider/fly's live mgx:feels emotion (emotionFor, above) resolves it
721
- // through the matching data/sprites-large/*-with-emotion.toml template;
722
- // every other class (egg) keeps propertyFacts empty and resolves through
723
- // its plain class template (or the flat SPRITE_REGISTRY), unchanged.
724
- const propertyFacts = propertyFactsForAgent(agent, cls);
725
- emotionByAgent[id] = propertyFacts[0] ? propertyFacts[0].object : null;
689
+ // a spider/fly's live mgx:feels mood resolves it through the matching
690
+ // data/sprites-large/*-with-emotion.toml template; every other class
691
+ // (egg) asks for no expression and resolves through its plain class
692
+ // template (or the flat SPRITE_REGISTRY), unchanged.
693
+ const mood = expressionForAgent(id, agent, cls);
694
+ moodByAgent[id] = mood;
726
695
  // The sprite SVG lives in its own inner wrapper so plan-driven facing
727
696
  // (a CSS rotate on THIS wrapper) never fights the outer .sprite node's
728
697
  // own translate(-50%,-50%) positioning transform.
729
698
  const face = document.createElement("div");
730
699
  face.className = "sprite-face";
731
- face.innerHTML = resolveSpriteFace(cls, propertyFacts);
700
+ face.innerHTML = resolveSpriteFace({ class: cls, expression: mood });
732
701
  node.appendChild(face);
733
702
  if (!preview) {
734
703
  node.tabIndex = 0;
@@ -747,7 +716,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
747
716
  function applyAgents(agents) {
748
717
  removeStaleSprites(agents);
749
718
  for (const [id, a] of Object.entries(agents)) {
750
- const cls = classOfAgentId(id);
719
+ const cls = agentKindOf(id);
751
720
  const node = ensureSpriteEl(id, cls, a);
752
721
  const parsed = tmctSpiderFly.parseCellId(a.cell);
753
722
  if (!parsed) continue;
@@ -763,16 +732,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
763
732
  const face = node.querySelector(".sprite-face");
764
733
  if (face) {
765
734
  face.style.transform = "rotate(" + facingByAgent[id] + "deg)";
766
- // Emotion changes tick-to-tick (a fly's fear spikes the instant a
735
+ // Mood changes tick-to-tick (a fly's fear spikes the instant a
767
736
  // spider comes into view, a spider's joy fires the instant it eats),
768
737
  // so this must re-resolve every redraw, not just at sprite creation —
769
- // but only actually replace the DOM when the emotion word itself
738
+ // but only actually replace the DOM when the mood word itself
770
739
  // changed since last render, never on every tick regardless.
771
- const propertyFacts = propertyFactsForAgent(a, cls);
772
- const emotion = propertyFacts[0] ? propertyFacts[0].object : null;
773
- if (emotion !== emotionByAgent[id]) {
774
- emotionByAgent[id] = emotion;
775
- face.innerHTML = resolveSpriteFace(cls, propertyFacts);
740
+ const mood = expressionForAgent(id, a, cls);
741
+ if (mood !== moodByAgent[id]) {
742
+ moodByAgent[id] = mood;
743
+ face.innerHTML = resolveSpriteFace({ class: cls, expression: mood });
776
744
  }
777
745
  }
778
746
  if (a.goal) goalById[id] = a.goal;
@@ -805,7 +773,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
805
773
  // never emotion-parameterized at all (a rotting carcass has none),
806
774
  // just its class's own plain template under the grayscale/fade the
807
775
  // .sprite.corpse CSS rule already applies.
808
- face.innerHTML = resolveSpriteFace(corpse.cls, []);
776
+ face.innerHTML = resolveSpriteFace({ class: corpse.cls });
809
777
  node.appendChild(face);
810
778
  spriteLayer.appendChild(node);
811
779
  corpseEls[id] = node;
@@ -818,13 +786,6 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
818
786
  }
819
787
  }
820
788
 
821
- function massBarHtml(cls, mass) {
822
- const maxMass = cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
823
- if (typeof mass !== "number" || !maxMass) return "";
824
- const pct = Math.max(0, Math.min(100, (mass / maxMass) * 100));
825
- return '<div class="mass-track"><div class="mass-fill ' + esc(cls) + '" style="width:' + pct + '%"></div></div>';
826
- }
827
-
828
789
  // The last-created plan as a plain arrow chain ("west → west"), or
829
790
  // "holding." when the agent's plan this tick is empty — mirrors exactly
830
791
  // what drove this tick's sprite facing (facingDegreesFor reads the same
@@ -850,15 +811,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
850
811
 
851
812
  // The full text rendering behind the click-expand panel: one sentence per
852
813
  // other candidate the observer currently has a belief about, or "has not
853
- // been observed" for a candidate it doesn't — the same belief map
854
- // beliefLineHtml already compresses into a single line, spelled out in
855
- // full beside the clicked agent instead of abbreviated above it.
814
+ // been observed" for a candidate it doesn't — believedFactSentence is the
815
+ // SAME function spider-fly-turn.mjs's own chat lane renders "X sees: ..."
816
+ // from, so the chat phrasing and this panel can never drift apart.
817
+ // beliefLineHtml already compresses the same belief map into a single line
818
+ // above; this spells it out in full beside the clicked agent.
856
819
  function observedFactsHtml(observerId, belief) {
857
820
  const entries = Object.entries(belief || {});
858
821
  const lines = entries.length
859
- ? entries.map(([id, cell]) => '<div class="hud-detail-line">'
860
- + (cell ? esc(id) + " is at " + esc(cell) + "." : esc(id) + " has not been observed.")
861
- + "</div>").join("")
822
+ ? entries.map(([id, cell]) => '<div class="hud-detail-line">' + esc(believedFactSentence(id, cell)) + "</div>").join("")
862
823
  : '<div class="hud-detail-line">nothing else is on the board yet.</div>';
863
824
  return '<div class="hud-detail"><div class="hud-detail-title">' + esc(observerId) + " observes</div>" + lines + "</div>";
864
825
  }
@@ -867,13 +828,13 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
867
828
  const ids = Object.keys(lastAgents).sort();
868
829
  if (!ids.length) { hudEl.innerHTML = '<div class="hud-empty">no agents on the board.</div>'; return; }
869
830
  hudEl.innerHTML = ids.map((id) => {
870
- const cls = classOfAgentId(id);
831
+ const cls = agentKindOf(id);
871
832
  const a = lastAgents[id];
872
833
  const clickable = cls === "spider" || cls === "fly";
873
834
  const expanded = clickable && expandedAgents.has(id);
874
835
  const main = '<div class="hud-main"><span class="hud-id ' + esc(cls) + '">' + esc(id) + '</span>'
875
836
  + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span>"
876
- + massBarHtml(cls, a.mass)
837
+ + meterBarHtml(cls, a.mass, massScaleFor(cls, { maxSpiderMass: SPIDERFLY.maxSpiderMass, maxFlyMass: SPIDERFLY.maxFlyMass }))
877
838
  + planLineHtml(a.plan)
878
839
  + beliefLineHtml(a.belief)
879
840
  + "</div>";
@@ -963,7 +924,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
963
924
  // engine's own shipped default before the session has finished
964
925
  // booting) — a POV toggle always reflects whatever vision range this
965
926
  // class currently actually has, not a fixed constant.
966
- const radius = classOfAgentId(povAgentId) === "spider"
927
+ const radius = agentKindOf(povAgentId) === "spider"
967
928
  ? (liveConfig.spiderVisionRadius ?? tmctSpiderFly.DEFAULT_VISION_RADIUS)
968
929
  : (liveConfig.flyVisionRadius ?? tmctSpiderFly.DEFAULT_VISION_RADIUS);
969
930
  const visible = new Set(tmctSpiderFly.visibleCells(p.x, p.y, radius));