@polycode-projects/the-mechanical-code-talker 2.7.12 → 2.7.13

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.
@@ -33,6 +33,7 @@
33
33
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
34
34
  import { createTicker } from "./viz-ticker.mjs";
35
35
  import { GRID_SIZE, WEB_HOME, WEB_RADIUS, isInWebBlock, cellId } from "../domain/spider-fly-world.mjs";
36
+ import { FLY_INITIAL_MASS, SPIDER_INITIAL_MASS } from "./spider-fly.mjs";
36
37
 
37
38
  const CELL_PX = 44;
38
39
  const BOARD_PX = CELL_PX * GRID_SIZE;
@@ -111,6 +112,8 @@ export function renderSpiderFlyHtml({ title = DEFAULT_TITLE } = {}) {
111
112
  cellPx: CELL_PX,
112
113
  previewMaxTurns: PREVIEW_MAX_TURNS,
113
114
  tickWaitMs: TICK_WAIT_MS,
115
+ maxFlyMass: FLY_INITIAL_MASS,
116
+ maxSpiderMass: SPIDER_INITIAL_MASS,
114
117
  });
115
118
 
116
119
  return `<!doctype html>
@@ -154,6 +157,9 @@ ${THEME_TOKENS_CSS}
154
157
  .hud-id { font-family: ${MONO_STACK}; font-size: .74rem; }
155
158
  .hud-id.spider { color: var(--taught); } .hud-id.fly { color: var(--fly); } .hud-id.egg { color: var(--muted); }
156
159
  .hud-goal { font-size: .85rem; }
160
+ .mass-track { height: 4px; margin-top: .3rem; background: var(--line); border-radius: 2px; overflow: hidden; }
161
+ .mass-fill { height: 100%; background: var(--taught); }
162
+ .mass-fill.fly { background: var(--fly); }
157
163
  .hud-empty { color: var(--muted); font-size: .85rem; }
158
164
  .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 220px; overflow-y: auto; margin-bottom: .5rem; }
159
165
  .chatlog:empty { display: none; margin-bottom: 0; }
@@ -264,6 +270,7 @@ const SPIDERFLY = ${gridData};
264
270
  // ---- state shared across redraws --------------------------------------
265
271
  let session = null;
266
272
  let lastAgents = {};
273
+ let lastActiveWebs = [];
267
274
  let lastTurn = 0;
268
275
  const goalById = {};
269
276
  const spriteEls = {};
@@ -324,13 +331,21 @@ const SPIDERFLY = ${gridData};
324
331
  lastAgents = agents;
325
332
  }
326
333
 
334
+ function massBarHtml(cls, mass) {
335
+ const maxMass = cls === "spider" ? SPIDERFLY.maxSpiderMass : cls === "fly" ? SPIDERFLY.maxFlyMass : null;
336
+ if (typeof mass !== "number" || !maxMass) return "";
337
+ const pct = Math.max(0, Math.min(100, (mass / maxMass) * 100));
338
+ return '<div class="mass-track"><div class="mass-fill ' + esc(cls) + '" style="width:' + pct + '%"></div></div>';
339
+ }
340
+
327
341
  function renderHud() {
328
342
  const ids = Object.keys(lastAgents).sort();
329
343
  if (!ids.length) { hudEl.innerHTML = '<div class="hud-empty">no agents on the board.</div>'; return; }
330
344
  hudEl.innerHTML = ids.map((id) => {
331
345
  const cls = classOfAgentId(id);
332
346
  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>";
347
+ + '<span class="hud-goal">' + esc(goalById[id] || "watching\\u2026") + "</span>"
348
+ + massBarHtml(cls, lastAgents[id].mass) + "</div>";
334
349
  }).join("");
335
350
  }
336
351
 
@@ -340,7 +355,7 @@ const SPIDERFLY = ${gridData};
340
355
  directionDelta: tmctSpiderFly.DIRECTION_DELTA,
341
356
  };
342
357
 
343
- function drawBoard(agents) {
358
+ function drawBoard(agents, activeWebs) {
344
359
  const w = SPIDERFLY.boardPx, h = SPIDERFLY.boardPx;
345
360
  boardCtx.clearRect(0, 0, w, h);
346
361
  boardCtx.fillStyle = cssVar("--taught-soft") || "rgba(46,125,79,.12)";
@@ -348,6 +363,20 @@ const SPIDERFLY = ${gridData};
348
363
  const p = tmctSpiderFly.parseCellId(wc);
349
364
  boardCtx.fillRect((p.x - 1) * cellSize, (p.y - 1) * cellSize, cellSize, cellSize);
350
365
  }
366
+ // A spider-built dynamic web is a distinct color from the always-on
367
+ // static home zone above, plus a dashed outline — same concept
368
+ // (hasActiveWebAt), visually two different things on the board.
369
+ boardCtx.fillStyle = cssVar("--alert-soft") || "rgba(176,80,63,.12)";
370
+ boardCtx.strokeStyle = cssVar("--alert") || "#B0503F";
371
+ boardCtx.lineWidth = 1;
372
+ boardCtx.setLineDash([3, 2]);
373
+ for (const web of activeWebs || []) {
374
+ const p = tmctSpiderFly.parseCellId(web.cell);
375
+ if (!p) continue;
376
+ boardCtx.fillRect((p.x - 1) * cellSize, (p.y - 1) * cellSize, cellSize, cellSize);
377
+ boardCtx.strokeRect((p.x - 1) * cellSize + 0.5, (p.y - 1) * cellSize + 0.5, cellSize - 1, cellSize - 1);
378
+ }
379
+ boardCtx.setLineDash([]);
351
380
  boardCtx.strokeStyle = cssVar("--line") || "#DDD9D0";
352
381
  boardCtx.lineWidth = 1;
353
382
  for (let i = 0; i <= SPIDERFLY.gridSize; i += 1) {
@@ -406,12 +435,13 @@ const SPIDERFLY = ${gridData};
406
435
  });
407
436
  boardFrame.addEventListener("mouseleave", () => { threadTip.style.display = "none"; });
408
437
 
409
- function redraw(agents, turn) {
438
+ function redraw(agents, turn, activeWebs) {
410
439
  applyAgents(agents);
411
440
  renderHud();
412
441
  lastTurn = turn;
442
+ lastActiveWebs = activeWebs || [];
413
443
  turnLabelEl.textContent = "turn: " + turn;
414
- drawBoard(agents);
444
+ drawBoard(agents, lastActiveWebs);
415
445
  drawPov();
416
446
  }
417
447
 
@@ -431,7 +461,7 @@ const SPIDERFLY = ${gridData};
431
461
  const result = await session.turn(q);
432
462
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
433
463
  const snap = await session.snapshot();
434
- redraw(snap.agents, snap.turn);
464
+ redraw(snap.agents, snap.turn, snap.activeWebs);
435
465
  });
436
466
  });
437
467
 
@@ -447,7 +477,7 @@ const SPIDERFLY = ${gridData};
447
477
 
448
478
  async function boot() {
449
479
  session = await tmctSpiderFly.createSpiderFlySession();
450
- redraw(session.initial.agents, session.initial.turn);
480
+ redraw(session.initial.agents, session.initial.turn, session.initial.activeWebs);
451
481
  statusEl.textContent = session.opening;
452
482
  chatqEl.disabled = false;
453
483
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
@@ -457,7 +487,7 @@ const SPIDERFLY = ${gridData};
457
487
  const ticker = createTicker({
458
488
  onTick: () => withLock(async () => {
459
489
  const result = await session.tick();
460
- redraw(result.agents, result.turn);
490
+ redraw(result.agents, result.turn, result.activeWebs);
461
491
  }),
462
492
  onRender: (state) => {
463
493
  playBtn.textContent = state.playing ? "\\u23f8 pause" : "\\u25b6 play";
@@ -479,10 +509,10 @@ const SPIDERFLY = ${gridData};
479
509
 
480
510
  boot().then(() => { if (preview) ticker.play(); });
481
511
 
482
- new MutationObserver(() => { drawBoard(lastAgents); drawPov(); })
512
+ new MutationObserver(() => { drawBoard(lastAgents, lastActiveWebs); drawPov(); })
483
513
  .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
484
514
  if (window.matchMedia) {
485
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { drawBoard(lastAgents); drawPov(); });
515
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { drawBoard(lastAgents, lastActiveWebs); drawPov(); });
486
516
  }
487
517
  })();
488
518
  </script>
@@ -10,13 +10,15 @@
10
10
  // pack and this engine read from.
11
11
 
12
12
  import {
13
- WORLD_NAME, WEB_HOME,
13
+ WORLD_NAME, WEB_HOME, WEB_DURATION_TURNS, SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN,
14
14
  cellId, parseCellId, chebyshevDistance, visibleCells, isInWebBlock, perimeterCells,
15
15
  DIRECTION_DELTA,
16
16
  } from "../domain/spider-fly-world.mjs";
17
17
  import { findActionPath, findReachableSet } from "../domain/planning.mjs";
18
18
  import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
19
19
  import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
20
+ import { mulberry32 } from "../domain/seeded-random.mjs";
21
+ import { fnv1a32 } from "../domain/hash.mjs";
20
22
 
21
23
  // ---- tunable constants (starting values, not fixed — the vision radius and
22
24
  // mass economy all want checking against a real playable board) -------------
@@ -27,6 +29,22 @@ export const FLY_MASS_DECREMENT_PER_TURN = 1;
27
29
  export const EGG_HATCH_DELAY_TURNS = 3;
28
30
  export const FLY_SPAWN_INTERVAL_TURNS = 3;
29
31
  export const EGGS_EATEN_THRESHOLD = 2;
32
+ export { SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN, WEB_DURATION_TURNS };
33
+
34
+ // ---- seeded "randomness" (never Math.random) ---------------------------------
35
+ // Every "random" decision (fly wander, fly/spawn placement) is a mulberry32
36
+ // draw seeded by an fnv1a32 hash of a context string built from data already
37
+ // in the facts (world name, turn number, the subject's own id, a purpose
38
+ // tag) — the same hash-seeds-a-PRNG idiom answer-variants.mjs's phrase
39
+ // selection already uses. Two runs from the same starting facts produce the
40
+ // byte-identical sequence of "random" choices, never wall-clock driven.
41
+
42
+ /** Deterministically pick one of `options` (must be non-empty), keyed on
43
+ * `contextString`. */
44
+ function seededPick(options, contextString) {
45
+ const rng = mulberry32(fnv1a32(contextString));
46
+ return options[Math.floor(rng() * options.length)];
47
+ }
30
48
 
31
49
  // ---- the state fold ----------------------------------------------------------
32
50
 
@@ -37,19 +55,24 @@ function splitSnapshot(subject) {
37
55
  return m ? { base: m[1], turn: Number(m[2]) } : { base: subject, turn: 0 };
38
56
  }
39
57
 
58
+ const WEB_ID_RE = /^web-\d+$/;
59
+
40
60
  /** Fold fact rows into the current spider-fly world state: per-subject
41
61
  * newest placement (mgx:currently-in), newest fly mass, spider's newest
42
- * flies-eaten count, each egg's laid-at-turn, and the terminal eaten-by/
43
- * starved/hatched-into markers that make a subject no longer live. The turn
44
- * counter is derived, never stored — the largest @turnN suffix seen,
45
- * exactly foldWorldState's own convention. Pure. */
62
+ * flies-eaten count, each egg's laid-at-turn, each dynamic web's cell +
63
+ * built-at turn, and the terminal eaten-by/starved/hatched-into markers that
64
+ * make a subject no longer live. The turn counter is derived, never stored —
65
+ * the largest @turnN suffix seen, exactly foldWorldState's own convention.
66
+ * Pure. */
46
67
  export function foldSpiderFlyState(factRows) {
47
68
  const placements = new Map(); // subject -> { cell, turn }
48
- const mass = new Map(); // fly subject -> { value, turn }
69
+ const mass = new Map(); // fly/spider subject -> { value, turn }
49
70
  const fliesEaten = new Map(); // spider subject -> { value, turn }
50
71
  const laidAtTurn = new Map(); // egg subject -> { value, turn }
72
+ const webCell = new Map(); // web subject -> { cell, turn }
73
+ const webBuiltAt = new Map(); // web subject -> { value, turn }
51
74
  const eatenBy = new Map(); // fly subject -> { spider, turn }
52
- const starved = new Set(); // fly subject
75
+ const starved = new Set(); // fly/spider subject
53
76
  const hatchedInto = new Map(); // egg subject -> { spider, turn }
54
77
  let turnCount = 0;
55
78
 
@@ -60,6 +83,10 @@ export function foldSpiderFlyState(factRows) {
60
83
  if (row.predicate === "mgx:currently-in") {
61
84
  const prior = placements.get(base);
62
85
  if (!prior || turn >= prior.turn) placements.set(base, { cell: row.object, turn });
86
+ if (WEB_ID_RE.test(base)) {
87
+ const priorWeb = webCell.get(base);
88
+ if (!priorWeb || turn >= priorWeb.turn) webCell.set(base, { cell: row.object, turn });
89
+ }
63
90
  continue;
64
91
  }
65
92
  if (row.predicate === "mgx:mass") {
@@ -77,6 +104,11 @@ export function foldSpiderFlyState(factRows) {
77
104
  if (!prior || turn >= prior.turn) laidAtTurn.set(base, { value: Number(row.object), turn });
78
105
  continue;
79
106
  }
107
+ if (row.predicate === "mgx:web-built-at-turn") {
108
+ const prior = webBuiltAt.get(base);
109
+ if (!prior || turn >= prior.turn) webBuiltAt.set(base, { value: Number(row.object), turn });
110
+ continue;
111
+ }
80
112
  if (row.predicate === "mgx:eaten-by") {
81
113
  const prior = eatenBy.get(base);
82
114
  if (!prior || turn >= prior.turn) eatenBy.set(base, { spider: row.object, turn });
@@ -90,8 +122,14 @@ export function foldSpiderFlyState(factRows) {
90
122
  }
91
123
  }
92
124
 
125
+ const webs = new Map(); // web subject -> { cell, builtAtTurn }
126
+ for (const [id, { cell }] of webCell) {
127
+ const builtAtTurn = webBuiltAt.get(id)?.value;
128
+ if (builtAtTurn !== undefined) webs.set(id, { cell, builtAtTurn });
129
+ }
130
+
93
131
  const removed = new Set([...eatenBy.keys(), ...starved, ...hatchedInto.keys()]);
94
- return { placements, mass, fliesEaten, laidAtTurn, eatenBy, starved, hatchedInto, removed, turnCount };
132
+ return { placements, mass, fliesEaten, laidAtTurn, webs, eatenBy, starved, hatchedInto, removed, turnCount };
95
133
  }
96
134
 
97
135
  const sortedLiveSubjects = (state, re) =>
@@ -148,17 +186,35 @@ export function gridApplyActions(factRows) {
148
186
  * elsewhere on the folded state, never on the path-search state itself). */
149
187
  export const spiderPathStateKey = (state) => cellId(state.x, state.y);
150
188
 
189
+ /** 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
+ * turn). The one predicate every eat precondition and the fly's movement
192
+ * gate consult, so the static zone and dynamic webs are ONE concept. `state`
193
+ * may be omitted (or carry no `webs` map) — the static-zone check alone
194
+ * 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) {
197
+ if (isInWebBlock(x, y)) return true;
198
+ if (!state?.webs?.size) return false;
199
+ const target = cellId(x, y);
200
+ for (const { cell, builtAtTurn } of state.webs.values()) {
201
+ if (cell === target && builtAtTurn + WEB_DURATION_TURNS > turn) return true;
202
+ }
203
+ return false;
204
+ }
205
+
151
206
  /** The spider's multi-step path: findActionPath wired with isGoal =
152
- * "co-located with the believed fly cell, and that cell is inside the web
153
- * block." When the fly's believed cell sits outside the web, isGoal can
154
- * never fire (it doesn't depend on the search state, only on the fixed
155
- * target), so this returns null — an honest "no path to an eat" rather
156
- * than a path toward a cell that would never satisfy the eat condition.
157
- * Null also covers "no believed target at all." */
158
- export function planSpiderPath(spiderCell, believedFlyCell, applyActions) {
207
+ * "co-located with the believed fly cell, and that cell has an active web
208
+ * (static or dynamic)." When the fly's believed cell sits outside every
209
+ * active web, isGoal can never fire (it doesn't depend on the search state,
210
+ * only on the fixed target), so this returns null — an honest "no path to
211
+ * an eat" rather than a path toward a cell that would never satisfy the eat
212
+ * 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) {
159
215
  if (!believedFlyCell) return null;
160
- const isGoal = (state) =>
161
- state.x === believedFlyCell.x && state.y === believedFlyCell.y && isInWebBlock(state.x, state.y);
216
+ const isGoal = (s) =>
217
+ s.x === believedFlyCell.x && s.y === believedFlyCell.y && hasActiveWebAt(s.x, s.y, state, turn);
162
218
  return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
163
219
  }
164
220
 
@@ -178,12 +234,24 @@ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
178
234
  return best;
179
235
  }
180
236
 
237
+ /** A fly with no believed spider position wanders instead of holding still:
238
+ * a seeded, uniform pick among staying put or any one-ply reachable cell,
239
+ * keyed on this turn + the fly's own id (purpose "wander") — deterministic
240
+ * and replayable, never Math.random. Looks random to a human watching. The
241
+ * caller is responsible for skipping this entirely when the fly sits in an
242
+ * active web this tick (a webbed fly can't move at all, wander or not). */
243
+ export function randomFlyWander(flyCell, applyActions, turn, flyId) {
244
+ const options = [flyCell, ...findReachableSet(flyCell, applyActions, { maxDepth: 1 }).map((r) => r.node)];
245
+ return seededPick(options, `${WORLD_NAME}:${turn}:${flyId}:wander`);
246
+ }
247
+
181
248
  /** The fly's one move this turn: score every one-ply reachable cell (plus
182
249
  * staying put) by Chebyshev distance from the fly's believed spider
183
250
  * position, move to the highest-scoring cell. A fly with no believed
184
- * spider position holds still. */
185
- export function greedyFlyMove(flyCell, believedSpiderCell, applyActions) {
186
- if (!believedSpiderCell) return flyCell;
251
+ * spider position wanders instead (randomFlyWander) — `turn`/`flyId` key
252
+ * that seeded draw. */
253
+ export function greedyFlyMove(flyCell, believedSpiderCell, applyActions, turn, flyId) {
254
+ if (!believedSpiderCell) return randomFlyWander(flyCell, applyActions, turn, flyId);
187
255
  return bestOneStepBy(
188
256
  flyCell, applyActions,
189
257
  (cell) => chebyshevDistance(cell.x, cell.y, believedSpiderCell.x, believedSpiderCell.y),
@@ -204,6 +272,20 @@ export function greedySpiderApproach(spiderCell, believedFlyCell, applyActions)
204
272
  );
205
273
  }
206
274
 
275
+ /** A spider's move when another live spider is believed visible: the mirror
276
+ * image of greedyFlyMove's evasion — score every one-ply reachable cell
277
+ * (plus staying put) by Chebyshev distance from the other spider's believed
278
+ * position, move to the highest-scoring (furthest) cell. Priority branch 1
279
+ * of §5's avoid-spiders > chase-flies > hold-and-web ordering. */
280
+ export function greedySpiderAvoid(spiderCell, believedOtherSpiderCell, applyActions) {
281
+ if (!believedOtherSpiderCell) return spiderCell;
282
+ return bestOneStepBy(
283
+ spiderCell, applyActions,
284
+ (cell) => chebyshevDistance(cell.x, cell.y, believedOtherSpiderCell.x, believedOtherSpiderCell.y),
285
+ (score, bestScore) => score > bestScore,
286
+ );
287
+ }
288
+
207
289
  // ---- visibility and belief (§4): static grid topology is common knowledge
208
290
  // to both agents; only dynamic entity positions are gated by vision. A told
209
291
  // fact (a later chat-integration piece of work, not built here) is the one
@@ -274,14 +356,16 @@ function mostRecentEaterSpider(state, eatenDeltaBySpider) {
274
356
  /**
275
357
  * One ecology pass over the tick's post-movement state: `postMovePlacements`
276
358
  * is a Map(subject -> {x,y}) for every currently-live spider and fly after
277
- * this turn's movement writes; `postMoveMassByFly` is a Map(flySubject ->
278
- * number), the fly's mass after this turn's decrement, pre-removal. `state`
279
- * is the PRE-move fold (for history: prior flies-eaten counts, prior eggs,
280
- * prior eaten turns). Returns `{ writes, events }` writes to append
281
- * alongside the turn's movement facts, events for the tick's own return
282
- * payload. Pure.
359
+ * this turn's movement writes; `postMoveMassByFly`/`postMoveMassBySpider` are
360
+ * Map(subject -> number), the mass after this turn's decrement, pre-removal
361
+ * (`postMoveMassBySpider` is optional a spider absent from it is simply
362
+ * never starve-checked, so callers that don't track spider mass, e.g. older
363
+ * tests, see no behavior change). `state` is the PRE-move fold (for history:
364
+ * prior flies-eaten counts, prior eggs, prior eaten turns, live webs).
365
+ * Returns `{ writes, events }` — writes to append alongside the turn's
366
+ * movement facts, events for the tick's own return payload. Pure.
283
367
  */
284
- export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, turn }) {
368
+ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider = new Map(), turn }) {
285
369
  const k = turn;
286
370
  const writes = [];
287
371
  const events = { eaten: [], starved: [], laid: null, hatched: [], spawned: null };
@@ -289,18 +373,22 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, t
289
373
  const spiders = [...postMovePlacements.keys()].filter((id) => /^spider-\d+$/.test(id)).sort();
290
374
  const flies = [...postMovePlacements.keys()].filter((id) => /^fly-\d+$/.test(id)).sort();
291
375
 
292
- // 1. Eat — a spider and a fly sharing an in-web cell.
376
+ // 1. Eat — a spider and a fly sharing an actively-webbed cell (static home
377
+ // zone or a live dynamic web). The eating spider gains exactly the fly's
378
+ // post-decrement remaining mass, not a flat bonus.
293
379
  const claimedFlies = new Set();
294
380
  const eatenDeltaBySpider = new Map();
381
+ const eatenMassBySpider = new Map();
295
382
  for (const spiderId of spiders) {
296
383
  const sCell = postMovePlacements.get(spiderId);
297
- if (!isInWebBlock(sCell.x, sCell.y)) continue;
384
+ if (!hasActiveWebAt(sCell.x, sCell.y, state, k)) continue;
298
385
  for (const flyId of flies) {
299
386
  if (claimedFlies.has(flyId)) continue;
300
387
  const fCell = postMovePlacements.get(flyId);
301
388
  if (sCell.x !== fCell.x || sCell.y !== fCell.y) continue;
302
389
  claimedFlies.add(flyId);
303
390
  eatenDeltaBySpider.set(spiderId, (eatenDeltaBySpider.get(spiderId) ?? 0) + 1);
391
+ eatenMassBySpider.set(spiderId, (eatenMassBySpider.get(spiderId) ?? 0) + (postMoveMassByFly.get(flyId) ?? 0));
304
392
  writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:eaten-by", object: spiderId });
305
393
  events.eaten.push({ fly: flyId, spider: spiderId, cell: cellId(sCell.x, sCell.y) });
306
394
  }
@@ -308,9 +396,14 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, t
308
396
  for (const [spiderId, delta] of eatenDeltaBySpider) {
309
397
  const newCount = (state.fliesEaten.get(spiderId)?.value ?? 0) + delta;
310
398
  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);
400
+ const newSpiderMass = priorSpiderMass + (eatenMassBySpider.get(spiderId) ?? 0);
401
+ writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newSpiderMass) });
311
402
  }
312
403
 
313
- // 2. Starve — mass reached zero, and not already claimed by this turn's eat.
404
+ // 2. Starve — mass reached zero, and not already claimed by this turn's
405
+ // eat. Spiders waste away the same as flies; a spider that just ate
406
+ // survives regardless (eat resolves first).
314
407
  for (const flyId of flies) {
315
408
  if (claimedFlies.has(flyId)) continue;
316
409
  if ((postMoveMassByFly.get(flyId) ?? 0) <= 0) {
@@ -319,6 +412,14 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, t
319
412
  }
320
413
  }
321
414
  const deadFliesThisTick = new Set([...claimedFlies, ...events.starved]);
415
+ for (const spiderId of spiders) {
416
+ if (eatenDeltaBySpider.has(spiderId)) continue;
417
+ if (!postMoveMassBySpider.has(spiderId)) continue;
418
+ if (postMoveMassBySpider.get(spiderId) <= 0) {
419
+ writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:starved", object: "true" });
420
+ events.starved.push(spiderId);
421
+ }
422
+ }
322
423
 
323
424
  // 3. Lay — the eat condition has fired enough times since the last egg (or
324
425
  // once, at game start), with no live egg outstanding right now.
@@ -351,11 +452,14 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, t
351
452
  const newSpiderId = `spider-${nextSpiderNum}`;
352
453
  nextSpiderNum += 1;
353
454
  writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
455
+ writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(SPIDER_INITIAL_MASS) });
354
456
  writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: newSpiderId });
355
457
  events.hatched.push({ egg: eggId, spider: newSpiderId, cell: eggCell });
356
458
  }
357
459
 
358
- // 5. Spawn — every third turn, a new fly at an uncontested perimeter cell.
460
+ // 5. Spawn — every third turn, a new fly at a seeded pick among the
461
+ // currently-uncontested perimeter cells (never Math.random — see
462
+ // seededPick's own header comment).
359
463
  if (k % FLY_SPAWN_INTERVAL_TURNS === 0) {
360
464
  const occupied = new Set();
361
465
  for (const spiderId of spiders) { const c = postMovePlacements.get(spiderId); occupied.add(cellId(c.x, c.y)); }
@@ -369,9 +473,10 @@ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, t
369
473
  occupied.add(state.placements.get(eggId)?.cell);
370
474
  }
371
475
  for (const h of events.hatched) occupied.add(h.cell);
372
- const cell = perimeterCells().find((c) => !occupied.has(c));
373
- if (cell) {
476
+ const uncontested = perimeterCells().filter((c) => !occupied.has(c));
477
+ if (uncontested.length) {
374
478
  const newFlyId = `fly-${1 + maxIdSuffix(state.placements.keys(), /^fly-(\d+)$/)}`;
479
+ const cell = seededPick(uncontested, `${WORLD_NAME}:${k}:${newFlyId}:spawn`);
375
480
  writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:currently-in", object: cell });
376
481
  writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
377
482
  events.spawned = newFlyId;
@@ -393,18 +498,26 @@ export async function startSpiderFlyGame(memoryDir, { flyCount = 1 } = {}) {
393
498
  if (state.placements.has("spider-1")) return { started: false, facts: [] };
394
499
 
395
500
  const perimeter = perimeterCells();
396
- const facts = [{ subject: "spider-1", predicate: "mgx:currently-in", object: cellId(WEB_HOME.x, WEB_HOME.y) }];
501
+ const facts = [
502
+ { subject: "spider-1", predicate: "mgx:currently-in", object: cellId(WEB_HOME.x, WEB_HOME.y) },
503
+ { subject: "spider-1", predicate: "mgx:mass", object: String(SPIDER_INITIAL_MASS) },
504
+ ];
505
+ const occupied = new Set([cellId(WEB_HOME.x, WEB_HOME.y)]);
397
506
  for (let i = 0; i < flyCount; i += 1) {
398
- const cell = perimeter[Math.floor((perimeter.length * (i + 1)) / (flyCount + 1)) % perimeter.length];
399
- facts.push({ subject: `fly-${i + 1}`, predicate: "mgx:currently-in", object: cell });
400
- facts.push({ subject: `fly-${i + 1}`, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
507
+ const flyId = `fly-${i + 1}`;
508
+ const uncontested = perimeter.filter((c) => !occupied.has(c));
509
+ const cell = seededPick(uncontested.length ? uncontested : perimeter, `${WORLD_NAME}:0:${flyId}:spawn`);
510
+ occupied.add(cell);
511
+ facts.push({ subject: flyId, predicate: "mgx:currently-in", object: cell });
512
+ facts.push({ subject: flyId, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
401
513
  }
402
514
  await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(WORLD_NAME) })));
403
515
  return { started: true, facts };
404
516
  }
405
517
 
406
518
  function goalLineFor(subject, believed, arrived, kind) {
407
- if (!believed) return kind === "spider" ? "no fly in sight holding position in the web." : "no spider in sight — holding position.";
519
+ if (kind === "spider-avoid") return `avoiding ${believed.subject}, last seen at ${cellId(believed.cell.x, believed.cell.y)}.`;
520
+ if (!believed) return kind === "spider" ? "no fly in sight — holding position in the web." : "no spider in sight — wandering.";
408
521
  const seenAt = cellId(believed.cell.x, believed.cell.y);
409
522
  if (kind === "spider") {
410
523
  return arrived
@@ -414,19 +527,37 @@ function goalLineFor(subject, believed, arrived, kind) {
414
527
  return `evading — last saw ${believed.subject} at ${seenAt}.`;
415
528
  }
416
529
 
530
+ /** Live (unexpired, by `turn`) dynamic webs from a `Map(webId -> {cell,
531
+ * builtAtTurn})` (either a folded state's own `.webs`, or that widened with
532
+ * web(s) minted THIS tick before they've been written/read back), as a
533
+ * plain array of { id, cell, builtAtTurn, expiresAtTurn }. Excludes the
534
+ * always-on static home zone (that's WEB_HOME/WEB_RADIUS, drawn separately —
535
+ * this is only the spider-built kind), for a renderer to draw distinctly. */
536
+ export function liveWebs(websMap, turn) {
537
+ const out = [];
538
+ for (const [id, { cell, builtAtTurn }] of websMap) {
539
+ if (builtAtTurn + WEB_DURATION_TURNS > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + WEB_DURATION_TURNS });
540
+ }
541
+ return out;
542
+ }
543
+
417
544
  /**
418
545
  * One full tick: fold state, compute each live spider's and fly's belief,
419
- * replan/re-score, execute one movement step per agent, run the ecology
420
- * pass, and append everything as this turn's @turnN facts in one write.
421
- * `opts.toldFacts` is the belief layer's chat-integration extension point
422
- * (§4) an array of `{ subject, toAgent, cell, turn }` rows, empty until a
423
- * later piece of work wires chat-told positions through it.
546
+ * replan/re-score, execute one movement step per agent (spiders: avoid other
547
+ * spiders > chase flies > hold-and-web; flies: evade > wander, unless
548
+ * trapped in an active web), run the ecology pass, and append everything as
549
+ * this turn's @turnN facts in one write. `opts.toldFacts` is the belief
550
+ * layer's chat-integration extension point (§4) an array of `{ subject,
551
+ * toAgent, cell, turn }` rows, empty until a later piece of work wires
552
+ * chat-told positions through it.
424
553
  *
425
- * Returns `{ turn, writes, agents, ecology }`: `agents` is keyed by every
426
- * live spider/fly subject after this tick, each `{ cell, goal, plan }` (the
427
- * spider's `plan` is its found path's remaining directions, or null when it
428
- * has none this tick); `ecology` is the tick's eaten/starved/laid/hatched/
429
- * spawned event summary.
554
+ * Returns `{ turn, writes, agents, ecology, activeWebs }`: `agents` is keyed
555
+ * by every live spider/fly subject after this tick, each `{ cell, goal,
556
+ * plan, mass }` (the spider's `plan` is its found path's remaining
557
+ * directions, or null when it has none this tick); `ecology` is the tick's
558
+ * eaten/starved/laid/hatched/spawned event summary; `activeWebs` is every
559
+ * currently-live dynamic web (static home zone excluded — that's fixed grid
560
+ * geometry, not runtime state), for a renderer to draw distinctly.
430
561
  */
431
562
  export async function runSpiderFlyTick(memoryDir, opts = {}) {
432
563
  const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
@@ -441,51 +572,88 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
441
572
  const movementWrites = [];
442
573
  const postMovePlacements = new Map();
443
574
  const postMoveMassByFly = new Map();
575
+ const postMoveMassBySpider = new Map();
444
576
  const agents = {};
577
+ const tickWebs = new Map(state.webs); // widened in place as spiders build/refresh this tick
578
+ let nextWebNum = 1 + maxIdSuffix(state.webs.keys(), /^web-(\d+)$/);
445
579
 
446
580
  for (const spiderId of spiders) {
447
581
  const spiderCell = parseCellId(state.placements.get(spiderId).cell);
448
- const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius, toldFacts });
449
- let nextCell = spiderCell;
582
+ const priorMass = state.mass.get(spiderId)?.value ?? SPIDER_INITIAL_MASS;
583
+ const newMass = Math.max(0, priorMass - SPIDER_MASS_DECREMENT_PER_TURN);
584
+ postMoveMassBySpider.set(spiderId, newMass);
585
+
586
+ // Priority 1: avoid any OTHER live spider believed visible.
587
+ const otherSpiders = spiders.filter((id) => id !== spiderId);
588
+ const avoidTarget = nearestBelievedTarget(spiderId, spiderCell, otherSpiders, state, { visionRadius, toldFacts });
589
+ let nextCell;
450
590
  let plan = null;
451
- if (target) {
452
- const path = planSpiderPath(spiderCell, target.cell, applyActions);
453
- if (path) {
454
- if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
591
+ let goal;
592
+ if (avoidTarget) {
593
+ nextCell = greedySpiderAvoid(spiderCell, avoidTarget.cell, applyActions);
594
+ goal = goalLineFor(spiderId, avoidTarget, false, "spider-avoid");
595
+ } else {
596
+ // Priority 2: chase a believed-visible fly, exactly as before.
597
+ const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius, toldFacts });
598
+ if (target) {
599
+ nextCell = spiderCell;
600
+ const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k);
601
+ if (path) {
602
+ if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
603
+ } else {
604
+ nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
605
+ }
606
+ // "Arrived" is the real eat precondition (co-located with the
607
+ // believed target, inside an active web) — NOT merely "didn't move
608
+ // this turn", which a greedy-approach spider also does whenever it's
609
+ // already at its closest reachable cell but still a step away
610
+ // (Chebyshev-adjacent isn't co-located; has-exit-* edges have no
611
+ // diagonal hop).
612
+ const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y && hasActiveWebAt(nextCell.x, nextCell.y, state, k);
613
+ goal = goalLineFor(spiderId, target, arrived, "spider");
455
614
  } else {
456
- nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
615
+ // Priority 3: hold position, and build/refresh a web there unless an
616
+ // unexpired web already covers this exact cell.
617
+ nextCell = spiderCell;
618
+ const heldCellId = cellId(spiderCell.x, spiderCell.y);
619
+ if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k)) {
620
+ const webId = `web-${nextWebNum}`;
621
+ nextWebNum += 1;
622
+ tickWebs.set(webId, { cell: heldCellId, builtAtTurn: k });
623
+ movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:currently-in", object: heldCellId });
624
+ movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:web-built-at-turn", object: String(k) });
625
+ goal = "no fly in sight — building a web here.";
626
+ } else {
627
+ goal = goalLineFor(spiderId, null, false, "spider");
628
+ }
457
629
  }
458
630
  }
631
+
459
632
  postMovePlacements.set(spiderId, nextCell);
460
633
  movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
461
- // "Arrived" is the real eat precondition (co-located with the believed
462
- // target, inside the web) NOT merely "didn't move this turn", which a
463
- // greedy-approach spider also does whenever it's already at its closest
464
- // reachable cell but still a step away (Chebyshev-adjacent isn't
465
- // co-located; has-exit-* edges have no diagonal hop). Using "didn't move"
466
- // as the proxy previously mislabeled that stuck-but-not-there case as
467
- // "co-located ... in the web" even when nowhere near the web.
468
- const arrived = !!target && nextCell.x === target.cell.x && nextCell.y === target.cell.y && isInWebBlock(nextCell.x, nextCell.y);
469
- agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal: goalLineFor(spiderId, target, arrived, "spider"), plan };
634
+ movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
635
+ agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal, plan, mass: newMass };
470
636
  }
471
637
 
472
638
  for (const flyId of flies) {
473
639
  const flyCell = parseCellId(state.placements.get(flyId).cell);
474
640
  const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius, toldFacts });
475
- const nextCell = greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions);
641
+ const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k);
642
+ const nextCell = webbed ? flyCell : greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions, k, flyId);
476
643
  postMovePlacements.set(flyId, nextCell);
477
644
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
478
645
  const priorMass = state.mass.get(flyId)?.value ?? FLY_INITIAL_MASS;
479
646
  const newMass = Math.max(0, priorMass - FLY_MASS_DECREMENT_PER_TURN);
480
647
  postMoveMassByFly.set(flyId, newMass);
481
648
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
482
- agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal: goalLineFor(flyId, believedSpider, true, "fly") };
649
+ const goal = webbed ? "trapped in an active web — can't move." : goalLineFor(flyId, believedSpider, true, "fly");
650
+ agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal, mass: newMass };
483
651
  }
484
652
 
485
- const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, turn: k });
653
+ const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider, turn: k });
486
654
  const writes = [...movementWrites, ...ecology.writes];
487
655
  const provenance = `${worldProvenanceTag(WORLD_NAME)}:turn${k}`;
488
656
  await appendFacts(memoryDir, writes.map((f) => ({ ...f, provenance })));
489
657
 
490
- return { turn: k, writes, agents, ecology: ecology.events };
658
+ return { turn: k, writes, agents, ecology: ecology.events, activeWebs: liveWebs(tickWebs, k) };
491
659
  }