@polycode-projects/the-mechanical-code-talker 2.7.23 → 2.7.25

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 (41) hide show
  1. package/README.md +23 -0
  2. package/data/sprites/adventurer.toml +5 -0
  3. package/data/sprites/animal.toml +6 -0
  4. package/data/sprites/butler.toml +8 -0
  5. package/data/sprites/cabinet.toml +5 -0
  6. package/data/sprites/container.toml +5 -0
  7. package/data/sprites/cook.toml +7 -0
  8. package/data/sprites/desk.toml +4 -0
  9. package/data/sprites/dog-with-colour.toml +20 -0
  10. package/data/sprites/dog.toml +5 -0
  11. package/data/sprites/egg.toml +4 -0
  12. package/data/sprites/fly.toml +5 -0
  13. package/data/sprites/furniture.toml +5 -0
  14. package/data/sprites/gardener.toml +4 -0
  15. package/data/sprites/housekeeper.toml +6 -0
  16. package/data/sprites/key.toml +5 -0
  17. package/data/sprites/lamp.toml +4 -0
  18. package/data/sprites/letter.toml +5 -0
  19. package/data/sprites/person.toml +4 -0
  20. package/data/sprites/poodle.toml +5 -0
  21. package/data/sprites/portable.toml +6 -0
  22. package/data/sprites/portrait.toml +5 -0
  23. package/data/sprites/room.toml +5 -0
  24. package/data/sprites/spider.toml +5 -0
  25. package/package.json +1 -1
  26. package/src/adapters/corpus/sprite-template-files.mjs +52 -0
  27. package/src/adapters/toml-config.mjs +8 -0
  28. package/src/domain/game-config.mjs +90 -0
  29. package/src/domain/spider-fly-world.mjs +5 -2
  30. package/src/domain/sprite-map.mjs +25 -10
  31. package/src/domain/sprite-templates.mjs +131 -0
  32. package/src/services/adventure-viz.mjs +201 -18
  33. package/src/services/adventure.mjs +45 -3
  34. package/src/services/chat-session.mjs +9 -1
  35. package/src/services/chat.mjs +49 -27
  36. package/src/services/spider-fly-turn.mjs +11 -10
  37. package/src/services/spider-fly-viz.mjs +73 -8
  38. package/src/services/spider-fly.mjs +56 -36
  39. package/src/surfaces/web/adventure-browser-entry.mjs +57 -19
  40. package/src/surfaces/web/memory-ask-browser.bundle.js +42 -0
  41. package/src/surfaces/web/spider-fly-browser-entry.mjs +2 -1
@@ -22,6 +22,7 @@ import { foldSpiderFlyState, runSpiderFlyTick, startSpiderFlyGame } from "./spid
22
22
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
23
23
  import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
24
24
  import { appendFacts, appendRule, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
25
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
25
26
 
26
27
  // ---- recognizers: the closed opening/stop/tick/address set -------------------
27
28
 
@@ -70,7 +71,7 @@ const WORLD_OPENING_FALLBACK =
70
71
 
71
72
  // ---- the opening turn: load the shipped board through the worlds pack -------
72
73
 
73
- async function openSpiderFlyGame({ planHolder, memoryDir, env, cache }) {
74
+ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
74
75
  if (!memoryDir) {
75
76
  return {
76
77
  text: "the spider-and-fly game needs a session with a memory store to hold the board — start tmct inside a repo first.",
@@ -99,7 +100,7 @@ async function openSpiderFlyGame({ planHolder, memoryDir, env, cache }) {
99
100
  }
100
101
  if (cache) cache.rows = null; // the fact-rows cache predates these writes
101
102
 
102
- const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1 });
103
+ const { started } = await startSpiderFlyGame(memoryDir, { flyCount: 1, config: gameConfig?.spiderFly });
103
104
  planHolder.state = { spiderFly: { turn: 0 } };
104
105
  const opener = started
105
106
  ? (payload.meta?.opening || WORLD_OPENING_FALLBACK)
@@ -212,8 +213,8 @@ function describeEcologyNote(eco) {
212
213
  return bits.length ? `; ${bits.join(", ")}` : "";
213
214
  }
214
215
 
215
- async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [], addressedNote = null }) {
216
- const tick = await runSpiderFlyTick(memoryDir, { toldFacts });
216
+ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [], addressedNote = null, gameConfig = DEFAULT_GAME_CONFIG }) {
217
+ const tick = await runSpiderFlyTick(memoryDir, { toldFacts, config: gameConfig?.spiderFly });
217
218
  if (cache) cache.rows = null;
218
219
  planHolder.state = { spiderFly: { turn: tick.turn } };
219
220
  return {
@@ -232,7 +233,7 @@ async function runTickAndRender({ planHolder, memoryDir, cache, toldFacts = [],
232
233
  * current turn's told-facts"). This also matches how runSpiderFlyTick
233
234
  * itself already works: it holds no standing plan or belief between calls,
234
235
  * recomputing everything fresh from the folded fact rows every tick. */
235
- async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
236
+ async function runToldFactTurn(match, { planHolder, memoryDir, cache, gameConfig = DEFAULT_GAME_CONFIG }) {
236
237
  const [, addrKindRaw, addrNum, subjKindRaw, subjNum, direction, cellLiteral] = match;
237
238
  const addrKind = addrKindRaw.toLowerCase();
238
239
  const subjKind = subjKindRaw.toLowerCase();
@@ -258,7 +259,7 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
258
259
  const targetCellId = cellId(targetCell.x, targetCell.y);
259
260
  const toldFacts = [{ subject: subjectId, toAgent: addresseeId, cell: targetCellId, turn: state.turnCount + 1 }];
260
261
  return runTickAndRender({
261
- planHolder, memoryDir, cache, toldFacts,
262
+ planHolder, memoryDir, cache, toldFacts, gameConfig,
262
263
  addressedNote: `told the ${addresseeId} the ${subjectId} is at ${targetCellId}`,
263
264
  });
264
265
  }
@@ -275,7 +276,7 @@ async function runToldFactTurn(match, { planHolder, memoryDir, cache }) {
275
276
  * ordinary lanes unchanged, board untouched (§6.2 — no special-cased
276
277
  * spider-fly code path for plain questions).
277
278
  */
278
- export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false }) {
279
+ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache = null, isPlanFrameLine = () => false, gameConfig = DEFAULT_GAME_CONFIG }) {
279
280
  const slot = planHolder?.state ?? null;
280
281
  const spiderFly = slot?.spiderFly ?? null;
281
282
  const opening = SPIDER_FLY_OPEN_RE.test(line);
@@ -305,7 +306,7 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
305
306
  note: "SPIDER-FLY — an opening arrived while a plan frame is active; the slot holds one thing at a time",
306
307
  };
307
308
  }
308
- return openSpiderFlyGame({ planHolder, memoryDir, env, cache });
309
+ return openSpiderFlyGame({ planHolder, memoryDir, env, cache, gameConfig });
309
310
  }
310
311
 
311
312
  // A game is live.
@@ -343,11 +344,11 @@ export async function spiderFlyTurn(line, { planHolder, memoryDir, env, cache =
343
344
  miss: true,
344
345
  };
345
346
  }
346
- return runToldFactTurn(told, { planHolder, memoryDir, cache });
347
+ return runToldFactTurn(told, { planHolder, memoryDir, cache, gameConfig });
347
348
  }
348
349
 
349
350
  if (SPIDER_FLY_TICK_RE.test(line)) {
350
- return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [] });
351
+ return runTickAndRender({ planHolder, memoryDir, cache, toldFacts: [], gameConfig });
351
352
  }
352
353
 
353
354
  return null; // an unaddressed aside — the ordinary lanes answer, board untouched
@@ -96,13 +96,18 @@ export function threadCellsForSpiderPlan(agents, geometry) {
96
96
  }
97
97
 
98
98
  /** The self-contained spider-and-fly page. Pure — the same output for the
99
- * same `title` every time; every other piece of state this page shows is
100
- * computed live in the browser once the sibling bundle loads. `?preview=1`
101
- * on the page's own URL switches it into the small, auto-playing,
102
- * non-interactive mode the home page's hero iframe embeds (§11) — one file
103
- * serves both the hero and the "open full-screen" link, matching how
104
- * ledger.html/plan.html are each one file embedded two ways. */
105
- export function renderSpiderFlyHtml({ title = DEFAULT_TITLE } = {}) {
99
+ * same `title`/`spriteTemplates` every time; every other piece of state
100
+ * this page shows is computed live in the browser once the sibling bundle
101
+ * loads. `spriteTemplates` is the build step's own read of
102
+ * data/sprites/*.toml (sprite-template-files.mjs), embedded as page data
103
+ * the same reason adventure-viz.mjs's own worldPayload is the browser
104
+ * bundle stays fs-free. Defaults to `[]` (every agent falls back to the
105
+ * flat SPRITE_REGISTRY, unchanged from before this module existed).
106
+ * `?preview=1` on the page's own URL switches it into the small, auto-
107
+ * playing, non-interactive mode the home page's hero iframe embeds (§11) —
108
+ * one file serves both the hero and the "open full-screen" link, matching
109
+ * how ledger.html/plan.html are each one file embedded two ways. */
110
+ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE, spriteTemplates = [] } = {}) {
106
111
  const gridData = embedJson({
107
112
  gridSize: GRID_SIZE,
108
113
  webCells: webCellIds(),
@@ -114,6 +119,7 @@ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE } = {}) {
114
119
  tickWaitMs: TICK_WAIT_MS,
115
120
  maxFlyMass: FLY_INITIAL_MASS,
116
121
  maxSpiderMass: SPIDER_INITIAL_MASS,
122
+ spriteTemplates,
117
123
  });
118
124
 
119
125
  return `<!doctype html>
@@ -171,6 +177,11 @@ ${THEME_TOKENS_CSS}
171
177
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
172
178
  .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; }
173
179
  .chatask input:disabled { opacity: .5; }
180
+ .chatpills { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .5rem; }
181
+ .pill { font-family: ${MONO_STACK}; font-size: .68rem; padding: .2rem .6rem; border: 1px solid var(--line); border-radius: 99px; background: var(--bg); color: var(--ink); white-space: nowrap; }
182
+ .pill:hover:not(:disabled) { border-color: var(--taught); }
183
+ .pill:disabled { opacity: .45; cursor: default; }
184
+ .pill[data-role="addr"].active { border-color: var(--taught); color: var(--taught); }
174
185
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
175
186
  .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
176
187
  .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
@@ -206,6 +217,14 @@ ${THEME_TOKENS_CSS}
206
217
  <span class="prompt mono">tmct&gt;</span>
207
218
  <input id="chatq" type="text" placeholder="@spider the fly is east" aria-label="Address the spider or the fly" disabled>
208
219
  </form>
220
+ <div class="chatpills" id="chatpills" role="group" aria-label="quick phrases to fill the chat input">
221
+ <button type="button" class="pill" data-role="addr" data-addressee="spider" disabled>@spider</button>
222
+ <button type="button" class="pill" data-role="addr" data-addressee="fly" disabled>@fly</button>
223
+ <button type="button" class="pill" data-role="dir" data-direction="north" disabled>the fly is north</button>
224
+ <button type="button" class="pill" data-role="dir" data-direction="south" disabled>the fly is south</button>
225
+ <button type="button" class="pill" data-role="dir" data-direction="east" disabled>the fly is east</button>
226
+ <button type="button" class="pill" data-role="dir" data-direction="west" disabled>the fly is west</button>
227
+ </div>
209
228
  </div>
210
229
  </aside>
211
230
  </div>
@@ -238,6 +257,9 @@ const SPIDERFLY = ${gridData};
238
257
  const chatlogEl = el("chatlog");
239
258
  const chatformEl = el("chatform");
240
259
  const chatqEl = el("chatq");
260
+ const chatpillsEl = el("chatpills");
261
+ const addressPillEls = [...chatpillsEl.querySelectorAll('[data-role="addr"]')];
262
+ const directionPillEls = [...chatpillsEl.querySelectorAll('[data-role="dir"]')];
241
263
  const statusEl = el("status");
242
264
  const turnLabelEl = el("turnLabel");
243
265
  const resetBtn = el("resetBtn");
@@ -298,8 +320,14 @@ const SPIDERFLY = ${gridData};
298
320
  node = document.createElement("div");
299
321
  node.className = "sprite";
300
322
  node.dataset.cls = cls;
323
+ // Property-aware resolution (sprite-templates.mjs's resolveSpriteAsset):
324
+ // no agent here carries an mgx:hasProperty fact today, so propertyFacts
325
+ // stays empty and every agent resolves through its plain class template
326
+ // (or the flat SPRITE_REGISTRY, for a class with none) — the same output
327
+ // as before this module existed, just wired for the day an agent does
328
+ // carry one.
301
329
  const sprite = window.tmctSpiderFly
302
- ? tmctSpiderFly.resolveSpriteForClass(cls, (session && session.taxonomyRows) || [], tmctSpiderFly.SPRITE_REGISTRY)
330
+ ? tmctSpiderFly.resolveSpriteAsset(cls, (session && session.taxonomyRows) || [], [], SPIDERFLY.spriteTemplates, tmctSpiderFly.SPRITE_REGISTRY)
303
331
  : "";
304
332
  node.innerHTML = sprite;
305
333
  if (!preview) {
@@ -465,6 +493,42 @@ const SPIDERFLY = ${gridData};
465
493
  });
466
494
  });
467
495
 
496
+ // ---- chat pills: click-to-fill shortcuts over the SAME #chatq input, never
497
+ // a second path into the engine — a pill only ever sets/appends text and
498
+ // focuses the field, exactly what typing the same characters would do, so
499
+ // free typing keeps working unchanged and every resulting phrase is one the
500
+ // addressed teach-frame grammar (SPIDER_FLY_TOLD_RE in spider-fly-turn.mjs)
501
+ // genuinely accepts.
502
+ function addresseeKindOf(value) {
503
+ const m = /^@(spider|fly)(?:-\\d+)?\\b/i.exec(String(value).trim());
504
+ return m ? m[1].toLowerCase() : null;
505
+ }
506
+ function refreshPills() {
507
+ const explicitKind = addresseeKindOf(chatqEl.value);
508
+ const subject = (explicitKind || "spider") === "spider" ? "fly" : "spider";
509
+ for (const btn of directionPillEls) btn.textContent = "the " + subject + " is " + btn.dataset.direction;
510
+ for (const btn of addressPillEls) btn.classList.toggle("active", btn.dataset.addressee === explicitKind);
511
+ }
512
+ for (const btn of addressPillEls) {
513
+ btn.addEventListener("click", () => {
514
+ chatqEl.value = "@" + btn.dataset.addressee + " ";
515
+ refreshPills();
516
+ chatqEl.focus();
517
+ });
518
+ }
519
+ for (const btn of directionPillEls) {
520
+ btn.addEventListener("click", () => {
521
+ const kind = addresseeKindOf(chatqEl.value) || "spider";
522
+ let value = chatqEl.value;
523
+ if (!addresseeKindOf(value)) value = "@" + kind + " " + value.trimStart();
524
+ chatqEl.value = value.replace(/\\s+$/, "") + " " + btn.textContent;
525
+ refreshPills();
526
+ chatqEl.focus();
527
+ });
528
+ }
529
+ chatqEl.addEventListener("input", refreshPills);
530
+ refreshPills();
531
+
468
532
  // ---- serialize every engine-touching call: the ticker and the chat dock
469
533
  // share one in-memory store, and an overlapping tick()/turn() pair could
470
534
  // race against the same @turnN write.
@@ -481,6 +545,7 @@ const SPIDERFLY = ${gridData};
481
545
  statusEl.textContent = session.opening;
482
546
  chatqEl.disabled = false;
483
547
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
548
+ for (const btn of [...addressPillEls, ...directionPillEls]) btn.disabled = false;
484
549
  }
485
550
 
486
551
  let loopScheduled = false;
@@ -19,6 +19,7 @@ import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.m
19
19
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
20
20
  import { mulberry32 } from "../domain/seeded-random.mjs";
21
21
  import { fnv1a32 } from "../domain/hash.mjs";
22
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
22
23
 
23
24
  // ---- tunable constants (starting values, not fixed — the vision radius and
24
25
  // mass economy all want checking against a real playable board) -------------
@@ -187,18 +188,20 @@ export function gridApplyActions(factRows) {
187
188
  export const spiderPathStateKey = (state) => cellId(state.x, state.y);
188
189
 
189
190
  /** Whether (x, y) is currently webbed — the static home zone (always active)
190
- * OR a live spider-built web (mgx:web-built-at-turn + WEB_DURATION_TURNS >
191
+ * OR a live spider-built web (mgx:web-built-at-turn + webDurationTurns >
191
192
  * turn). The one predicate every eat precondition and the fly's movement
192
193
  * gate consult, so the static zone and dynamic webs are ONE concept. `state`
193
194
  * may be omitted (or carry no `webs` map) — the static-zone check alone
194
195
  * still answers correctly, just blind to dynamic webs; every real caller
195
- * threads the folded state through. */
196
- export function hasActiveWebAt(x, y, state, turn) {
196
+ * threads the folded state through. `webDurationTurns` defaults to the
197
+ * shipped WEB_DURATION_TURNS; a caller holding a resolved game config passes
198
+ * its own webDurationTurns instead. */
199
+ export function hasActiveWebAt(x, y, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
197
200
  if (isInWebBlock(x, y)) return true;
198
201
  if (!state?.webs?.size) return false;
199
202
  const target = cellId(x, y);
200
203
  for (const { cell, builtAtTurn } of state.webs.values()) {
201
- if (cell === target && builtAtTurn + WEB_DURATION_TURNS > turn) return true;
204
+ if (cell === target && builtAtTurn + webDurationTurns > turn) return true;
202
205
  }
203
206
  return false;
204
207
  }
@@ -210,11 +213,12 @@ export function hasActiveWebAt(x, y, state, turn) {
210
213
  * only on the fixed target), so this returns null — an honest "no path to
211
214
  * an eat" rather than a path toward a cell that would never satisfy the eat
212
215
  * condition. Null also covers "no believed target at all." `state`/`turn`
213
- * are optional, defaulting to "static web zone only" (see hasActiveWebAt). */
214
- export function planSpiderPath(spiderCell, believedFlyCell, applyActions, state, turn) {
216
+ * are optional, defaulting to "static web zone only" (see hasActiveWebAt).
217
+ * `webDurationTurns` forwards to hasActiveWebAt unchanged. */
218
+ export function planSpiderPath(spiderCell, believedFlyCell, applyActions, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
215
219
  if (!believedFlyCell) return null;
216
220
  const isGoal = (s) =>
217
- s.x === believedFlyCell.x && s.y === believedFlyCell.y && hasActiveWebAt(s.x, s.y, state, turn);
221
+ s.x === believedFlyCell.x && s.y === believedFlyCell.y && hasActiveWebAt(s.x, s.y, state, turn, webDurationTurns);
218
222
  return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
219
223
  }
220
224
 
@@ -362,10 +366,17 @@ function mostRecentEaterSpider(state, eatenDeltaBySpider) {
362
366
  * never starve-checked, so callers that don't track spider mass, e.g. older
363
367
  * tests, see no behavior change). `state` is the PRE-move fold (for history:
364
368
  * prior flies-eaten counts, prior eggs, prior eaten turns, live webs).
369
+ * `config` (default DEFAULT_GAME_CONFIG.spiderFly) supplies every tunable
370
+ * this pass reads: the initial masses a fallback/hatch/spawn mints, the
371
+ * eggs-eaten lay threshold, the hatch delay, the spawn interval, and the web
372
+ * duration the eat precondition checks against.
365
373
  * Returns `{ writes, events }` — writes to append alongside the turn's
366
374
  * movement facts, events for the tick's own return payload. Pure.
367
375
  */
368
- export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider = new Map(), turn }) {
376
+ export function runEcologyPass({
377
+ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider = new Map(), turn,
378
+ config = DEFAULT_GAME_CONFIG.spiderFly,
379
+ }) {
369
380
  const k = turn;
370
381
  const writes = [];
371
382
  const events = { eaten: [], starved: [], laid: null, hatched: [], spawned: null, spawnedCell: null, massAfterEating: new Map() };
@@ -381,7 +392,7 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
381
392
  const eatenMassBySpider = new Map();
382
393
  for (const spiderId of spiders) {
383
394
  const sCell = postMovePlacements.get(spiderId);
384
- if (!hasActiveWebAt(sCell.x, sCell.y, state, k)) continue;
395
+ if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
385
396
  for (const flyId of flies) {
386
397
  if (claimedFlies.has(flyId)) continue;
387
398
  const fCell = postMovePlacements.get(flyId);
@@ -396,7 +407,7 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
396
407
  for (const [spiderId, delta] of eatenDeltaBySpider) {
397
408
  const newCount = (state.fliesEaten.get(spiderId)?.value ?? 0) + delta;
398
409
  writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:flies-eaten", object: String(newCount) });
399
- const priorSpiderMass = postMoveMassBySpider.get(spiderId) ?? (state.mass.get(spiderId)?.value ?? SPIDER_INITIAL_MASS);
410
+ const priorSpiderMass = postMoveMassBySpider.get(spiderId) ?? (state.mass.get(spiderId)?.value ?? config.spiderInitialMass);
400
411
  const newSpiderMass = priorSpiderMass + (eatenMassBySpider.get(spiderId) ?? 0);
401
412
  writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newSpiderMass) });
402
413
  events.massAfterEating.set(spiderId, newSpiderMass);
@@ -427,7 +438,7 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
427
438
  const liveEggId = [...state.laidAtTurn.keys()].find((id) => !state.removed.has(id));
428
439
  if (!liveEggId) {
429
440
  const sinceTurn = mostRecentEggLaidTurn(state);
430
- const threshold = sinceTurn === -1 ? 1 : EGGS_EATEN_THRESHOLD;
441
+ const threshold = sinceTurn === -1 ? 1 : config.eggsEatenThreshold;
431
442
  let eatsSince = events.eaten.length;
432
443
  for (const { turn: eatenTurn } of state.eatenBy.values()) if (eatenTurn > sinceTurn) eatsSince += 1;
433
444
  if (eatsSince >= threshold) {
@@ -442,18 +453,18 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
442
453
  }
443
454
  }
444
455
 
445
- // 4. Hatch — any live egg laid exactly EGG_HATCH_DELAY_TURNS turns ago.
456
+ // 4. Hatch — any live egg laid exactly config.eggHatchDelayTurns turns ago.
446
457
  const liveEggIds = [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort();
447
458
  let nextSpiderNum = 1 + maxIdSuffix(state.placements.keys(), /^spider-(\d+)$/);
448
459
  for (const eggId of liveEggIds) {
449
460
  const laidTurn = state.laidAtTurn.get(eggId).value;
450
- if (laidTurn + EGG_HATCH_DELAY_TURNS !== k) continue;
461
+ if (laidTurn + config.eggHatchDelayTurns !== k) continue;
451
462
  const eggCell = state.placements.get(eggId)?.cell;
452
463
  if (!eggCell) continue;
453
464
  const newSpiderId = `spider-${nextSpiderNum}`;
454
465
  nextSpiderNum += 1;
455
466
  writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
456
- writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(SPIDER_INITIAL_MASS) });
467
+ writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(config.spiderInitialMass) });
457
468
  writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: newSpiderId });
458
469
  events.hatched.push({ egg: eggId, spider: newSpiderId, cell: eggCell });
459
470
  }
@@ -461,7 +472,7 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
461
472
  // 5. Spawn — every third turn, a new fly at a seeded pick among the
462
473
  // currently-uncontested perimeter cells (never Math.random — see
463
474
  // seededPick's own header comment).
464
- if (k % FLY_SPAWN_INTERVAL_TURNS === 0) {
475
+ if (k % config.flySpawnIntervalTurns === 0) {
465
476
  const occupied = new Set();
466
477
  for (const spiderId of spiders) { const c = postMovePlacements.get(spiderId); occupied.add(cellId(c.x, c.y)); }
467
478
  for (const flyId of flies) {
@@ -479,7 +490,7 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
479
490
  const newFlyId = `fly-${1 + maxIdSuffix(state.placements.keys(), /^fly-(\d+)$/)}`;
480
491
  const cell = seededPick(uncontested, `${WORLD_NAME}:${k}:${newFlyId}:spawn`);
481
492
  writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:currently-in", object: cell });
482
- writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
493
+ writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:mass", object: String(config.flyInitialMass) });
483
494
  events.spawned = newFlyId;
484
495
  events.spawnedCell = cell;
485
496
  }
@@ -494,15 +505,16 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, p
494
505
  * board perimeter — a fresh session's own starting state, never part of
495
506
  * the shipped (reusable, static) world pack itself. A no-op when spider-1
496
507
  * already exists (idempotent — safe to call from a caller unsure whether
497
- * the game has already started). */
498
- export async function startSpiderFlyGame(memoryDir, { flyCount = 1 } = {}) {
508
+ * the game has already started). `config` (default
509
+ * DEFAULT_GAME_CONFIG.spiderFly) supplies the starting masses. */
510
+ export async function startSpiderFlyGame(memoryDir, { flyCount = 1, config = DEFAULT_GAME_CONFIG.spiderFly } = {}) {
499
511
  const state = foldSpiderFlyState(readFactRows(await loadMemory(memoryDir)));
500
512
  if (state.placements.has("spider-1")) return { started: false, facts: [] };
501
513
 
502
514
  const perimeter = perimeterCells();
503
515
  const facts = [
504
516
  { subject: "spider-1", predicate: "mgx:currently-in", object: cellId(WEB_HOME.x, WEB_HOME.y) },
505
- { subject: "spider-1", predicate: "mgx:mass", object: String(SPIDER_INITIAL_MASS) },
517
+ { subject: "spider-1", predicate: "mgx:mass", object: String(config.spiderInitialMass) },
506
518
  ];
507
519
  const occupied = new Set([cellId(WEB_HOME.x, WEB_HOME.y)]);
508
520
  for (let i = 0; i < flyCount; i += 1) {
@@ -511,7 +523,7 @@ export async function startSpiderFlyGame(memoryDir, { flyCount = 1 } = {}) {
511
523
  const cell = seededPick(uncontested.length ? uncontested : perimeter, `${WORLD_NAME}:0:${flyId}:spawn`);
512
524
  occupied.add(cell);
513
525
  facts.push({ subject: flyId, predicate: "mgx:currently-in", object: cell });
514
- facts.push({ subject: flyId, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
526
+ facts.push({ subject: flyId, predicate: "mgx:mass", object: String(config.flyInitialMass) });
515
527
  }
516
528
  await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(WORLD_NAME) })));
517
529
  return { started: true, facts };
@@ -534,11 +546,13 @@ function goalLineFor(subject, believed, arrived, kind) {
534
546
  * web(s) minted THIS tick before they've been written/read back), as a
535
547
  * plain array of { id, cell, builtAtTurn, expiresAtTurn }. Excludes the
536
548
  * always-on static home zone (that's WEB_HOME/WEB_RADIUS, drawn separately —
537
- * this is only the spider-built kind), for a renderer to draw distinctly. */
538
- export function liveWebs(websMap, turn) {
549
+ * this is only the spider-built kind), for a renderer to draw distinctly.
550
+ * `webDurationTurns` defaults to the shipped WEB_DURATION_TURNS; a caller
551
+ * holding a resolved game config passes its own webDurationTurns instead. */
552
+ export function liveWebs(websMap, turn, webDurationTurns = WEB_DURATION_TURNS) {
539
553
  const out = [];
540
554
  for (const [id, { cell, builtAtTurn }] of websMap) {
541
- if (builtAtTurn + WEB_DURATION_TURNS > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + WEB_DURATION_TURNS });
555
+ if (builtAtTurn + webDurationTurns > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + webDurationTurns });
542
556
  }
543
557
  return out;
544
558
  }
@@ -560,9 +574,15 @@ export function liveWebs(websMap, turn) {
560
574
  * eaten/starved/laid/hatched/spawned event summary; `activeWebs` is every
561
575
  * currently-live dynamic web (static home zone excluded — that's fixed grid
562
576
  * geometry, not runtime state), for a renderer to draw distinctly.
577
+ *
578
+ * `opts.config` (default DEFAULT_GAME_CONFIG.spiderFly) supplies every
579
+ * tunable this tick reads: the vision radius, both agents' starting/decrement
580
+ * masses, and the web duration, and is forwarded unchanged into
581
+ * runEcologyPass.
563
582
  */
564
583
  export async function runSpiderFlyTick(memoryDir, opts = {}) {
565
- const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
584
+ const { toldFacts = [], config = DEFAULT_GAME_CONFIG.spiderFly } = opts;
585
+ const visionRadius = config.visionRadius;
566
586
  const rows = readFactRows(await loadMemory(memoryDir));
567
587
  const state = foldSpiderFlyState(rows);
568
588
  const k = state.turnCount + 1;
@@ -581,8 +601,8 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
581
601
 
582
602
  for (const spiderId of spiders) {
583
603
  const spiderCell = parseCellId(state.placements.get(spiderId).cell);
584
- const priorMass = state.mass.get(spiderId)?.value ?? SPIDER_INITIAL_MASS;
585
- const newMass = Math.max(0, priorMass - SPIDER_MASS_DECREMENT_PER_TURN);
604
+ const priorMass = state.mass.get(spiderId)?.value ?? config.spiderInitialMass;
605
+ const newMass = Math.max(0, priorMass - config.spiderMassDecrementPerTurn);
586
606
  postMoveMassBySpider.set(spiderId, newMass);
587
607
 
588
608
  // Priority 1: avoid any OTHER live spider believed visible.
@@ -599,7 +619,7 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
599
619
  const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius, toldFacts });
600
620
  if (target) {
601
621
  nextCell = spiderCell;
602
- const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k);
622
+ const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k, config.webDurationTurns);
603
623
  if (path) {
604
624
  if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
605
625
  } else {
@@ -611,14 +631,14 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
611
631
  // already at its closest reachable cell but still a step away
612
632
  // (Chebyshev-adjacent isn't co-located; has-exit-* edges have no
613
633
  // diagonal hop).
614
- const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y && hasActiveWebAt(nextCell.x, nextCell.y, state, k);
634
+ const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y && hasActiveWebAt(nextCell.x, nextCell.y, state, k, config.webDurationTurns);
615
635
  goal = goalLineFor(spiderId, target, arrived, "spider");
616
636
  } else {
617
637
  // Priority 3: hold position, and build/refresh a web there unless an
618
638
  // unexpired web already covers this exact cell.
619
639
  nextCell = spiderCell;
620
640
  const heldCellId = cellId(spiderCell.x, spiderCell.y);
621
- if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k)) {
641
+ if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns)) {
622
642
  const webId = `web-${nextWebNum}`;
623
643
  nextWebNum += 1;
624
644
  tickWebs.set(webId, { cell: heldCellId, builtAtTurn: k });
@@ -640,19 +660,19 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
640
660
  for (const flyId of flies) {
641
661
  const flyCell = parseCellId(state.placements.get(flyId).cell);
642
662
  const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius, toldFacts });
643
- const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k);
663
+ const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k, config.webDurationTurns);
644
664
  const nextCell = webbed ? flyCell : greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions, k, flyId);
645
665
  postMovePlacements.set(flyId, nextCell);
646
666
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
647
- const priorMass = state.mass.get(flyId)?.value ?? FLY_INITIAL_MASS;
648
- const newMass = Math.max(0, priorMass - FLY_MASS_DECREMENT_PER_TURN);
667
+ const priorMass = state.mass.get(flyId)?.value ?? config.flyInitialMass;
668
+ const newMass = Math.max(0, priorMass - config.flyMassDecrementPerTurn);
649
669
  postMoveMassByFly.set(flyId, newMass);
650
670
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
651
671
  const goal = webbed ? "trapped in an active web — can't move." : goalLineFor(flyId, believedSpider, true, "fly");
652
672
  agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal, mass: newMass };
653
673
  }
654
674
 
655
- const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider, turn: k });
675
+ const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider, turn: k, config });
656
676
  // Every agent's goal was assigned during movement, before this same tick's
657
677
  // ecology pass resolves eating/starving — so a THIRD agent's goal can name
658
678
  // a subject that dies in this exact tick just as easily as the dying
@@ -705,14 +725,14 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
705
725
  // though the very same tick's event text already announces it, only
706
726
  // catching up the following tick once the fold picks it up naturally.
707
727
  for (const h of ecology.events.hatched) {
708
- agents[h.spider] = { cell: h.cell, goal: "just hatched — no goal yet.", plan: null, mass: SPIDER_INITIAL_MASS };
728
+ agents[h.spider] = { cell: h.cell, goal: "just hatched — no goal yet.", plan: null, mass: config.spiderInitialMass };
709
729
  }
710
730
  if (ecology.events.spawned && ecology.events.spawnedCell) {
711
- agents[ecology.events.spawned] = { cell: ecology.events.spawnedCell, goal: "just arrived — no goal yet.", mass: FLY_INITIAL_MASS };
731
+ agents[ecology.events.spawned] = { cell: ecology.events.spawnedCell, goal: "just arrived — no goal yet.", mass: config.flyInitialMass };
712
732
  }
713
733
  const writes = [...movementWrites, ...ecology.writes];
714
734
  const provenance = `${worldProvenanceTag(WORLD_NAME)}:turn${k}`;
715
735
  await appendFacts(memoryDir, writes.map((f) => ({ ...f, provenance })));
716
736
 
717
- return { turn: k, writes, agents, ecology: ecology.events, activeWebs: liveWebs(tickWebs, k) };
737
+ return { turn: k, writes, agents, ecology: ecology.events, activeWebs: liveWebs(tickWebs, k, config.webDurationTurns) };
718
738
  }
@@ -13,30 +13,40 @@
13
13
  // rationale) — the bootstrap below then just appends it, exactly the shape
14
14
  // openAdventure() itself writes for a real chat session.
15
15
  //
16
- // This session exposes ONLY a raw autoplay tick and a read-only snapshot
17
- // no chat dock. Every state-changing command adventure.mjs's own
18
- // runWorldCommand issues (go/take/open/...) already re-narrates itself
19
- // through the extractive completions digest on every turn (the
20
- // "auto-relook"), so this bundle carries the same wink-nlp/completions
21
- // dependency chain chat.mjs's own runTurn does; a second, lighter path was
22
- // not available to duck under it. Nothing here calls runTurn, though the
23
- // one entry point exercised is adventureTurn itself, via
24
- // adventure-autoplay.mjs, which is the "auto-play is a caller of the
25
- // existing interpreter, never a second one" contract this whole feature
26
- // rests on.
16
+ // This session exposes a raw autoplay tick, a read-only snapshot, AND
17
+ // (mirroring createSpiderFlySession's own `turn(line)`) a full chat-dock
18
+ // entry point: `turn(line)` runs the exact same runTurn the CLI and every
19
+ // other viz page's chat dock run, over this session's own memoryDir/graph/
20
+ // lexicon, threading `focus`/`last`/`planState` across calls the same way a
21
+ // real chat session does. `planState` and `autoplayTick`'s own `planHolder`
22
+ // share ONE mutable holder here, so a manual chat command and an auto-play
23
+ // tick can never disagree about whether the adventure is still open, mid a
24
+ // number game, etc. whichever ran last leaves the holder as the other's
25
+ // starting point. `planHolder.state` starts as adventureTurn's own opened-
26
+ // world shape, so BOTH entry points treat every call as a live, already-open
27
+ // world rather than a fresh opening line: ordinary in-game commands (look/
28
+ // go/take/open/talk/examine/...) dispatch through adventure.mjs's own
29
+ // adventureTurn exactly as autoplayTick's calls already do, and anything not
30
+ // game-shaped falls through to the ordinary conversational layer, exactly
31
+ // like a real CLI session.
32
+ import { runTurn } from "../../services/chat.mjs";
27
33
  import {
28
34
  createInMemoryStore, appendFacts, appendRule, loadMemory, readFactRows,
29
35
  } from "../../adapters/memory/core.mjs";
30
- import { foldWorldState, worldDigestRows } from "../../services/adventure.mjs";
36
+ import { parseEntities } from "../../domain/codegraph.mjs";
37
+ import { loadLexicon } from "../../domain/grammar/lexicon.mjs";
38
+ import { foldWorldState, worldDigestRows, roomAffordances } from "../../services/adventure.mjs";
31
39
  import { runAdventureAutoplayTick } from "../../services/adventure-autoplay.mjs";
32
40
  import { resolveSpriteForClass, SPRITE_REGISTRY } from "../../domain/sprite-map.mjs";
41
+ import { resolveSpriteAsset } from "../../domain/sprite-templates.mjs";
33
42
 
34
- /** A live in-memory adventure this page's ticker drives one auto-play tick
35
- * at a time. Returns `{ memoryDir, autoplayTick, snapshot }`.
43
+ /** A live in-memory adventure this page's ticker AND chat dock can both
44
+ * drive. Returns `{ memoryDir, autoplayTick, turn, snapshot }`.
36
45
  * `worldPayload.facts`/`.rules` seed the store exactly the way
37
46
  * openAdventure() itself does for a real session; `planHolder.state` is set
38
- * the same way, so adventureTurn treats every subsequent call as a live,
39
- * already-open world rather than a fresh opening line. */
47
+ * the same way, so adventureTurn treats every subsequent call auto-play's
48
+ * own or a visitor's typed one — as a live, already-open world rather than
49
+ * a fresh opening line. */
40
50
  export async function createAdventureSession(worldPayload) {
41
51
  const memoryDir = createInMemoryStore();
42
52
  const tag = `world:${worldPayload.name}`;
@@ -54,6 +64,11 @@ export async function createAdventureSession(worldPayload) {
54
64
  const openingHere = foldWorldState(openingRows).placements.get("player")?.object ?? null;
55
65
  if (openingHere) exposedRoomIds = new Set([openingHere]);
56
66
 
67
+ const graph = parseEntities({ individuals: [], objectProperties: [] });
68
+ const lexicon = loadLexicon();
69
+ let focus = null;
70
+ let last = null;
71
+
57
72
  return {
58
73
  memoryDir,
59
74
 
@@ -69,8 +84,30 @@ export async function createAdventureSession(worldPayload) {
69
84
  return result;
70
85
  },
71
86
 
87
+ /** One dispatched chat turn — the SAME runTurn the CLI and every other
88
+ * viz page's own chat dock run, over this session's own memoryDir. A
89
+ * throwing runTurn must never kill the session — the page has no other
90
+ * chance to show this turn's answer. */
91
+ async turn(line) {
92
+ let result;
93
+ try {
94
+ result = await runTurn(line, {
95
+ config: null, source: null, graph, focus, last, memoryDir, sessionId,
96
+ env: {}, lexicon, vocabHint: "", planState: planHolder.state,
97
+ });
98
+ } catch (e) {
99
+ const message = e instanceof Error ? e.message : String(e);
100
+ return { answer: `Something went wrong answering that (${message}). Try rephrasing, or /help.`, end: false, record: null, plan: null };
101
+ }
102
+ focus = result.focus;
103
+ last = result.last;
104
+ if ("planState" in result) planHolder.state = result.planState;
105
+ return { answer: result.answer, end: Boolean(result.end), record: result.record ?? null, plan: result.plan ?? null };
106
+ },
107
+
72
108
  /** A read-only fold of the current room — no engine advance — for the
73
- * page's own redraw after boot and after every tick. */
109
+ * page's own redraw after boot, after every tick, and after every
110
+ * manual chat turn. */
74
111
  async snapshot() {
75
112
  const rows = readFactRows(await loadMemory(memoryDir));
76
113
  const state = foldWorldState(rows);
@@ -81,8 +118,9 @@ export async function createAdventureSession(worldPayload) {
81
118
  }
82
119
 
83
120
  // Re-exported so the page's own rendering script (adventure-viz.mjs) never
84
- // has to duplicate sprite resolution or the digest reader the same posture
121
+ // has to duplicate sprite resolution, the digest reader, or the room
122
+ // affordances the chat dock's own pills read from — the same posture
85
123
  // spider-fly-browser-entry.mjs's own globalThis.tmctSpiderFly re-export takes.
86
124
  globalThis.tmctAdventure = {
87
- createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, worldDigestRows,
125
+ createAdventureSession, resolveSpriteForClass, SPRITE_REGISTRY, resolveSpriteAsset, worldDigestRows, roomAffordances,
88
126
  };