@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  import {
13
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
- DIRECTION_DELTA,
15
+ DIRECTION_DELTA, oneStepDirectionBetween,
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";
@@ -29,7 +29,9 @@ export const FLY_INITIAL_MASS = 10;
29
29
  export const FLY_MASS_DECREMENT_PER_TURN = 1;
30
30
  export const EGG_HATCH_DELAY_TURNS = 3;
31
31
  export const FLY_SPAWN_INTERVAL_TURNS = 3;
32
- export const EGGS_EATEN_THRESHOLD = 2;
32
+ export const EGG_LAY_MASS_THRESHOLD = 25;
33
+ export const EGG_HATCH_COUNT = 2;
34
+ export const MIN_HATCHLING_MASS = 3;
33
35
  export { SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN, WEB_DURATION_TURNS };
34
36
 
35
37
  // ---- seeded "randomness" (never Math.random) ---------------------------------
@@ -60,7 +62,8 @@ const WEB_ID_RE = /^web-\d+$/;
60
62
 
61
63
  /** Fold fact rows into the current spider-fly world state: per-subject
62
64
  * newest placement (mgx:currently-in), newest fly mass, spider's newest
63
- * flies-eaten count, each egg's laid-at-turn, each dynamic web's cell +
65
+ * flies-eaten count, each spider's newest carrying status (mgx:carrying
66
+ * a fly id, or "none"), each egg's laid-at-turn, each dynamic web's cell +
64
67
  * built-at turn, and the terminal eaten-by/starved/hatched-into markers that
65
68
  * make a subject no longer live. The turn counter is derived, never stored —
66
69
  * the largest @turnN suffix seen, exactly foldWorldState's own convention.
@@ -69,6 +72,7 @@ export function foldSpiderFlyState(factRows) {
69
72
  const placements = new Map(); // subject -> { cell, turn }
70
73
  const mass = new Map(); // fly/spider subject -> { value, turn }
71
74
  const fliesEaten = new Map(); // spider subject -> { value, turn }
75
+ const carrying = new Map(); // spider subject -> { flyId, turn } (flyId "none" omitted)
72
76
  const laidAtTurn = new Map(); // egg subject -> { value, turn }
73
77
  const webCell = new Map(); // web subject -> { cell, turn }
74
78
  const webBuiltAt = new Map(); // web subject -> { value, turn }
@@ -100,6 +104,14 @@ export function foldSpiderFlyState(factRows) {
100
104
  if (!prior || turn >= prior.turn) fliesEaten.set(base, { value: Number(row.object), turn });
101
105
  continue;
102
106
  }
107
+ if (row.predicate === "mgx:carrying") {
108
+ const prior = carrying.get(base);
109
+ if (!prior || turn >= prior.turn) {
110
+ if (row.object === "none") carrying.delete(base);
111
+ else carrying.set(base, { flyId: row.object, turn });
112
+ }
113
+ continue;
114
+ }
103
115
  if (row.predicate === "mgx:laid-at-turn") {
104
116
  const prior = laidAtTurn.get(base);
105
117
  if (!prior || turn >= prior.turn) laidAtTurn.set(base, { value: Number(row.object), turn });
@@ -130,7 +142,7 @@ export function foldSpiderFlyState(factRows) {
130
142
  }
131
143
 
132
144
  const removed = new Set([...eatenBy.keys(), ...starved, ...hatchedInto.keys()]);
133
- return { placements, mass, fliesEaten, laidAtTurn, webs, eatenBy, starved, hatchedInto, removed, turnCount };
145
+ return { placements, mass, fliesEaten, carrying, laidAtTurn, webs, eatenBy, starved, hatchedInto, removed, turnCount };
134
146
  }
135
147
 
136
148
  const sortedLiveSubjects = (state, re) =>
@@ -207,18 +219,31 @@ export function hasActiveWebAt(x, y, state, turn, webDurationTurns = WEB_DURATIO
207
219
  }
208
220
 
209
221
  /** The spider's multi-step path: findActionPath wired with isGoal =
210
- * "co-located with the believed fly cell, and that cell has an active web
211
- * (static or dynamic)." When the fly's believed cell sits outside every
212
- * active web, isGoal can never fire (it doesn't depend on the search state,
213
- * only on the fixed target), so this returns null — an honest "no path to
214
- * an eat" rather than a path toward a cell that would never satisfy the eat
215
- * condition. Null also covers "no believed target at all." `state`/`turn`
216
- * are optional, defaulting to "static web zone only" (see hasActiveWebAt).
217
- * `webDurationTurns` forwards to hasActiveWebAt unchanged. */
222
+ * "co-located with the believed fly cell" mere co-location, not
223
+ * co-location-in-a-web. Catching a fly (the ecology pass's own catch step)
224
+ * never needs a web; only the SEPARATE eat step does, once a caught fly is
225
+ * actually carried into one (planSpiderPathToWeb's own job, run by the
226
+ * movement priority a carrying spider takes over next tick). Null when no
227
+ * believed target exists at all. `state`/`turn`/`webDurationTurns` are
228
+ * accepted for signature symmetry with planSpiderPathToWeb and every other
229
+ * caller in this file, but this function's own isGoal no longer reads
230
+ * them. */
218
231
  export function planSpiderPath(spiderCell, believedFlyCell, applyActions, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
219
232
  if (!believedFlyCell) return null;
220
- const isGoal = (s) =>
221
- s.x === believedFlyCell.x && s.y === believedFlyCell.y && hasActiveWebAt(s.x, s.y, state, turn, webDurationTurns);
233
+ const isGoal = (s) => s.x === believedFlyCell.x && s.y === believedFlyCell.y;
234
+ return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
235
+ }
236
+
237
+ /** A carrying spider's own multi-step path home: findActionPath wired with
238
+ * isGoal = "any actively-webbed cell" (hasActiveWebAt, reused directly,
239
+ * unlike planSpiderPath there is no specific target cell — any live web
240
+ * will do). Used by the movement priority (below) for a spider already
241
+ * holding a fly: it races the fly to the nearest web rather than
242
+ * continuing to chase. Returns null when no active web is reachable at all
243
+ * (the caller falls back to greedySpiderApproach toward the static web's
244
+ * home cell). */
245
+ export function planSpiderPathToWeb(spiderCell, applyActions, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
246
+ const isGoal = (s) => hasActiveWebAt(s.x, s.y, state, turn, webDurationTurns);
222
247
  return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
223
248
  }
224
249
 
@@ -331,31 +356,17 @@ export function nearestBelievedTarget(observerSubject, observerCell, candidates,
331
356
  return best;
332
357
  }
333
358
 
334
- // ---- the ecology pass (§10): eat, lay, hatch, spawn, starve, all as
359
+ // ---- the ecology pass (§10): catch, eat, lay, hatch, spawn, starve, all as
335
360
  // ordinary turn-gated checks in one fixed-order pass. Order matters and is
336
- // fixed deliberately: eat first (predation resolves on the turn's fresh
337
- // positions), then starve (a fly already claimed by an eat this turn cannot
338
- // also starve), then lay (reads the egg slot as it stood BEFORE this tick's
339
- // own hatch, so a hatch and a fresh lay never land the same turn), then
340
- // hatch, then spawn (reads the board as every earlier step in this same
341
- // pass left it, so a fly never spawns on a cell an eat/hatch just vacated
342
- // or occupied). ---------------------------------------------------------------
343
-
344
- function mostRecentEggLaidTurn(state) {
345
- let max = -1;
346
- for (const { value } of state.laidAtTurn.values()) max = Math.max(max, value);
347
- return max;
348
- }
349
-
350
- function mostRecentEaterSpider(state, eatenDeltaBySpider) {
351
- if (eatenDeltaBySpider.size) return [...eatenDeltaBySpider.keys()].sort()[0];
352
- let best = null;
353
- let bestTurn = -1;
354
- for (const { spider, turn } of state.eatenBy.values()) {
355
- if (turn > bestTurn) { bestTurn = turn; best = spider; }
356
- }
357
- return best;
358
- }
361
+ // fixed deliberately: catch first (a spider not yet carrying claims an
362
+ // uncarried live fly it now shares a cell with, web or not), then eat (only
363
+ // a carrying spider standing in an active web actually consumes its catch
364
+ // predation resolves on the turn's fresh positions), then starve (a fly
365
+ // already claimed by an eat this turn cannot also starve), then lay (reads
366
+ // the egg slot as it stood BEFORE this tick's own hatch, so a hatch and a
367
+ // fresh lay never land the same turn), then hatch, then spawn (reads the
368
+ // board as every earlier step in this same pass left it, so a fly never
369
+ // spawns on a cell an eat/hatch just vacated or occupied). ------------------
359
370
 
360
371
  /**
361
372
  * One ecology pass over the tick's post-movement state: `postMovePlacements`
@@ -365,11 +376,12 @@ function mostRecentEaterSpider(state, eatenDeltaBySpider) {
365
376
  * (`postMoveMassBySpider` is optional — a spider absent from it is simply
366
377
  * never starve-checked, so callers that don't track spider mass, e.g. older
367
378
  * tests, see no behavior change). `state` is the PRE-move fold (for history:
368
- * prior flies-eaten counts, prior eggs, prior eaten turns, live webs).
379
+ * prior flies-eaten counts, prior carrying status, prior eggs, live webs).
369
380
  * `config` (default DEFAULT_GAME_CONFIG.spiderFly) supplies every tunable
370
381
  * 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.
382
+ * egg-lay mass threshold, the hatch delay/count, the minimum hatchling mass,
383
+ * the spawn interval, and the web duration the eat precondition checks
384
+ * against.
373
385
  * Returns `{ writes, events }` — writes to append alongside the turn's
374
386
  * movement facts, events for the tick's own return payload. Pure.
375
387
  */
@@ -379,31 +391,58 @@ export function runEcologyPass({
379
391
  }) {
380
392
  const k = turn;
381
393
  const writes = [];
382
- const events = { eaten: [], starved: [], laid: null, hatched: [], spawned: null, spawnedCell: null, massAfterEating: new Map() };
394
+ const events = {
395
+ caught: [], eaten: [], starved: [], laid: null, hatched: [], spawned: null, spawnedCell: null,
396
+ massAfterEating: new Map(),
397
+ };
383
398
 
384
399
  const spiders = [...postMovePlacements.keys()].filter((id) => /^spider-\d+$/.test(id)).sort();
385
400
  const flies = [...postMovePlacements.keys()].filter((id) => /^fly-\d+$/.test(id)).sort();
386
401
 
387
- // 1. Eat — a spider and a fly sharing an actively-webbed cell (static home
388
- // zone or a live dynamic web). The eating spider gains exactly the fly's
389
- // post-decrement remaining mass, not a flat bonus.
390
- const claimedFlies = new Set();
391
- const eatenDeltaBySpider = new Map();
392
- const eatenMassBySpider = new Map();
402
+ // 1. Catch — a spider not already carrying a live fly claims the first
403
+ // uncarried live fly it shares a cell with this tick (web or not the web
404
+ // only matters for the EAT step below). At most one catch per spider per
405
+ // tick. Starts from whatever every live spider was already carrying
406
+ // BEFORE this tick (state.carrying, filtered to a still-live captor and a
407
+ // still-live fly a captor that died drops its stale carrying claim,
408
+ // freeing the fly to move independently again from the next tick on).
409
+ const carryingBySpider = new Map(); // spiderId -> flyId, this tick's working belief
410
+ for (const [spiderId, { flyId }] of state.carrying) {
411
+ if (postMovePlacements.has(spiderId) && postMovePlacements.has(flyId)) carryingBySpider.set(spiderId, flyId);
412
+ }
413
+ const carriedFlyIds = new Set(carryingBySpider.values());
393
414
  for (const spiderId of spiders) {
415
+ if (carryingBySpider.has(spiderId)) continue;
394
416
  const sCell = postMovePlacements.get(spiderId);
395
- if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
396
417
  for (const flyId of flies) {
397
- if (claimedFlies.has(flyId)) continue;
418
+ if (carriedFlyIds.has(flyId)) continue;
398
419
  const fCell = postMovePlacements.get(flyId);
399
420
  if (sCell.x !== fCell.x || sCell.y !== fCell.y) continue;
400
- claimedFlies.add(flyId);
401
- eatenDeltaBySpider.set(spiderId, (eatenDeltaBySpider.get(spiderId) ?? 0) + 1);
402
- eatenMassBySpider.set(spiderId, (eatenMassBySpider.get(spiderId) ?? 0) + (postMoveMassByFly.get(flyId) ?? 0));
403
- writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:eaten-by", object: spiderId });
404
- events.eaten.push({ fly: flyId, spider: spiderId, cell: cellId(sCell.x, sCell.y) });
421
+ carryingBySpider.set(spiderId, flyId);
422
+ carriedFlyIds.add(flyId);
423
+ events.caught.push({ spider: spiderId, fly: flyId, cell: cellId(sCell.x, sCell.y) });
424
+ break;
405
425
  }
406
426
  }
427
+
428
+ // 2. Eat — a spider carrying a fly AND standing in an actively-webbed cell
429
+ // (static home zone or a live dynamic web) consumes it. The eating spider
430
+ // gains exactly the fly's post-decrement remaining mass, not a flat bonus.
431
+ const claimedFlies = new Set();
432
+ const eatenDeltaBySpider = new Map();
433
+ const eatenMassBySpider = new Map();
434
+ for (const spiderId of spiders) {
435
+ const flyId = carryingBySpider.get(spiderId);
436
+ if (!flyId) continue;
437
+ const sCell = postMovePlacements.get(spiderId);
438
+ if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
439
+ carryingBySpider.delete(spiderId);
440
+ claimedFlies.add(flyId);
441
+ eatenDeltaBySpider.set(spiderId, (eatenDeltaBySpider.get(spiderId) ?? 0) + 1);
442
+ eatenMassBySpider.set(spiderId, (eatenMassBySpider.get(spiderId) ?? 0) + (postMoveMassByFly.get(flyId) ?? 0));
443
+ writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:eaten-by", object: spiderId });
444
+ events.eaten.push({ fly: flyId, spider: spiderId, cell: cellId(sCell.x, sCell.y) });
445
+ }
407
446
  for (const [spiderId, delta] of eatenDeltaBySpider) {
408
447
  const newCount = (state.fliesEaten.get(spiderId)?.value ?? 0) + delta;
409
448
  writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:flies-eaten", object: String(newCount) });
@@ -413,9 +452,11 @@ export function runEcologyPass({
413
452
  events.massAfterEating.set(spiderId, newSpiderMass);
414
453
  }
415
454
 
416
- // 2. Starve — mass reached zero, and not already claimed by this turn's
455
+ // 3. Starve — mass reached zero, and not already claimed by this turn's
417
456
  // eat. Spiders waste away the same as flies; a spider that just ate
418
- // survives regardless (eat resolves first).
457
+ // survives regardless (eat resolves first). A carried-but-not-yet-eaten
458
+ // fly starves exactly like a free one — its captor's carrying claim is
459
+ // dropped below so the spider doesn't keep "holding" a dead fly.
419
460
  for (const flyId of flies) {
420
461
  if (claimedFlies.has(flyId)) continue;
421
462
  if ((postMoveMassByFly.get(flyId) ?? 0) <= 0) {
@@ -424,6 +465,9 @@ export function runEcologyPass({
424
465
  }
425
466
  }
426
467
  const deadFliesThisTick = new Set([...claimedFlies, ...events.starved]);
468
+ for (const [spiderId, flyId] of carryingBySpider) {
469
+ if (events.starved.includes(flyId)) carryingBySpider.delete(spiderId);
470
+ }
427
471
  for (const spiderId of spiders) {
428
472
  if (eatenDeltaBySpider.has(spiderId)) continue;
429
473
  if (!postMoveMassBySpider.has(spiderId)) continue;
@@ -433,27 +477,47 @@ export function runEcologyPass({
433
477
  }
434
478
  }
435
479
 
436
- // 3. Lay the eat condition has fired enough times since the last egg (or
437
- // once, at game start), with no live egg outstanding right now.
480
+ // Every live spider re-asserts its own mgx:carrying fact every tick (the
481
+ // same always-rewritten idiom mgx:currently-in/mgx:mass already use) a
482
+ // merely-stopped write would leave a stale "carrying" row standing forever
483
+ // for the fold to keep honoring long after this tick's catch/eat actually
484
+ // changed it.
485
+ for (const spiderId of spiders) {
486
+ writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:carrying", object: carryingBySpider.get(spiderId) ?? "none" });
487
+ }
488
+
489
+ // 4. Lay — a spider's mass has reached the lay threshold AND it is
490
+ // standing in an active web, with no live egg outstanding right now. The
491
+ // laying spider resets to exactly its own initial mass; the surplus
492
+ // becomes the egg's own starting mass (an egg is never laid below the
493
+ // initial-mass reset, so the surplus is always >= 0).
438
494
  const liveEggId = [...state.laidAtTurn.keys()].find((id) => !state.removed.has(id));
439
495
  if (!liveEggId) {
440
- const sinceTurn = mostRecentEggLaidTurn(state);
441
- const threshold = sinceTurn === -1 ? 1 : config.eggsEatenThreshold;
442
- let eatsSince = events.eaten.length;
443
- for (const { turn: eatenTurn } of state.eatenBy.values()) if (eatenTurn > sinceTurn) eatsSince += 1;
444
- if (eatsSince >= threshold) {
445
- const eggSpider = mostRecentEaterSpider(state, eatenDeltaBySpider);
446
- const eggCell = eggSpider ? (postMovePlacements.get(eggSpider) ?? parseCellId(state.placements.get(eggSpider)?.cell)) : null;
447
- if (eggCell) {
448
- const eggId = `egg-${1 + maxIdSuffix(state.placements.keys(), /^egg-(\d+)$/)}`;
449
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(eggCell.x, eggCell.y) });
450
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:laid-at-turn", object: String(k) });
451
- events.laid = eggId;
452
- }
496
+ for (const spiderId of spiders) {
497
+ const sCell = postMovePlacements.get(spiderId);
498
+ if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
499
+ const spiderMass = eatenDeltaBySpider.has(spiderId)
500
+ ? events.massAfterEating.get(spiderId)
501
+ : (postMoveMassBySpider.get(spiderId) ?? state.mass.get(spiderId)?.value ?? config.spiderInitialMass);
502
+ if (spiderMass < config.eggLayMassThreshold) continue;
503
+ const eggMass = spiderMass - config.spiderInitialMass;
504
+ writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(config.spiderInitialMass) });
505
+ const eggId = `egg-${1 + maxIdSuffix(state.placements.keys(), /^egg-(\d+)$/)}`;
506
+ const eggCellId = cellId(sCell.x, sCell.y);
507
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:currently-in", object: eggCellId });
508
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:laid-at-turn", object: String(k) });
509
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:mass", object: String(eggMass) });
510
+ events.laid = eggId;
511
+ break; // one-egg-at-a-time cap — the first qualifying spider lays it
453
512
  }
454
513
  }
455
514
 
456
- // 4. Hatch — any live egg laid exactly config.eggHatchDelayTurns turns ago.
515
+ // 5. Hatch — any live egg laid exactly config.eggHatchDelayTurns turns ago
516
+ // hatches into config.eggHatchCount spiders, capped by a floor on COUNT
517
+ // (never on hatch mass): actualHatchCount = max(1, min(eggHatchCount,
518
+ // floor(eggMass / minHatchlingMass))) — a too-small egg produces fewer,
519
+ // still-viable hatchlings rather than emaciated ones. The egg's mass
520
+ // splits evenly, remainder to the lowest-numbered hatchling.
457
521
  const liveEggIds = [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort();
458
522
  let nextSpiderNum = 1 + maxIdSuffix(state.placements.keys(), /^spider-(\d+)$/);
459
523
  for (const eggId of liveEggIds) {
@@ -461,15 +525,24 @@ export function runEcologyPass({
461
525
  if (laidTurn + config.eggHatchDelayTurns !== k) continue;
462
526
  const eggCell = state.placements.get(eggId)?.cell;
463
527
  if (!eggCell) continue;
464
- const newSpiderId = `spider-${nextSpiderNum}`;
465
- nextSpiderNum += 1;
466
- writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
467
- writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(config.spiderInitialMass) });
468
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: newSpiderId });
469
- events.hatched.push({ egg: eggId, spider: newSpiderId, cell: eggCell });
528
+ const eggMass = state.mass.get(eggId)?.value ?? config.spiderInitialMass;
529
+ const hatchCount = Math.max(1, Math.min(config.eggHatchCount, Math.floor(eggMass / config.minHatchlingMass)));
530
+ const share = Math.floor(eggMass / hatchCount);
531
+ const remainder = eggMass - share * hatchCount;
532
+ const hatchlings = [];
533
+ for (let i = 0; i < hatchCount; i += 1) {
534
+ const newSpiderId = `spider-${nextSpiderNum}`;
535
+ nextSpiderNum += 1;
536
+ const hatchlingMass = share + (i === 0 ? remainder : 0);
537
+ writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
538
+ writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(hatchlingMass) });
539
+ hatchlings.push({ spider: newSpiderId, mass: hatchlingMass });
540
+ }
541
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: hatchlings[0].spider });
542
+ events.hatched.push({ egg: eggId, cell: eggCell, spiders: hatchlings });
470
543
  }
471
544
 
472
- // 5. Spawn — every third turn, a new fly at a seeded pick among the
545
+ // 6. Spawn — every third turn, a new fly at a seeded pick among the
473
546
  // currently-uncontested perimeter cells (never Math.random — see
474
547
  // seededPick's own header comment).
475
548
  if (k % config.flySpawnIntervalTurns === 0) {
@@ -530,17 +603,50 @@ export async function startSpiderFlyGame(memoryDir, { flyCount = 1, config = DEF
530
603
  }
531
604
 
532
605
  function goalLineFor(subject, believed, arrived, kind) {
606
+ if (kind === "spider-carrying") return `carrying ${believed.subject} toward the web.`;
607
+ if (kind === "spider-carrying-delivered") return `carrying ${believed.subject} — already in the web, delivering it now.`;
533
608
  if (kind === "spider-avoid") return `avoiding ${believed.subject}, last seen at ${cellId(believed.cell.x, believed.cell.y)}.`;
534
609
  if (!believed) return kind === "spider" ? "no fly in sight — holding position in the web." : "no spider in sight — wandering.";
535
610
  const seenAt = cellId(believed.cell.x, believed.cell.y);
536
611
  if (kind === "spider") {
537
612
  return arrived
538
- ? `co-located with ${believed.subject} in the web.`
613
+ ? `co-located with ${believed.subject} catching it.`
539
614
  : `chasing ${believed.subject}, last seen at ${seenAt}.`;
540
615
  }
541
616
  return `evading — last saw ${believed.subject} at ${seenAt}.`;
542
617
  }
543
618
 
619
+ /** A one-step "plan" for a greedy or held move — the direction from
620
+ * `fromCell` to `toCell` as a length-1 array, or `[]` when the agent held
621
+ * still. Every agent's `plan` field is populated this way when its move
622
+ * came from one-ply greedy scoring or holding; a genuine multi-step search
623
+ * result (planSpiderPath/planSpiderPathToWeb) supplies its own full
624
+ * direction list instead — either way, `plan[0]` is always the direction
625
+ * actually taken this tick — the facing driver a renderer keys sprite
626
+ * orientation on. */
627
+ function stepPlan(fromCell, toCell) {
628
+ const direction = oneStepDirectionBetween(fromCell, toCell);
629
+ return direction ? [direction] : [];
630
+ }
631
+
632
+ /** A full "world knowledge graph" snapshot for one observer this tick: every
633
+ * OTHER named candidate's believed cell (believedCellOf — ground truth
634
+ * inside vision, else the newest told fact addressed to this observer,
635
+ * else null for "unknown") — feeds a per-agent belief panel. Deliberately
636
+ * never ground truth: showing the observer's own honest gap
637
+ * between belief and reality (visibly widened by a deceiving pill or a fed
638
+ * false fact) is the whole point of that panel. Returns a plain
639
+ * `{ [candidateId]: cellId | null }` map. */
640
+ function beliefSnapshotFor(observerSubject, observerCell, candidateIds, state, opts) {
641
+ const belief = {};
642
+ for (const candidateId of candidateIds) {
643
+ if (candidateId === observerSubject) continue;
644
+ const cell = believedCellOf(candidateId, observerSubject, observerCell, state, opts);
645
+ belief[candidateId] = cell ? cellId(cell.x, cell.y) : null;
646
+ }
647
+ return belief;
648
+ }
649
+
544
650
  /** Live (unexpired, by `turn`) dynamic webs from a `Map(webId -> {cell,
545
651
  * builtAtTurn})` (either a folded state's own `.webs`, or that widened with
546
652
  * web(s) minted THIS tick before they've been written/read back), as a
@@ -559,30 +665,37 @@ export function liveWebs(websMap, turn, webDurationTurns = WEB_DURATION_TURNS) {
559
665
 
560
666
  /**
561
667
  * One full tick: fold state, compute each live spider's and fly's belief,
562
- * replan/re-score, execute one movement step per agent (spiders: avoid other
563
- * spiders > chase flies > hold-and-web; flies: evade > wander, unless
564
- * trapped in an active web), run the ecology pass, and append everything as
565
- * this turn's @turnN facts in one write. `opts.toldFacts` is the belief
566
- * layer's chat-integration extension point (§4) — an array of `{ subject,
567
- * toAgent, cell, turn }` rows, empty until a later piece of work wires
568
- * chat-told positions through it.
668
+ * replan/re-score, execute one movement step per agent (spiders: carrying-
669
+ * not-yet-delivered > avoid other spiders > chase flies > hold-and-web;
670
+ * flies: carried (inert) > evade > wander, unless trapped in an active web),
671
+ * run the ecology pass, and append everything as this turn's @turnN facts in
672
+ * one write. `opts.toldFacts` is the belief layer's chat-integration
673
+ * extension point (§4) an array of `{ subject, toAgent, cell, turn }`
674
+ * rows, empty until a later piece of work wires chat-told positions through
675
+ * it.
569
676
  *
570
677
  * Returns `{ turn, writes, agents, ecology, activeWebs }`: `agents` is keyed
571
678
  * by every live spider/fly subject after this tick, each `{ cell, goal,
572
- * plan, mass }` (the spider's `plan` is its found path's remaining
573
- * directions, or null when it has none this tick); `ecology` is the tick's
574
- * eaten/starved/laid/hatched/spawned event summary; `activeWebs` is every
575
- * currently-live dynamic web (static home zone excluded that's fixed grid
576
- * geometry, not runtime state), for a renderer to draw distinctly.
679
+ * plan, mass, belief }` `plan` is the direction sequence that produced
680
+ * THIS tick's move (a full multi-step search result when one was found,
681
+ * else a length-1 array for a single greedy step, else `[]` when the agent
682
+ * held still `plan[0]` is always the direction actually taken, the facing
683
+ * driver a renderer keys sprite orientation on); `belief` is
684
+ * `{ [otherAgentId]: cellId | null }`,
685
+ * this agent's own believed position for every other live agent (never
686
+ * ground truth — see beliefSnapshotFor). `ecology` is the tick's own
687
+ * caught/eaten/starved/laid/hatched/spawned event summary; `activeWebs` is
688
+ * every currently-live dynamic web (static home zone excluded — that's
689
+ * fixed grid geometry, not runtime state), for a renderer to draw
690
+ * distinctly.
577
691
  *
578
692
  * `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.
693
+ * tunable this tick reads: each class's own vision radius, both agents'
694
+ * starting/decrement masses, and the web duration, and is forwarded
695
+ * unchanged into runEcologyPass.
582
696
  */
583
697
  export async function runSpiderFlyTick(memoryDir, opts = {}) {
584
698
  const { toldFacts = [], config = DEFAULT_GAME_CONFIG.spiderFly } = opts;
585
- const visionRadius = config.visionRadius;
586
699
  const rows = readFactRows(await loadMemory(memoryDir));
587
700
  const state = foldSpiderFlyState(rows);
588
701
  const k = state.turnCount + 1;
@@ -605,48 +718,83 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
605
718
  const newMass = Math.max(0, priorMass - config.spiderMassDecrementPerTurn);
606
719
  postMoveMassBySpider.set(spiderId, newMass);
607
720
 
608
- // Priority 1: avoid any OTHER live spider believed visible.
609
721
  const otherSpiders = spiders.filter((id) => id !== spiderId);
610
- const avoidTarget = nearestBelievedTarget(spiderId, spiderCell, otherSpiders, state, { visionRadius, toldFacts });
722
+ const belief = beliefSnapshotFor(spiderId, spiderCell, [...otherSpiders, ...flies], state, { visionRadius: config.spiderVisionRadius, toldFacts });
723
+
724
+ // Priority 0: carrying a fly, above everything else — a
725
+ // carrying spider never drops its catch to avoid another spider or
726
+ // chase a second one (no spider-eats-spider mechanic exists anywhere in
727
+ // this engine, so "avoid" is resource contention, not survival, and
728
+ // dropping/abandoning the catch would undermine the whole mass-economy
729
+ // goal chain). Not yet delivered: race the shortest path to any active
730
+ // web (planSpiderPathToWeb), falling back to a greedy approach toward
731
+ // the static web's home cell when no path is found. Already delivered
732
+ // (already standing in an active web): hold still rather than run the
733
+ // ordinary priority chain, so the ecology pass's own eat gate (which
734
+ // reads this SAME post-move position) reliably resolves the delivery
735
+ // this exact tick instead of risking the spider wandering back out
736
+ // first.
737
+ const carriedFlyId = state.carrying.get(spiderId)?.flyId;
738
+ const isCarrying = Boolean(carriedFlyId) && !state.removed.has(carriedFlyId);
739
+ const alreadyDelivered = isCarrying && hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns);
740
+
611
741
  let nextCell;
612
- let plan = null;
742
+ let plan;
613
743
  let goal;
614
- if (avoidTarget) {
615
- nextCell = greedySpiderAvoid(spiderCell, avoidTarget.cell, applyActions);
616
- goal = goalLineFor(spiderId, avoidTarget, false, "spider-avoid");
744
+ if (isCarrying && alreadyDelivered) {
745
+ nextCell = spiderCell;
746
+ plan = [];
747
+ goal = goalLineFor(spiderId, { subject: carriedFlyId }, false, "spider-carrying-delivered");
748
+ } else if (isCarrying) {
749
+ const path = planSpiderPathToWeb(spiderCell, applyActions, state, k, config.webDurationTurns);
750
+ if (path && path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
751
+ else { nextCell = greedySpiderApproach(spiderCell, WEB_HOME, applyActions); plan = stepPlan(spiderCell, nextCell); }
752
+ goal = goalLineFor(spiderId, { subject: carriedFlyId }, false, "spider-carrying");
617
753
  } else {
618
- // Priority 2: chase a believed-visible fly, exactly as before.
619
- const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius, toldFacts });
620
- if (target) {
621
- nextCell = spiderCell;
622
- const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k, config.webDurationTurns);
623
- if (path) {
624
- if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
625
- } else {
626
- nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
627
- }
628
- // "Arrived" is the real eat precondition (co-located with the
629
- // believed target, inside an active web) — NOT merely "didn't move
630
- // this turn", which a greedy-approach spider also does whenever it's
631
- // already at its closest reachable cell but still a step away
632
- // (Chebyshev-adjacent isn't co-located; has-exit-* edges have no
633
- // diagonal hop).
634
- const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y && hasActiveWebAt(nextCell.x, nextCell.y, state, k, config.webDurationTurns);
635
- goal = goalLineFor(spiderId, target, arrived, "spider");
754
+ // Priority 1: avoid any OTHER live spider believed visible.
755
+ const avoidTarget = nearestBelievedTarget(spiderId, spiderCell, otherSpiders, state, { visionRadius: config.spiderVisionRadius, toldFacts });
756
+ if (avoidTarget) {
757
+ nextCell = greedySpiderAvoid(spiderCell, avoidTarget.cell, applyActions);
758
+ plan = stepPlan(spiderCell, nextCell);
759
+ goal = goalLineFor(spiderId, avoidTarget, false, "spider-avoid");
636
760
  } else {
637
- // Priority 3: hold position, and build/refresh a web there unless an
638
- // unexpired web already covers this exact cell.
639
- nextCell = spiderCell;
640
- const heldCellId = cellId(spiderCell.x, spiderCell.y);
641
- if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns)) {
642
- const webId = `web-${nextWebNum}`;
643
- nextWebNum += 1;
644
- tickWebs.set(webId, { cell: heldCellId, builtAtTurn: k });
645
- movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:currently-in", object: heldCellId });
646
- movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:web-built-at-turn", object: String(k) });
647
- goal = "no fly in sight — building a web here.";
761
+ // Priority 2: chase a believed-visible fly, exactly as before.
762
+ const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius: config.spiderVisionRadius, toldFacts });
763
+ if (target) {
764
+ nextCell = spiderCell;
765
+ const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k, config.webDurationTurns);
766
+ if (path) {
767
+ if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
768
+ else plan = [];
769
+ } else {
770
+ nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
771
+ plan = stepPlan(spiderCell, nextCell);
772
+ }
773
+ // "Arrived" is the real catch precondition (co-located with the
774
+ // believed target — a catch never needs a web, only the SEPARATE
775
+ // eat step that follows once it's carried into one) — NOT merely
776
+ // "didn't move this turn", which a greedy-approach spider also
777
+ // does whenever it's already at its closest reachable cell but
778
+ // still a step away (Chebyshev-adjacent isn't co-located;
779
+ // has-exit-* edges have no diagonal hop).
780
+ const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y;
781
+ goal = goalLineFor(spiderId, target, arrived, "spider");
648
782
  } else {
649
- goal = goalLineFor(spiderId, null, false, "spider");
783
+ // Priority 3: hold position, and build/refresh a web there unless an
784
+ // unexpired web already covers this exact cell.
785
+ nextCell = spiderCell;
786
+ plan = [];
787
+ const heldCellId = cellId(spiderCell.x, spiderCell.y);
788
+ if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns)) {
789
+ const webId = `web-${nextWebNum}`;
790
+ nextWebNum += 1;
791
+ tickWebs.set(webId, { cell: heldCellId, builtAtTurn: k });
792
+ movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:currently-in", object: heldCellId });
793
+ movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:web-built-at-turn", object: String(k) });
794
+ goal = "no fly in sight — building a web here.";
795
+ } else {
796
+ goal = goalLineFor(spiderId, null, false, "spider");
797
+ }
650
798
  }
651
799
  }
652
800
  }
@@ -654,22 +802,54 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
654
802
  postMovePlacements.set(spiderId, nextCell);
655
803
  movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
656
804
  movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
657
- agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal, plan, mass: newMass };
805
+ agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal, plan, mass: newMass, belief };
806
+ }
807
+
808
+ // A fly currently carried by a still-live spider (state.carrying, keyed by
809
+ // captor) rides that captor's own just-chosen cell instead of scoring its
810
+ // own move — fully inert, but its mass still decrements and it can still
811
+ // starve mid-transit below (an intended emergent failure mode). A captor
812
+ // that died since being folded self-heals the fly to independent movement
813
+ // for free: it's simply absent from this map (state.removed.has(spiderId)
814
+ // was never true for a captor still in `spiders`, so only a captor gone
815
+ // BEFORE this tick's fold — already excluded from `spiders` — is missing).
816
+ const captorOfFly = new Map();
817
+ for (const [spiderId, { flyId }] of state.carrying) {
818
+ if (spiders.includes(spiderId) && flies.includes(flyId)) captorOfFly.set(flyId, spiderId);
658
819
  }
659
820
 
660
821
  for (const flyId of flies) {
661
822
  const flyCell = parseCellId(state.placements.get(flyId).cell);
662
- const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius, toldFacts });
663
- const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k, config.webDurationTurns);
664
- const nextCell = webbed ? flyCell : greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions, k, flyId);
823
+ const captorId = captorOfFly.get(flyId);
824
+ let nextCell;
825
+ let plan;
826
+ let goal;
827
+ if (captorId) {
828
+ nextCell = postMovePlacements.get(captorId);
829
+ plan = [];
830
+ goal = `being carried by ${captorId}.`;
831
+ } else {
832
+ const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius: config.flyVisionRadius, toldFacts });
833
+ const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k, config.webDurationTurns);
834
+ if (webbed) {
835
+ nextCell = flyCell;
836
+ plan = [];
837
+ goal = "trapped in an active web — can't move.";
838
+ } else {
839
+ nextCell = greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions, k, flyId);
840
+ plan = stepPlan(flyCell, nextCell);
841
+ goal = goalLineFor(flyId, believedSpider, true, "fly");
842
+ }
843
+ }
844
+ const belief = beliefSnapshotFor(flyId, captorId ? nextCell : flyCell, [...spiders, ...flies.filter((id) => id !== flyId)], state, { visionRadius: config.flyVisionRadius, toldFacts });
845
+
665
846
  postMovePlacements.set(flyId, nextCell);
666
847
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
667
848
  const priorMass = state.mass.get(flyId)?.value ?? config.flyInitialMass;
668
849
  const newMass = Math.max(0, priorMass - config.flyMassDecrementPerTurn);
669
850
  postMoveMassByFly.set(flyId, newMass);
670
851
  movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
671
- const goal = webbed ? "trapped in an active web — can't move." : goalLineFor(flyId, believedSpider, true, "fly");
672
- agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal, mass: newMass };
852
+ agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal, plan, mass: newMass, belief };
673
853
  }
674
854
 
675
855
  const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider, turn: k, config });
@@ -698,7 +878,7 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
698
878
  // eaten" and still list fly-5's stale "trapped, can't move" goal one clause
699
879
  // later, as if it were still on the board. Drop it from `agents` (its own
700
880
  // goal is moot) and let the eating spider's line say what actually
701
- // happened instead of the now-false "co-located with fly-5 in the web".
881
+ // happened instead of the now-false "co-located with fly-5 catching it".
702
882
  // A spider can eat more than one fly in the same tick (several flies
703
883
  // co-located with it on the same cell) — group by spider first, rather
704
884
  // than overwriting the goal once per eaten fly, which silently credited
@@ -718,17 +898,31 @@ export async function runSpiderFlyTick(memoryDir, opts = {}) {
718
898
  }
719
899
  }
720
900
  for (const flyId of ecology.events.starved) delete agents[flyId];
901
+ // A fresh catch that wasn't ALSO delivered+eaten this same tick (the
902
+ // "just ate" override above already wins for that case) leaves both the
903
+ // spider's and the fly's pre-ecology goal stale (assigned during movement,
904
+ // before the catch resolved) — the spider's own chase/hold text and the
905
+ // fly's own evade/wander text neither one mentions the catch.
906
+ for (const { spider, fly } of ecology.events.caught) {
907
+ if (eatenBySpider.has(spider)) continue;
908
+ if (agents[spider]) agents[spider].goal = goalLineFor(spider, { subject: fly }, false, "spider-carrying");
909
+ if (agents[fly]) agents[fly].goal = `just caught by ${spider} — being carried.`;
910
+ }
721
911
  // A hatched spider or a spawned fly is minted by the ecology pass, which
722
912
  // runs AFTER the movement loops above already built `agents` from the
723
913
  // pre-tick roster — so without this, a brand-new individual is absent from
724
914
  // this tick's own returned agents (and so invisible on the board/HUD) even
725
915
  // though the very same tick's event text already announces it, only
726
- // catching up the following tick once the fold picks it up naturally.
916
+ // catching up the following tick once the fold picks it up naturally. One
917
+ // egg can hatch into more than one spider — every hatchling gets its own
918
+ // agents[] entry, sharing the egg's own cell.
727
919
  for (const h of ecology.events.hatched) {
728
- agents[h.spider] = { cell: h.cell, goal: "just hatched — no goal yet.", plan: null, mass: config.spiderInitialMass };
920
+ for (const { spider, mass } of h.spiders) {
921
+ agents[spider] = { cell: h.cell, goal: "just hatched — no goal yet.", plan: [], mass, belief: {} };
922
+ }
729
923
  }
730
924
  if (ecology.events.spawned && ecology.events.spawnedCell) {
731
- agents[ecology.events.spawned] = { cell: ecology.events.spawnedCell, goal: "just arrived — no goal yet.", mass: config.flyInitialMass };
925
+ agents[ecology.events.spawned] = { cell: ecology.events.spawnedCell, goal: "just arrived — no goal yet.", plan: [], mass: config.flyInitialMass, belief: {} };
732
926
  }
733
927
  const writes = [...movementWrites, ...ecology.writes];
734
928
  const provenance = `${worldProvenanceTag(WORLD_NAME)}:turn${k}`;