@polycode-projects/the-mechanical-code-talker 5.0.2 → 5.0.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.
Files changed (37) hide show
  1. package/corpus/worlds/manifest.json +9 -9
  2. package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
  3. package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
  4. package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
  5. package/corpus/worlds/src/town-square-chapel.jsonl +1 -1
  6. package/corpus/worlds/src/town-square-market.jsonl +1 -1
  7. package/corpus/worlds/src/town-square.jsonl +1 -1
  8. package/data/mudiii-assets.json +35 -5
  9. package/package.json +1 -1
  10. package/src/adapters/memory/core.mjs +236 -18
  11. package/src/domain/ask-vocab.mjs +1 -1
  12. package/src/domain/ask.mjs +20 -10
  13. package/src/domain/game-config.mjs +10 -1
  14. package/src/domain/interpret/normalize.mjs +4 -0
  15. package/src/domain/memory/retraction.mjs +232 -0
  16. package/src/domain/p2p/sync-filter.mjs +9 -1
  17. package/src/domain/spider-fly-world.mjs +80 -35
  18. package/src/domain/syllogise.mjs +128 -75
  19. package/src/services/adventure-autoplay.mjs +2 -2
  20. package/src/services/adventure-viz.mjs +12 -7
  21. package/src/services/chat.mjs +42 -14
  22. package/src/services/mud-turn.mjs +1 -1
  23. package/src/services/mud-viz.mjs +11 -2
  24. package/src/services/mudiii-scene.mjs +199 -55
  25. package/src/services/mudiii-turn.mjs +82 -1
  26. package/src/services/mudiii-viz.mjs +17 -7
  27. package/src/services/p2p-room.mjs +130 -9
  28. package/src/services/predator-prey.mjs +524 -69
  29. package/src/services/spider-fly-turn.mjs +128 -56
  30. package/src/services/spider-fly-viz.mjs +65 -71
  31. package/src/services/world-teach.mjs +3 -3
  32. package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
  33. package/src/surfaces/web/memory-ask-browser.bundle.js +118 -118
  34. package/src/surfaces/web/mud-browser-entry.mjs +10 -3
  35. package/src/surfaces/web/mudiii-browser-entry.mjs +3 -3
  36. package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
  37. package/src/services/spider-fly.mjs +0 -943
@@ -1,34 +1,32 @@
1
- // predator-prey.mjs — the headless town-square turn engine: an (epoch, turn)
1
+ // predator-prey.mjs — the headless predator/prey turn engine: an (epoch, turn)
2
2
  // state fold, vision-gated belief over agents AND inert food, the two decision
3
3
  // chains, and one fixed-order ecology pass, all reading and writing plain fact
4
4
  // rows through the shared memory store. No chat, no rendering.
5
5
  //
6
- // Role-parameterized from the first line. Nothing below names a fox or a
7
- // goblin: the cast arrives as MUDIII_ROLES, the numbers arrive role-keyed from
8
- // DEFAULT_GAME_CONFIG.mudiii, and the board arrives as a layout. A later cast
9
- // is a new roles object plus new model keys, with no edit here.
6
+ // Role-parameterized from the first line. Nothing below names a fox, a goblin,
7
+ // a spider or a fly: the cast arrives as a roles object, the numbers arrive
8
+ // role-keyed in `config`, and the board arrives as a layout. A new cast is a
9
+ // new roles object plus new model keys, with no edit here. Two casts run on it
10
+ // today — the town square (fox and goblin) and the spider-and-fly board.
10
11
  //
11
12
  // Grid geometry, the prop tables and what "solid" means all come from
12
13
  // domain/town-square-world.mjs. Belief comes from domain/agent-belief.mjs,
13
14
  // which is board-size agnostic and models vision as plain Chebyshev distance
14
15
  // with no line of sight — a building blocks movement, never sight.
15
16
  //
16
- // Two things this file deliberately does NOT borrow from spider-fly.mjs, its
17
- // nearest sibling:
17
+ // Three mechanics are OPT-IN, off unless `config` asks for them by name, so a
18
+ // cast that does not want them sees the same board it always did:
18
19
  //
19
- // - Its snapshot regex. `/^(.+)@turn(\d+)$/` is greedy, so it reads
20
- // `goblin-1@epoch2@turn3`'s base as `goblin-1@epoch2` an id no roster
21
- // matches, which drops every post-recast fact out of the fold silently.
22
- // parseSnapshotSubject/snapshotSubject from adventure.mjs are lazy and
23
- // read both forms, and are imported here rather than re-derived.
24
- // - Its `mgx:mass` predicate. Mass is rewritten every turn for every agent,
25
- // and mgx:mass is unlisted in the memory resolution table, so it falls to
26
- // the contradiction policy. mgx:hasMass is the listed one.
27
- //
28
- // gridApplyActions is written out below rather than imported from
29
- // spider-fly.mjs on purpose: spider-fly is the pattern this engine mirrors,
30
- // not a library underneath it, and the design has spider-fly migrating onto
31
- // this engine later — an import in this direction would become a cycle then.
20
+ // - `carryPreyToWeb` splits catching from eating. A predator that shares a
21
+ // cell with prey claims it (mgx:carrying) and only eats standing in a web.
22
+ // Off, a predator eats where it catches, in one step.
23
+ // - `buildWebs` gives the predator's last rung a job — hold and spin a web
24
+ // (web-N + mgx:web-built-at-turn) instead of wandering and traps prey
25
+ // that ends a move inside a live one. A layout may also declare an
26
+ // always-on web block through `staticWebAt`.
27
+ // - `layEggs` adds the reproduction stage: an egg (mgx:laid-at-turn) laid in
28
+ // a web once mass crosses a threshold, hatching (mgx:hatched-into) into
29
+ // hatchlings that split the egg's mass.
32
30
 
33
31
  import {
34
32
  DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
@@ -44,7 +42,7 @@ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
44
42
  import { mulberry32 } from "../domain/seeded-random.mjs";
45
43
  import { fnv1a32 } from "../domain/hash.mjs";
46
44
  import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
47
- import { objectClassChain, parseSnapshotSubject, snapshotSubject, WORLD_EPOCH_PREDICATE } from "./adventure.mjs";
45
+ import { objectClassChain, parseSnapshotSubject, snapshotSubject, worldEpochFact, WORLD_EPOCH_PREDICATE } from "./adventure.mjs";
48
46
 
49
47
  export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
50
48
 
@@ -67,6 +65,18 @@ const MODEL_PREDICATE = "mgx:model";
67
65
  const ROTATION_PREDICATE = "mgx:rotation";
68
66
  const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
69
67
 
68
+ // The four predicates only an opt-in mechanic ever writes. They are listed
69
+ // here, and folded below, whatever the cast: a fold that skipped them would
70
+ // have to know which features its caller turned on, and the rows simply never
71
+ // appear on a board that never writes them.
72
+ const CARRYING_PREDICATE = "mgx:carrying";
73
+ const PREY_EATEN_PREDICATE = "mgx:prey-eaten";
74
+ const WEB_BUILT_PREDICATE = "mgx:web-built-at-turn";
75
+ const LAID_AT_TURN_PREDICATE = "mgx:laid-at-turn";
76
+ const HATCHED_INTO_PREDICATE = "mgx:hatched-into";
77
+
78
+ const EGG_KIND = "egg";
79
+
70
80
  // One row per tick, whatever else the turn did, on a subject the board itself
71
81
  // owns. It is what the next tick's turn number counts, and it exists as its own
72
82
  // predicate rather than being read off the mood rows because a board with
@@ -79,6 +89,8 @@ const MUDIII_STATE_PREDICATE_SET = new Set([
79
89
  PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
80
90
  EATEN_BY_PREDICATE, STARVED_PREDICATE, PLACED_BY_PREDICATE,
81
91
  MODEL_PREDICATE, ROTATION_PREDICATE, TURN_PLAYED_PREDICATE,
92
+ CARRYING_PREDICATE, PREY_EATEN_PREDICATE, WEB_BUILT_PREDICATE,
93
+ LAID_AT_TURN_PREDICATE, HATCHED_INTO_PREDICATE,
82
94
  "rdf:type", WORLD_EPOCH_PREDICATE,
83
95
  ]);
84
96
 
@@ -109,6 +121,14 @@ function seededPick(options, contextString) {
109
121
  return options[Math.floor(rng() * options.length)];
110
122
  }
111
123
 
124
+ /** One placement cell drawn on this engine's own seed scheme, for a cast layer
125
+ * minting a starting roster of its own rather than taking seededRoster's.
126
+ * Exported so a hand-built roster and an engine spawn draw from the same
127
+ * keyspace instead of a second, silently different one. */
128
+ export function seededSpawnCell(options, { layoutName, epoch = 0, turn = 0, id }) {
129
+ return seededPick(options, seedKey(layoutName, epoch, turn, id, "spawn"));
130
+ }
131
+
112
132
  // ---- the (epoch, turn) fold ---------------------------------------------------
113
133
 
114
134
  /** Fold fact rows into the current town-square state.
@@ -151,6 +171,12 @@ export function foldTownSquareState(factRows) {
151
171
  const types = new Map(); // subject -> class named by its bare rdf:type row
152
172
  const models = new Map(); // prop subject -> asset key
153
173
  const starved = new Set();
174
+ const carrying = new Map(); // predator -> { prey, turn, epoch }
175
+ const carryingRank = new Map(); // predator -> the (epoch, turn) its newest carrying row carries
176
+ const preyEaten = new Map(); // predator -> { value, turn, epoch }
177
+ const laidAtTurn = new Map(); // egg -> { value, turn, epoch }
178
+ const webBuiltAt = new Map(); // web -> { value, turn, epoch }
179
+ const hatchedInto = new Map();// egg -> { into, turn, epoch }
154
180
  let turnCount = 0;
155
181
  let tickCount = 0;
156
182
 
@@ -193,6 +219,36 @@ export function foldTownSquareState(factRows) {
193
219
  case STARVED_PREDICATE:
194
220
  if (rowEpoch === epoch) starved.add(base);
195
221
  break;
222
+ // A predator re-asserts what it holds every turn, and "none" is a real
223
+ // answer. The rank is tracked apart from the value so a dropped catch
224
+ // cannot be undone by an older row arriving later in the array.
225
+ case CARRYING_PREDICATE:
226
+ if (!outranks(rowEpoch, turn, carryingRank.get(base))) break;
227
+ carryingRank.set(base, { turn, epoch: rowEpoch });
228
+ if (row.object === "none") carrying.delete(base);
229
+ else carrying.set(base, { prey: row.object, turn, epoch: rowEpoch });
230
+ break;
231
+ case PREY_EATEN_PREDICATE: {
232
+ const value = Number(row.object);
233
+ if (!Number.isFinite(value)) break;
234
+ if (outranks(rowEpoch, turn, preyEaten.get(base))) preyEaten.set(base, { value, turn, epoch: rowEpoch });
235
+ break;
236
+ }
237
+ case LAID_AT_TURN_PREDICATE: {
238
+ const value = Number(row.object);
239
+ if (!Number.isInteger(value)) break;
240
+ if (outranks(rowEpoch, turn, laidAtTurn.get(base))) laidAtTurn.set(base, { value, turn, epoch: rowEpoch });
241
+ break;
242
+ }
243
+ case WEB_BUILT_PREDICATE: {
244
+ const value = Number(row.object);
245
+ if (!Number.isInteger(value)) break;
246
+ if (outranks(rowEpoch, turn, webBuiltAt.get(base))) webBuiltAt.set(base, { value, turn, epoch: rowEpoch });
247
+ break;
248
+ }
249
+ case HATCHED_INTO_PREDICATE:
250
+ if (outranks(rowEpoch, turn, hatchedInto.get(base))) hatchedInto.set(base, { into: row.object, turn, epoch: rowEpoch });
251
+ break;
196
252
  case MODEL_PREDICATE:
197
253
  models.set(base, row.object);
198
254
  break;
@@ -211,8 +267,24 @@ export function foldTownSquareState(factRows) {
211
267
  for (const [subject, { epoch: rowEpoch }] of eatenBy) {
212
268
  if (rowEpoch === epoch) removed.add(subject);
213
269
  }
270
+ for (const [subject, { epoch: rowEpoch }] of hatchedInto) {
271
+ if (rowEpoch === epoch) removed.add(subject);
272
+ }
214
273
 
215
- return { placements, mass, mood, facing, eatenBy, placedBy, types, models, starved, removed, turnCount, tickCount, epoch };
274
+ // A web is a placed individual plus the turn it was spun. Both halves are
275
+ // needed, and only this epoch's webs stand: a recast leaves the board bare.
276
+ const webs = new Map(); // web subject -> { cell, builtAtTurn }
277
+ for (const [id, built] of webBuiltAt) {
278
+ if (built.epoch !== epoch) continue;
279
+ const place = placements.get(id);
280
+ if (place) webs.set(id, { cell: place.cell, builtAtTurn: built.value });
281
+ }
282
+
283
+ return {
284
+ placements, mass, mood, facing, eatenBy, placedBy, types, models, starved,
285
+ carrying, preyEaten, laidAtTurn, hatchedInto, webs,
286
+ removed, turnCount, tickCount, epoch,
287
+ };
216
288
  }
217
289
 
218
290
  /** Every live subject whose id names `kind`, sorted. */
@@ -268,26 +340,41 @@ export const pathStateKey = (searchState) => cellId(searchState.x, searchState.y
268
340
  const oneStepOptions = (fromCell, applyActions) =>
269
341
  [fromCell, ...findReachableSet(fromCell, applyActions, { maxDepth: 1, stateKey: pathStateKey }).map((r) => r.node)];
270
342
 
271
- function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
343
+ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter, tieBreakScoreOf = null) {
272
344
  const options = oneStepOptions(fromCell, applyActions);
273
345
  let best = options[0];
274
346
  let bestScore = scoreOf(best);
347
+ let bestTieScore = tieBreakScoreOf ? tieBreakScoreOf(best) : null;
275
348
  for (let i = 1; i < options.length; i += 1) {
276
349
  const score = scoreOf(options[i]);
277
- if (isBetter(score, bestScore)) { bestScore = score; best = options[i]; }
350
+ if (isBetter(score, bestScore)) {
351
+ bestScore = score; best = options[i];
352
+ bestTieScore = tieBreakScoreOf ? tieBreakScoreOf(options[i]) : null;
353
+ } else if (tieBreakScoreOf && score === bestScore) {
354
+ const tieScore = tieBreakScoreOf(options[i]);
355
+ if (tieScore < bestTieScore) { bestTieScore = tieScore; best = options[i]; }
356
+ }
278
357
  }
279
358
  return best;
280
359
  }
281
360
 
282
361
  /** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
283
362
  * terms from `awayFrom`. Both the prey's evade rung and the predator's avoid
284
- * rung are this function. */
285
- export function greedyAway(fromCell, awayFrom, applyActions) {
363
+ * rung are this function.
364
+ *
365
+ * `opts.towardCell`, when given, breaks a tie among equally-safe cells in
366
+ * favor of whichever is closest to it — a fleeing prey that knows where food
367
+ * is should flee toward it, not toward whichever direction DIRECTION_DELTA's
368
+ * key order happens to check first. Opt-in and null by default, so a caller
369
+ * that never passes it (the predator's own avoid rung) sees no change at
370
+ * all: same options, same scores, same first-wins tie order. */
371
+ export function greedyAway(fromCell, awayFrom, applyActions, { towardCell = null } = {}) {
286
372
  if (!awayFrom) return fromCell;
287
373
  return bestOneStepBy(
288
374
  fromCell, applyActions,
289
375
  (cell) => chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y),
290
376
  (score, bestScore) => score > bestScore,
377
+ towardCell ? (cell) => chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y) : null,
291
378
  );
292
379
  }
293
380
 
@@ -309,6 +396,49 @@ export function seededWander(fromCell, applyActions, { layoutName, epoch, turn,
309
396
  return seededPick(oneStepOptions(fromCell, applyActions), seedKey(layoutName, epoch, turn, id, "wander"));
310
397
  }
311
398
 
399
+ // ---- webs, when a cast asks for them ---------------------------------------------
400
+ // One predicate answers "is this cell webbed right now", and both the eat gate
401
+ // and the prey's movement gate read it, so a layout's always-on block and a
402
+ // predator-spun web are ONE concept rather than two code paths.
403
+
404
+ /** Whether (x, y) is webbed on `turn`: inside the layout's own always-on block
405
+ * (`layout.staticWebAt`, absent on a layout with no such block), or covered by
406
+ * a predator-built web still inside `webDurationTurns` of the turn it was
407
+ * spun. False for a board that has neither. Pure. */
408
+ export function hasActiveWebAt(layout, state, turn, x, y, webDurationTurns) {
409
+ if (layout?.staticWebAt?.(x, y)) return true;
410
+ if (!state?.webs?.size || !webDurationTurns) return false;
411
+ const target = cellId(x, y);
412
+ for (const { cell, builtAtTurn } of state.webs.values()) {
413
+ if (cell === target && builtAtTurn + webDurationTurns > turn) return true;
414
+ }
415
+ return false;
416
+ }
417
+
418
+ /** Every unexpired predator-built web in `websMap` (a folded state's own
419
+ * `.webs`, or that widened with one spun THIS tick before it has been read
420
+ * back), as `{ id, cell, builtAtTurn, expiresAtTurn }` — a renderer draws
421
+ * these apart from the layout's static block, which is fixed geometry rather
422
+ * than runtime state. Pure. */
423
+ export function liveWebs(websMap, turn, webDurationTurns) {
424
+ const out = [];
425
+ for (const [id, { cell, builtAtTurn }] of websMap || []) {
426
+ if (builtAtTurn + webDurationTurns > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + webDurationTurns });
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /** A carrying predator's own path home: the shortest route to ANY webbed cell,
432
+ * rather than to one named target. Null when no web is reachable at all. */
433
+ function pathToAnyWeb(fromCell, applyActions, { layout, state, turn, webDurationTurns }) {
434
+ return findActionPath(
435
+ fromCell,
436
+ (s) => hasActiveWebAt(layout, state, turn, s.x, s.y, webDurationTurns),
437
+ applyActions,
438
+ { stateKey: pathStateKey },
439
+ );
440
+ }
441
+
312
442
  /** A one-step "plan" for a greedy or held move — the direction as a length-1
313
443
  * array, or `[]` when the agent held still. A genuine multi-step search result
314
444
  * supplies its own full direction list instead. Either way plan[0] is the
@@ -327,13 +457,21 @@ const round2 = (n) => Math.round(n * 100) / 100;
327
457
  // is scared, anything wandering or foraging is calm, and anything that just ate
328
458
  // is happy.
329
459
 
330
- function goalLine(kind, { subject, cell, arrived } = {}) {
460
+ function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false } = {}) {
331
461
  switch (kind) {
332
462
  case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
333
- case "chase": return arrived ? `standing over ${subject} — taking it.` : `chasing ${subject}, last seen at ${cell}.`;
463
+ case "chase":
464
+ if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
465
+ return catches ? `co-located with ${subject} — catching it.` : `standing over ${subject} — taking it.`;
334
466
  case "evade": return `evading — last saw ${subject} at ${cell}.`;
335
467
  case "forage": return arrived ? `standing on ${subject} — eating it.` : `foraging — heading for ${subject} at ${cell}.`;
336
- default: return "nothing in sight — wandering the square.";
468
+ case "carry": return `carrying ${subject} toward the web.`;
469
+ case "deliver": return `carrying ${subject} — already in the web, delivering it now.`;
470
+ case "carried": return `being carried by ${subject}.`;
471
+ case "trapped": return "trapped in an active web — can't move.";
472
+ case "hold-web": return "nothing in sight — holding position in the web.";
473
+ case "build-web": return "nothing in sight — building a web here.";
474
+ default: return `nothing in sight — wandering the ${boardNoun}.`;
337
475
  }
338
476
  }
339
477
 
@@ -343,20 +481,30 @@ function goalLine(kind, { subject, cell, arrived } = {}) {
343
481
  // 1. eat-agent — a predator sharing a cell with prey takes it, and gains the
344
482
  // prey's remaining mass. Catch and eat are one step: a predator needs no
345
483
  // apparatus, so nothing is ever carried and no state survives the turn.
484
+ // Under `carryPreyToWeb` this splits in two — catch-prey claims the prey
485
+ // wherever they meet, and the eat waits until the captor is standing in a
486
+ // web. At most one catch per predator per turn.
346
487
  // 2. eat-item — prey standing on food eat it. AFTER eat-agent, so a prey
347
488
  // taken this turn is never also credited with a crumb. A prey on two
348
489
  // crumbs eats both.
349
490
  // 3. starve — mass reached zero, and nothing this turn already claimed it.
350
- // Whatever ate this turn survives it, because eating resolved first.
351
- // 4. spawn-prey arrivals wander in from the edge, so the pick is the
491
+ // Whatever ate this turn survives it, because eating resolved first. A
492
+ // carried prey starves like a free one, and its captor's claim drops.
493
+ // 4. lay-egg — `layEggs` only. A predator standing in a web whose mass has
494
+ // reached the threshold lays one egg and resets to its own starting mass;
495
+ // the surplus becomes the egg's. One live egg at a time.
496
+ // 5. hatch-egg — `layEggs` only. An egg laid exactly eggHatchDelayTurns ago
497
+ // splits its mass across its hatchlings, fewer of them rather than
498
+ // emaciated ones when the egg is small.
499
+ // 6. spawn-prey — arrivals wander in from the edge, so the pick is the
352
500
  // perimeter minus prop cells. That subtraction is not a nicety: the
353
501
  // headline layout has buildings sitting on two whole edges.
354
- // 5. spawn-food — dropped bread lands anywhere open, so the pick is every
502
+ // 7. spawn-food — dropped bread lands anywhere open, so the pick is every
355
503
  // open cell minus whatever stands or lies on one. Last, so it reads the
356
504
  // board as every earlier step left it.
357
505
  //
358
506
  // Both spawns check their cap first, so a full board simply skips that turn's
359
- // arrival.
507
+ // arrival, and a cast that sets no interval never spawns that kind at all.
360
508
 
361
509
  function runEcologyPass({
362
510
  state, layout, roles, config, turn, epoch,
@@ -366,6 +514,9 @@ function runEcologyPass({
366
514
  const writes = [];
367
515
  const events = [];
368
516
  const stamp = (base) => snapshotSubject(base, k, epoch);
517
+ const carriesPrey = config.carryPreyToWeb === true;
518
+ const laysEggs = config.layEggs === true;
519
+ const webbedAt = (c) => hasActiveWebAt(layout, state, k, c.x, c.y, config.webDurationTurns);
369
520
 
370
521
  const predators = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "predator").sort();
371
522
  const prey = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "prey").sort();
@@ -373,19 +524,60 @@ function runEcologyPass({
373
524
  const takenAgents = new Set();
374
525
  const takenItems = new Set();
375
526
 
376
- // 1. eat-agent
377
- for (const predatorId of predators) {
378
- const pc = postMovePlacements.get(predatorId);
379
- for (const preyId of prey) {
380
- if (takenAgents.has(preyId)) continue;
381
- const qc = postMovePlacements.get(preyId);
382
- if (pc.x !== qc.x || pc.y !== qc.y) continue;
527
+ // 1. eat-agent, or catch-then-eat for a cast that carries.
528
+ const carriedBy = new Map(); // predator -> prey it holds at the end of this turn
529
+ if (!carriesPrey) {
530
+ for (const predatorId of predators) {
531
+ const pc = postMovePlacements.get(predatorId);
532
+ for (const preyId of prey) {
533
+ if (takenAgents.has(preyId)) continue;
534
+ const qc = postMovePlacements.get(preyId);
535
+ if (pc.x !== qc.x || pc.y !== qc.y) continue;
536
+ takenAgents.add(preyId);
537
+ const gained = finalMass.get(preyId) ?? 0;
538
+ finalMass.set(predatorId, (finalMass.get(predatorId) ?? 0) + gained);
539
+ writes.push({ subject: stamp(preyId), predicate: EATEN_BY_PREDICATE, object: predatorId });
540
+ events.push({ type: "eat-agent", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y), massGained: round2(gained) });
541
+ }
542
+ }
543
+ } else {
544
+ // Whatever each predator was already holding before this tick, dropped for
545
+ // a captor or a prey that has since left the board.
546
+ for (const [predatorId, { prey: preyId }] of state.carrying) {
547
+ if (postMovePlacements.has(predatorId) && postMovePlacements.has(preyId)) carriedBy.set(predatorId, preyId);
548
+ }
549
+ const claimed = new Set(carriedBy.values());
550
+ for (const predatorId of predators) {
551
+ if (carriedBy.has(predatorId)) continue;
552
+ const pc = postMovePlacements.get(predatorId);
553
+ for (const preyId of prey) {
554
+ if (claimed.has(preyId)) continue;
555
+ const qc = postMovePlacements.get(preyId);
556
+ if (pc.x !== qc.x || pc.y !== qc.y) continue;
557
+ carriedBy.set(predatorId, preyId);
558
+ claimed.add(preyId);
559
+ events.push({ type: "catch-prey", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y) });
560
+ break;
561
+ }
562
+ }
563
+ const eatenDelta = new Map();
564
+ for (const predatorId of predators) {
565
+ const preyId = carriedBy.get(predatorId);
566
+ if (!preyId) continue;
567
+ const pc = postMovePlacements.get(predatorId);
568
+ if (!webbedAt(pc)) continue;
569
+ carriedBy.delete(predatorId);
383
570
  takenAgents.add(preyId);
384
571
  const gained = finalMass.get(preyId) ?? 0;
385
572
  finalMass.set(predatorId, (finalMass.get(predatorId) ?? 0) + gained);
573
+ eatenDelta.set(predatorId, (eatenDelta.get(predatorId) ?? 0) + 1);
386
574
  writes.push({ subject: stamp(preyId), predicate: EATEN_BY_PREDICATE, object: predatorId });
387
575
  events.push({ type: "eat-agent", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y), massGained: round2(gained) });
388
576
  }
577
+ for (const [predatorId, delta] of eatenDelta) {
578
+ const count = (state.preyEaten.get(predatorId)?.value ?? 0) + delta;
579
+ writes.push({ subject: stamp(predatorId), predicate: PREY_EATEN_PREDICATE, object: String(count) });
580
+ }
389
581
  }
390
582
 
391
583
  // 2. eat-item
@@ -417,22 +609,93 @@ function runEcologyPass({
417
609
  }
418
610
 
419
611
  const goneThisTurn = new Set([...takenAgents, ...starvedThisTurn]);
612
+ const spawned = [];
613
+
614
+ // A predator re-asserts what it holds every turn, whether or not that
615
+ // changed: a write that merely stopped would leave a stale claim standing
616
+ // for the fold to keep honoring. A prey that starved mid-carry is dropped.
617
+ if (carriesPrey) {
618
+ for (const [predatorId, preyId] of carriedBy) {
619
+ if (starvedThisTurn.has(preyId)) carriedBy.delete(predatorId);
620
+ }
621
+ for (const predatorId of predators) {
622
+ writes.push({ subject: stamp(predatorId), predicate: CARRYING_PREDICATE, object: carriedBy.get(predatorId) ?? "none" });
623
+ }
624
+ }
625
+
626
+ // 4. lay-egg
627
+ const liveEggIds = laysEggs ? [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort() : [];
628
+ if (laysEggs && !liveEggIds.length) {
629
+ for (const predatorId of predators) {
630
+ if (goneThisTurn.has(predatorId)) continue;
631
+ if (!webbedAt(postMovePlacements.get(predatorId))) continue;
632
+ const predatorMass = finalMass.get(predatorId) ?? 0;
633
+ if (predatorMass < config.eggLayMassThreshold) continue;
634
+ const eggMass = predatorMass - config.predatorInitialMass;
635
+ finalMass.set(predatorId, config.predatorInitialMass);
636
+ const c = postMovePlacements.get(predatorId);
637
+ const eggCell = cellId(c.x, c.y);
638
+ const eggId = `${EGG_KIND}-${1 + maxIdSuffix(state.placements.keys(), EGG_KIND)}`;
639
+ writes.push({ subject: eggId, predicate: "rdf:type", object: EGG_KIND });
640
+ writes.push({ subject: stamp(eggId), predicate: PLACEMENT_PREDICATE, object: eggCell });
641
+ writes.push({ subject: stamp(eggId), predicate: LAID_AT_TURN_PREDICATE, object: String(k) });
642
+ writes.push({ subject: stamp(eggId), predicate: MASS_PREDICATE, object: String(eggMass) });
643
+ events.push({ type: "lay-egg", agent: predatorId, egg: eggId, cell: eggCell, mass: round2(eggMass) });
644
+ break; // one live egg at a time — the first qualifying predator lays it
645
+ }
646
+ }
647
+
648
+ // 5. hatch-egg. Reads the egg slot as it stood BEFORE this tick's own lay, so
649
+ // a hatch and a fresh lay never land on the same turn.
650
+ const hatchedCells = new Set();
651
+ if (laysEggs) {
652
+ let nextHatchlingNum = 1 + maxIdSuffix(state.placements.keys(), roles.predator.kind);
653
+ for (const eggId of liveEggIds) {
654
+ if (state.laidAtTurn.get(eggId).value + config.eggHatchDelayTurns !== k) continue;
655
+ const eggCell = state.placements.get(eggId)?.cell;
656
+ if (!eggCell) continue;
657
+ const eggMass = state.mass.get(eggId)?.value ?? config.predatorInitialMass;
658
+ const hatchCount = Math.max(1, Math.min(config.eggHatchCount, Math.floor(eggMass / config.minHatchlingMass)));
659
+ const share = Math.floor(eggMass / hatchCount);
660
+ const remainder = eggMass - share * hatchCount;
661
+ const hatchlings = [];
662
+ for (let i = 0; i < hatchCount; i += 1) {
663
+ const hatchlingId = `${roles.predator.idPrefix}-${nextHatchlingNum}`;
664
+ nextHatchlingNum += 1;
665
+ const hatchlingMass = share + (i === 0 ? remainder : 0);
666
+ writes.push({ subject: hatchlingId, predicate: "rdf:type", object: roles.predator.kind });
667
+ writes.push({ subject: stamp(hatchlingId), predicate: PLACEMENT_PREDICATE, object: eggCell });
668
+ writes.push({ subject: stamp(hatchlingId), predicate: MASS_PREDICATE, object: String(hatchlingMass) });
669
+ writes.push({ subject: stamp(hatchlingId), predicate: FACING_PREDICATE, object: DEFAULT_FACING });
670
+ hatchlings.push({ id: hatchlingId, mass: round2(hatchlingMass) });
671
+ spawned.push({ id: hatchlingId, role: "predator", cell: eggCell, mass: hatchlingMass, goal: "just hatched — no goal yet." });
672
+ }
673
+ writes.push({ subject: stamp(eggId), predicate: HATCHED_INTO_PREDICATE, object: hatchlings[0].id });
674
+ events.push({ type: "hatch-egg", egg: eggId, cell: eggCell, hatchlings });
675
+ hatchedCells.add(eggCell);
676
+ }
677
+ }
678
+
420
679
  const occupied = new Set();
421
680
  for (const [id, c] of postMovePlacements) {
422
681
  if (goneThisTurn.has(id)) continue;
423
682
  occupied.add(cellId(c.x, c.y));
424
683
  }
684
+ for (const eggId of liveEggIds) {
685
+ const eggCell = state.placements.get(eggId)?.cell;
686
+ if (eggCell) occupied.add(eggCell);
687
+ }
688
+ for (const cell of hatchedCells) occupied.add(cell);
425
689
  const itemCells = new Set();
426
690
  for (const itemId of liveItemIds) {
427
691
  if (takenItems.has(itemId)) continue;
428
692
  itemCells.add(itemCellOf.get(itemId));
429
693
  }
430
694
 
431
- const spawned = [];
432
-
433
- // 4. spawn-prey
695
+ // 6. spawn-prey
434
696
  const livePreyAfter = prey.filter((id) => !goneThisTurn.has(id)).length;
435
- if (k % config.preySpawnIntervalTurns === 0 && livePreyAfter < config.maxPreyPopulation) {
697
+ const preyCap = config.maxPreyPopulation ?? Infinity;
698
+ if (config.preySpawnIntervalTurns && k % config.preySpawnIntervalTurns === 0 && livePreyAfter < preyCap) {
436
699
  const free = perimeterCells(layout).filter((c) => !occupied.has(c));
437
700
  if (free.length) {
438
701
  const preyId = `${roles.prey.idPrefix}-${1 + maxIdSuffix(state.placements.keys(), roles.prey.kind)}`;
@@ -447,9 +710,10 @@ function runEcologyPass({
447
710
  }
448
711
  }
449
712
 
450
- // 5. spawn-food
713
+ // 7. spawn-food
451
714
  const liveFoodAfter = liveItemIds.filter((id) => !takenItems.has(id)).length;
452
- if (k % config.foodSpawnIntervalTurns === 0 && liveFoodAfter < config.maxFoodItems) {
715
+ const foodCap = config.maxFoodItems ?? Infinity;
716
+ if (config.foodSpawnIntervalTurns && k % config.foodSpawnIntervalTurns === 0 && liveFoodAfter < foodCap) {
453
717
  const free = openCells(layout).filter((c) => !occupied.has(c) && !itemCells.has(c));
454
718
  if (free.length) {
455
719
  const itemId = `${roles.food.spawnedKind}-${1 + maxIdSuffix(state.placements.keys(), roles.food.spawnedKind)}`;
@@ -485,8 +749,24 @@ export function roleOfId(id, roles = MUDIII_ROLES) {
485
749
  /**
486
750
  * Seed a fresh town square: one predator/prey roster placed on the board, each
487
751
  * with its role's starting mass, a `calm` mood and a facing. A no-op when the
488
- * first predator already exists, so it is safe to call from a caller unsure
489
- * whether the game has started.
752
+ * given `epoch` already has a roster minted safe to call from a caller
753
+ * unsure whether the game has started, and safe to call again from
754
+ * recastTownSquare below without double-minting the epoch it just opened.
755
+ *
756
+ * The guard checks the EPOCH the existing roster's own placement is stamped
757
+ * to, not merely whether one exists: `state.placements.has(firstPredator)`
758
+ * alone would still be true on a store recastTownSquare just moved onto a new
759
+ * epoch, because it only ever looks at the base id, never at which run
760
+ * planted it. Every roster fact this function writes is stamped through
761
+ * `snapshotSubject(id, 0, epoch)` for exactly that reason — a bare "fox-1"
762
+ * placement fact from one epoch and another from the next would collide as
763
+ * the SAME (subject, predicate) at the memory layer and have to be resolved
764
+ * there (by observation time, falling back to trust and then to the object
765
+ * string) instead of by this file's own epoch-and-turn fold, which is a
766
+ * needless and less predictable path to the same answer. rdf:type stays
767
+ * bare on purpose: a roster id's class does not change on a recast, and
768
+ * foldTownSquareState's own fold only ever takes the first unstamped type row
769
+ * it sees for a subject.
490
770
  *
491
771
  * `opts.agents` places an explicit roster (`{ id: { role, cell, facing, mass } }`)
492
772
  * — how a recorded fixture pins a starting board. Without it the roster is
@@ -500,7 +780,7 @@ export async function startTownSquareGame(memoryDir, {
500
780
  } = {}) {
501
781
  const state = foldTownSquareState(readFactRows(await loadMemory(memoryDir)));
502
782
  const firstPredator = `${roles.predator.idPrefix}-1`;
503
- if (state.placements.has(firstPredator)) return { started: false, facts: [] };
783
+ if (state.placements.get(firstPredator)?.epoch === epoch) return { started: false, facts: [] };
504
784
 
505
785
  const roster = agents ?? seededRoster(layout, {
506
786
  predators: predatorCount ?? layout.cast.predators,
@@ -508,6 +788,7 @@ export async function startTownSquareGame(memoryDir, {
508
788
  roles, epoch,
509
789
  });
510
790
 
791
+ const stamp = (base) => snapshotSubject(base, 0, epoch);
511
792
  const facts = [];
512
793
  for (const id of Object.keys(roster).sort()) {
513
794
  const spec = roster[id];
@@ -515,15 +796,53 @@ export async function startTownSquareGame(memoryDir, {
515
796
  const kind = role === "predator" ? roles.predator.kind : roles.prey.kind;
516
797
  const mass = spec.mass ?? (role === "predator" ? config.predatorInitialMass : config.preyInitialMass);
517
798
  facts.push({ subject: id, predicate: "rdf:type", object: kind });
518
- facts.push({ subject: id, predicate: PLACEMENT_PREDICATE, object: spec.cell });
519
- facts.push({ subject: id, predicate: MASS_PREDICATE, object: String(mass) });
520
- facts.push({ subject: id, predicate: MOOD_PREDICATE, object: "calm" });
521
- facts.push({ subject: id, predicate: FACING_PREDICATE, object: spec.facing ?? DEFAULT_FACING });
799
+ facts.push({ subject: stamp(id), predicate: PLACEMENT_PREDICATE, object: spec.cell });
800
+ facts.push({ subject: stamp(id), predicate: MASS_PREDICATE, object: String(mass) });
801
+ facts.push({ subject: stamp(id), predicate: MOOD_PREDICATE, object: "calm" });
802
+ facts.push({ subject: stamp(id), predicate: FACING_PREDICATE, object: spec.facing ?? DEFAULT_FACING });
522
803
  }
523
804
  await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(layout.name) })));
524
805
  return { started: true, facts };
525
806
  }
526
807
 
808
+ /**
809
+ * Move a LIVE town square onto a new epoch, in place, and re-mint its roster
810
+ * for that epoch — the recast an EDIT-mode change or an epoch bump needs
811
+ * without re-opening the whole session over a brand new store. Two steps:
812
+ * append the epoch marker (worldEpochFact, the same bare triple adventure.mjs
813
+ * writes for its own recast), then call startTownSquareGame for the new
814
+ * epoch.
815
+ *
816
+ * That second call is not redundant with a plain startTownSquareGame call:
817
+ * this only works because startTownSquareGame's own idempotency guard is
818
+ * epoch-aware (see its doc comment) and because its roster facts are
819
+ * epoch-stamped. Without both, the previous epoch's still-placed roster would
820
+ * read as "already minted" for the epoch being opened now, and this function
821
+ * would silently do nothing.
822
+ *
823
+ * `epoch` defaults to one past whatever the store is currently on, so a
824
+ * caller that just wants "recast, fresh epoch" carries no bookkeeping of its
825
+ * own. Throws if given an epoch that would not actually move the store
826
+ * forward — epochs are add-only by construction (the fold takes the max), so
827
+ * a non-increasing one could never become current no matter what it wrote.
828
+ */
829
+ export async function recastTownSquare(memoryDir, {
830
+ layout, epoch = null, agents = null, predatorCount = null, preyCount = null,
831
+ config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
832
+ } = {}) {
833
+ const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
834
+ if (!lay) throw new Error(`recastTownSquare: no such layout "${layout}"`);
835
+ const state = foldTownSquareState(readFactRows(await loadMemory(memoryDir)));
836
+ const nextEpoch = epoch ?? state.epoch + 1;
837
+ if (!Number.isInteger(nextEpoch) || nextEpoch <= state.epoch) {
838
+ throw new Error(`recastTownSquare: epoch must be an integer greater than the current epoch (${state.epoch}), got ${nextEpoch}`);
839
+ }
840
+ await appendFacts(memoryDir, [{ ...worldEpochFact(nextEpoch), provenance: worldProvenanceTag(lay.name) }]);
841
+ return startTownSquareGame(memoryDir, {
842
+ layout: lay, agents, predatorCount, preyCount, config, roles, epoch: nextEpoch,
843
+ });
844
+ }
845
+
527
846
  function seededRoster(layout, { predators, prey, roles, epoch }) {
528
847
  const roster = {};
529
848
  const taken = new Set();
@@ -622,7 +941,11 @@ export async function placeFood(memoryDir, {
622
941
  function readLiveBoard(rows, state, { config, roles }) {
623
942
  const predators = liveOfKind(state, roles.predator.kind);
624
943
  const prey = liveOfKind(state, roles.prey.kind);
625
- const liveItemIds = [...liveOfKind(state, roles.food.spawnedKind), ...liveOfKind(state, roles.food.placedKind)].sort();
944
+ // `roles.food` is null for a cast with nothing inert on its board. Every
945
+ // reader downstream walks the item roster, so an empty one is all it takes.
946
+ const liveItemIds = roles.food
947
+ ? [...liveOfKind(state, roles.food.spawnedKind), ...liveOfKind(state, roles.food.placedKind)].sort()
948
+ : [];
626
949
  return {
627
950
  predators,
628
951
  prey,
@@ -657,13 +980,16 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
657
980
  * to where the predator was when it looked), while eating resolves on the
658
981
  * post-move ones (it is caught where the predator actually ends up).
659
982
  *
660
- * Returns `{ turn, epoch, agents, items, ecology, rungs, writes }`. `agents`
661
- * and `items` and `ecology` are the frozen render payload — see
983
+ * Returns `{ turn, epoch, agents, items, ecology, rungs, activeWebs, writes }`.
984
+ * `agents` and `items` and `ecology` are the frozen render payload — see
662
985
  * townSquareTickPayload, which projects exactly those three plus the turn.
663
986
  * `rungs` is the decision each live agent reached this turn ("chase", "evade",
664
- * "forage", "avoid", "wander"); an agent that decided and then died still has a
665
- * rung and no longer has an `agents` entry, which is the difference between a
666
- * decision and a survivor.
987
+ * "forage", "avoid", "wander", and on a cast that carries or spins webs —
988
+ * "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
989
+ * that decided and then died still has a rung and no longer has an `agents`
990
+ * entry, which is the difference between a decision and a survivor.
991
+ * `activeWebs` is every unexpired predator-built web, empty on a board with
992
+ * none.
667
993
  */
668
994
  export async function runTownSquareTick(memoryDir, {
669
995
  layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
@@ -684,10 +1010,40 @@ export async function runTownSquareTick(memoryDir, {
684
1010
  const agents = {};
685
1011
  const rungs = {};
686
1012
 
1013
+ const carriesPrey = config.carryPreyToWeb === true;
1014
+ const buildsWebs = config.buildWebs === true;
1015
+ const boardNoun = lay.boardNoun ?? "square";
1016
+ const webbedAt = (c) => hasActiveWebAt(lay, state, k, c.x, c.y, config.webDurationTurns);
1017
+ // Widened in place as predators spin webs this tick, for the renderer's own
1018
+ // list. The gates above keep reading `state`, so a web spun this turn is not
1019
+ // yet active for this turn's eat, lay or trap.
1020
+ const tickWebs = new Map(state.webs);
1021
+ let nextWebNum = 1 + maxIdSuffix(state.webs.keys(), "web");
1022
+ // Prey a still-live predator was already holding when the tick opened. It
1023
+ // rides its captor's move instead of scoring one, and a captor that has left
1024
+ // the board simply isn't here, which frees the prey again.
1025
+ const captorOfPrey = new Map();
1026
+ if (carriesPrey) {
1027
+ for (const [predatorId, { prey: preyId }] of state.carrying) {
1028
+ if (predators.includes(predatorId) && prey.includes(preyId)) captorOfPrey.set(preyId, predatorId);
1029
+ }
1030
+ }
1031
+
687
1032
  const decide = (agentId, role) => {
688
1033
  const fromCell = parseCellId(state.placements.get(agentId).cell);
689
1034
  const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
690
1035
  const beliefOpts = { visionRadius, toldFacts };
1036
+ // True unless a caller explicitly turns it off. A config object built by
1037
+ // hand (a fixture, a test, an older tmct.toml read before this key
1038
+ // existed) can omit the key entirely, and `undefined` must still mean
1039
+ // "gated" — the shipped default — never silently flip to omniscient food.
1040
+ const foodVisionGated = config.foodVisionGated !== false;
1041
+ // Every food lookup goes through this instead of beliefOpts. Gated (the
1042
+ // default) it is beliefOpts itself, unchanged; ungated it is the same
1043
+ // opts widened to see the whole board — omniscient food is a
1044
+ // visionRadius: Infinity call, not new belief machinery. The rival-threat
1045
+ // lookup below stays on beliefOpts either way: this switch is about food.
1046
+ const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
691
1047
  const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
692
1048
  const threat = nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
693
1049
 
@@ -696,16 +1052,64 @@ export async function runTownSquareTick(memoryDir, {
696
1052
  let plan;
697
1053
  let goal;
698
1054
  let mood;
699
- if (threat) {
1055
+ // A carried prey and its captor both leave the ordinary chain. A carrying
1056
+ // predator never drops its catch to dodge a rival or chase a second one:
1057
+ // nothing here eats a predator, so "avoid" is contention, not survival.
1058
+ const carriedPreyId = (carriesPrey && role === "predator") ? (state.carrying.get(agentId)?.prey ?? null) : null;
1059
+ const isCarrying = Boolean(carriedPreyId) && prey.includes(carriedPreyId);
1060
+ const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
1061
+ let beliefCell = fromCell;
1062
+
1063
+ if (isCarrying && webbedAt(fromCell)) {
1064
+ // Already standing in a web: hold, so the pass's own eat gate reads this
1065
+ // same cell and resolves the delivery on this exact tick.
1066
+ rung = "deliver";
1067
+ nextCell = fromCell;
1068
+ plan = [];
1069
+ goal = goalLine("deliver", { subject: carriedPreyId });
1070
+ mood = "angry";
1071
+ } else if (isCarrying) {
1072
+ rung = "carry";
1073
+ const path = pathToAnyWeb(fromCell, applyActions, { layout: lay, state, turn: k, webDurationTurns: config.webDurationTurns });
1074
+ if (path && path.actions.length) {
1075
+ nextCell = path.states[1];
1076
+ plan = path.actions;
1077
+ } else {
1078
+ nextCell = greedyToward(fromCell, parseCellId(lay.webHomeCell) ?? fromCell, applyActions);
1079
+ plan = stepPlan(fromCell, nextCell);
1080
+ }
1081
+ goal = goalLine("carry", { subject: carriedPreyId });
1082
+ mood = "angry";
1083
+ } else if (captorId) {
1084
+ rung = "carried";
1085
+ nextCell = postMovePlacements.get(captorId);
1086
+ beliefCell = nextCell;
1087
+ plan = [];
1088
+ goal = goalLine("carried", { subject: captorId });
1089
+ mood = "scared";
1090
+ } else if (role === "prey" && buildsWebs && webbedAt(fromCell)) {
1091
+ rung = "trapped";
1092
+ nextCell = fromCell;
1093
+ plan = [];
1094
+ goal = goalLine("trapped");
1095
+ mood = "scared";
1096
+ } else if (threat) {
700
1097
  rung = role === "predator" ? "avoid" : "evade";
701
- nextCell = greedyAway(fromCell, threat.cell, applyActions);
1098
+ // A fleeing prey that already knows where food is should flee toward
1099
+ // it, not away from it — among cells that are equally safe, break the
1100
+ // tie toward the nearest believed crumb. Prey-only: the predator's own
1101
+ // avoid rung passes nothing, so its ties still resolve the old way.
1102
+ const towardFood = role === "prey"
1103
+ ? nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts)
1104
+ : null;
1105
+ nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
702
1106
  plan = stepPlan(fromCell, nextCell);
703
1107
  goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
704
1108
  mood = "scared";
705
1109
  } else {
706
1110
  const quarry = role === "predator"
707
1111
  ? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
708
- : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, beliefOpts);
1112
+ : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
709
1113
  if (quarry) {
710
1114
  rung = role === "predator" ? "chase" : "forage";
711
1115
  const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
@@ -720,18 +1124,48 @@ export async function runTownSquareTick(memoryDir, {
720
1124
  plan = stepPlan(fromCell, nextCell);
721
1125
  }
722
1126
  const arrived = nextCell.x === quarry.cell.x && nextCell.y === quarry.cell.y;
723
- goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived });
1127
+ goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived, catches: carriesPrey });
724
1128
  mood = role === "predator" ? "angry" : "calm";
1129
+ } else if (role === "predator" && buildsWebs) {
1130
+ // A web-spinning predator with nothing in sight has something better to
1131
+ // do than wander: hold this cell, and spin a web here unless a live one
1132
+ // already covers it.
1133
+ nextCell = fromCell;
1134
+ plan = [];
1135
+ mood = "calm";
1136
+ if (webbedAt(fromCell)) {
1137
+ rung = "hold-web";
1138
+ goal = goalLine("hold-web");
1139
+ } else {
1140
+ rung = "build-web";
1141
+ const webId = `web-${nextWebNum}`;
1142
+ nextWebNum += 1;
1143
+ const here = cellId(fromCell.x, fromCell.y);
1144
+ tickWebs.set(webId, { cell: here, builtAtTurn: k });
1145
+ movementWrites.push({ subject: stamp(webId), predicate: PLACEMENT_PREDICATE, object: here });
1146
+ movementWrites.push({ subject: stamp(webId), predicate: WEB_BUILT_PREDICATE, object: String(k) });
1147
+ goal = goalLine("build-web");
1148
+ }
725
1149
  } else {
726
1150
  rung = "wander";
727
1151
  nextCell = seededWander(fromCell, applyActions, { layoutName: lay.name, epoch, turn: k, id: agentId });
728
1152
  plan = stepPlan(fromCell, nextCell);
729
- goal = goalLine("wander");
1153
+ goal = goalLine("wander", { boardNoun });
730
1154
  mood = "calm";
731
1155
  }
732
1156
  }
733
1157
 
734
- const belief = beliefSnapshotFor(agentId, fromCell, beliefCandidates, state, beliefOpts);
1158
+ const belief = beliefSnapshotFor(agentId, beliefCell, beliefCandidates, state, beliefOpts);
1159
+ // beliefSnapshotFor's one call covers every candidate, food included, so
1160
+ // an ungated food call needs the food entries re-taken at the widened
1161
+ // radius too — otherwise the panel would show a crumb null while the
1162
+ // goal line right beside it names that same crumb as the target. Values
1163
+ // only: every key already exists from the call above, so this can never
1164
+ // reorder the panel.
1165
+ if (!foodVisionGated) {
1166
+ const foodCandidates = beliefCandidates.filter((id) => foodIds.has(id));
1167
+ Object.assign(belief, beliefSnapshotFor(agentId, beliefCell, foodCandidates, state, foodBeliefOpts));
1168
+ }
735
1169
  const facing = plan[0] ?? state.facing.get(agentId)?.value ?? DEFAULT_FACING;
736
1170
  postMovePlacements.set(agentId, nextCell);
737
1171
  rungs[agentId] = rung;
@@ -806,17 +1240,32 @@ export async function runTownSquareTick(memoryDir, {
806
1240
  for (const id of [...pass.takenAgents, ...pass.starvedThisTurn]) delete agents[id];
807
1241
  for (const [eater, meals] of ateBy) {
808
1242
  if (!agents[eater]) continue;
809
- agents[eater].goal = `just ate ${meals.join(" and ")}.`;
1243
+ agents[eater].goal = carriesPrey ? `just ate ${meals.join(" and ")} in the web.` : `just ate ${meals.join(" and ")}.`;
810
1244
  agents[eater].mood = "happy";
811
1245
  }
1246
+ // A catch that wasn't also delivered and eaten this same tick leaves both
1247
+ // sides' goals stale: they were assigned during movement, before the pass
1248
+ // resolved the catch, so neither one mentions it.
1249
+ for (const event of ecology) {
1250
+ if (event.type !== "catch-prey") continue;
1251
+ if (ateBy.has(event.predator)) continue;
1252
+ if (agents[event.predator]) {
1253
+ agents[event.predator].goal = goalLine("carry", { subject: event.prey });
1254
+ agents[event.predator].mood = "angry";
1255
+ }
1256
+ if (agents[event.prey]) {
1257
+ agents[event.prey].goal = `just caught by ${event.predator} — being carried.`;
1258
+ agents[event.prey].mood = "scared";
1259
+ }
1260
+ }
812
1261
  // A prey the pass minted arrives after the movement loops built `agents`, so
813
1262
  // without this it would be announced by its own turn's event and still be
814
- // invisible on the board until the next one.
1263
+ // invisible on the board until the next one. A hatchling is the same case.
815
1264
  for (const arrival of pass.spawned) {
816
1265
  if (arrival.isItem) continue;
817
1266
  agents[arrival.id] = {
818
1267
  role: arrival.role, cell: arrival.cell, facing: DEFAULT_FACING,
819
- goal: "just arrived — no goal yet.", mood: "calm", plan: [], mass: round2(arrival.mass), belief: {},
1268
+ goal: arrival.goal ?? "just arrived — no goal yet.", mood: "calm", plan: [], mass: round2(arrival.mass), belief: {},
820
1269
  };
821
1270
  }
822
1271
 
@@ -840,6 +1289,7 @@ export async function runTownSquareTick(memoryDir, {
840
1289
  items: sortedByKey(items),
841
1290
  ecology,
842
1291
  rungs: sortedByKey(rungs),
1292
+ activeWebs: liveWebs(tickWebs, k, config.webDurationTurns),
843
1293
  writes,
844
1294
  };
845
1295
  }
@@ -895,13 +1345,18 @@ export async function townSquareBoard(memoryDir, {
895
1345
  };
896
1346
  }
897
1347
 
898
- return {
1348
+ // A resting board's whole return IS the render payload, so `activeWebs`
1349
+ // appears only for a cast that spins webs. A board with none would otherwise
1350
+ // grow a field that is always empty for it.
1351
+ const resting = {
899
1352
  turn: state.tickCount,
900
1353
  epoch: state.epoch,
901
1354
  agents: sortedByKey(agents),
902
1355
  items: sortedByKey(itemsPayload(board.liveItemIds, { state, itemCellOf: board.itemCellOf, roles })),
903
1356
  ecology: [],
904
1357
  };
1358
+ if (config.buildWebs === true) resting.activeWebs = liveWebs(state.webs, state.tickCount, config.webDurationTurns);
1359
+ return resting;
905
1360
  }
906
1361
 
907
1362
  /** The frozen render payload, projected off a tick result: exactly