@polycode-projects/the-mechanical-code-talker 5.0.0 → 5.0.2

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.
@@ -0,0 +1,916 @@
1
+ // predator-prey.mjs — the headless town-square turn engine: an (epoch, turn)
2
+ // state fold, vision-gated belief over agents AND inert food, the two decision
3
+ // chains, and one fixed-order ecology pass, all reading and writing plain fact
4
+ // rows through the shared memory store. No chat, no rendering.
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.
10
+ //
11
+ // Grid geometry, the prop tables and what "solid" means all come from
12
+ // domain/town-square-world.mjs. Belief comes from domain/agent-belief.mjs,
13
+ // which is board-size agnostic and models vision as plain Chebyshev distance
14
+ // with no line of sight — a building blocks movement, never sight.
15
+ //
16
+ // Two things this file deliberately does NOT borrow from spider-fly.mjs, its
17
+ // nearest sibling:
18
+ //
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.
32
+
33
+ import {
34
+ DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
35
+ cellId, parseCellId, chebyshevDistance, isFoodId, isPropId,
36
+ DIRECTION_DELTA, oneStepDirectionBetween, openCells, perimeterCells,
37
+ } from "../domain/town-square-world.mjs";
38
+ import {
39
+ DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor,
40
+ } from "../domain/agent-belief.mjs";
41
+ import { findActionPath, findReachableSet } from "../domain/planning.mjs";
42
+ import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
43
+ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
44
+ import { mulberry32 } from "../domain/seeded-random.mjs";
45
+ import { fnv1a32 } from "../domain/hash.mjs";
46
+ import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
47
+ import { objectClassChain, parseSnapshotSubject, snapshotSubject, WORLD_EPOCH_PREDICATE } from "./adventure.mjs";
48
+
49
+ export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
50
+
51
+ /** The v1 cast. Keyed by role, never by species — every knob this engine reads
52
+ * is role-keyed too, so swapping the pair is data. */
53
+ export const MUDIII_ROLES = Object.freeze({
54
+ predator: { role: "predator", kind: "fox", idPrefix: "fox" },
55
+ prey: { role: "prey", kind: "goblin", idPrefix: "goblin" },
56
+ food: { spawnedKind: "crumb", placedKind: "morsel" },
57
+ });
58
+
59
+ const PLACEMENT_PREDICATE = "mgx:currently-in";
60
+ const MASS_PREDICATE = "mgx:hasMass";
61
+ const MOOD_PREDICATE = "mgx:feels";
62
+ const FACING_PREDICATE = "mgx:facing";
63
+ const EATEN_BY_PREDICATE = "mgx:eaten-by";
64
+ const STARVED_PREDICATE = "mgx:starved";
65
+ const PLACED_BY_PREDICATE = "mgx:placed-by";
66
+ const MODEL_PREDICATE = "mgx:model";
67
+ const ROTATION_PREDICATE = "mgx:rotation";
68
+ const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
69
+
70
+ // One row per tick, whatever else the turn did, on a subject the board itself
71
+ // owns. It is what the next tick's turn number counts, and it exists as its own
72
+ // predicate rather than being read off the mood rows because a board with
73
+ // nothing alive on it writes no moods — and a turn counter that stops advancing
74
+ // leaves every later tick replaying the same turn number forever.
75
+ const TURN_PLAYED_PREDICATE = "mgx:turn-played";
76
+ const BOARD_SUBJECT = "square";
77
+
78
+ const MUDIII_STATE_PREDICATE_SET = new Set([
79
+ PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
80
+ EATEN_BY_PREDICATE, STARVED_PREDICATE, PLACED_BY_PREDICATE,
81
+ MODEL_PREDICATE, ROTATION_PREDICATE, TURN_PLAYED_PREDICATE,
82
+ "rdf:type", WORLD_EPOCH_PREDICATE,
83
+ ]);
84
+
85
+ /** Every predicate that carries live town-square state — where a thing stands,
86
+ * what it weighs, how it feels, which way it faces, what took it off the
87
+ * board, who put it there, and the class and model a mid-play mint needs. */
88
+ export const MUDIII_STATE_PREDICATES = Object.freeze([...MUDIII_STATE_PREDICATE_SET].sort());
89
+
90
+ /** Whether `predicate` carries live town-square state. The P2P sync filter
91
+ * reads this to tell a fact a turn produced from the page chrome around it.
92
+ * Pure. */
93
+ export function isMudiiiStatePredicate(predicate) {
94
+ const p = String(predicate || "");
95
+ return MUDIII_STATE_PREDICATE_SET.has(p) || EXIT_PREDICATE_RE.test(p);
96
+ }
97
+
98
+ // ---- seeded "randomness" (never Math.random) ---------------------------------
99
+ // Every seed string carries the epoch. Without it a Reset restarts the turn
100
+ // counter and replays the previous run's spawn and wander cells exactly, which
101
+ // makes Reset look broken while being perfectly deterministic.
102
+
103
+ const seedKey = (layoutName, epoch, turn, id, purpose) => `${layoutName}:${epoch}:${turn}:${id}:${purpose}`;
104
+
105
+ /** Deterministically pick one of `options` (must be non-empty), keyed on
106
+ * `contextString`. */
107
+ function seededPick(options, contextString) {
108
+ const rng = mulberry32(fnv1a32(contextString));
109
+ return options[Math.floor(rng() * options.length)];
110
+ }
111
+
112
+ // ---- the (epoch, turn) fold ---------------------------------------------------
113
+
114
+ /** Fold fact rows into the current town-square state.
115
+ *
116
+ * Rows rank by the (epoch, turn) pair, exactly as adventure.mjs's
117
+ * foldWorldState does, so a recast can never be outranked by the run it
118
+ * replaced: a peer's turn-9 snapshot from before the recast loses to a turn-1
119
+ * snapshot written after it. An unstamped row carries no stamp of its own and
120
+ * ranks as turn 0 of the CURRENT epoch — the world pack's own rows are
121
+ * exactly the state a fresh run starts from.
122
+ *
123
+ * Two counters come out, and they are not the same number. `turnCount` is the
124
+ * largest turn stamped on ANY row in the current epoch. `tickCount` is the
125
+ * largest turn a TICK actually ran, read off the one marker row a tick always
126
+ * writes. The next tick is tickCount + 1, and so is the stamp on a food the
127
+ * player places before it — which is how a placement and the tick that
128
+ * resolves it share one turn number instead of the placement quietly
129
+ * consuming one.
130
+ *
131
+ * Pure. */
132
+ export function foldTownSquareState(factRows) {
133
+ const rows = factRows || [];
134
+ let epoch = 0;
135
+ for (const row of rows) {
136
+ if (row.predicate === WORLD_EPOCH_PREDICATE) {
137
+ const marked = Number(row.object);
138
+ if (Number.isInteger(marked) && marked > epoch) epoch = marked;
139
+ continue;
140
+ }
141
+ const snap = parseSnapshotSubject(row.subject);
142
+ if (snap && snap.epoch > epoch) epoch = snap.epoch;
143
+ }
144
+
145
+ const placements = new Map(); // subject -> { cell, turn, epoch }
146
+ const mass = new Map(); // subject -> { value, turn, epoch }
147
+ const mood = new Map(); // subject -> { value, turn, epoch }
148
+ const facing = new Map(); // subject -> { value, turn, epoch }
149
+ const eatenBy = new Map(); // subject -> { by, turn, epoch }
150
+ const placedBy = new Map(); // subject -> { by, turn, epoch }
151
+ const types = new Map(); // subject -> class named by its bare rdf:type row
152
+ const models = new Map(); // prop subject -> asset key
153
+ const starved = new Set();
154
+ let turnCount = 0;
155
+ let tickCount = 0;
156
+
157
+ const outranks = (rowEpoch, turn, prior) =>
158
+ !prior || rowEpoch > prior.epoch || (rowEpoch === prior.epoch && turn >= prior.turn);
159
+
160
+ for (const row of rows) {
161
+ if (row.predicate === WORLD_EPOCH_PREDICATE) continue;
162
+ const snap = parseSnapshotSubject(row.subject);
163
+ const base = snap ? snap.base : row.subject;
164
+ const rowEpoch = snap ? snap.epoch : epoch;
165
+ const turn = snap ? snap.turn : 0;
166
+ if (snap && rowEpoch === epoch) turnCount = Math.max(turnCount, turn);
167
+
168
+ switch (row.predicate) {
169
+ case PLACEMENT_PREDICATE:
170
+ if (outranks(rowEpoch, turn, placements.get(base))) placements.set(base, { cell: row.object, turn, epoch: rowEpoch });
171
+ break;
172
+ case MASS_PREDICATE: {
173
+ const value = Number(row.object);
174
+ if (!Number.isFinite(value)) break;
175
+ if (outranks(rowEpoch, turn, mass.get(base))) mass.set(base, { value, turn, epoch: rowEpoch });
176
+ break;
177
+ }
178
+ case MOOD_PREDICATE:
179
+ if (outranks(rowEpoch, turn, mood.get(base))) mood.set(base, { value: row.object, turn, epoch: rowEpoch });
180
+ break;
181
+ case TURN_PLAYED_PREDICATE:
182
+ if (snap && rowEpoch === epoch) tickCount = Math.max(tickCount, turn);
183
+ break;
184
+ case FACING_PREDICATE:
185
+ if (outranks(rowEpoch, turn, facing.get(base))) facing.set(base, { value: row.object, turn, epoch: rowEpoch });
186
+ break;
187
+ case EATEN_BY_PREDICATE:
188
+ if (outranks(rowEpoch, turn, eatenBy.get(base))) eatenBy.set(base, { by: row.object, turn, epoch: rowEpoch });
189
+ break;
190
+ case PLACED_BY_PREDICATE:
191
+ if (outranks(rowEpoch, turn, placedBy.get(base))) placedBy.set(base, { by: row.object, turn, epoch: rowEpoch });
192
+ break;
193
+ case STARVED_PREDICATE:
194
+ if (rowEpoch === epoch) starved.add(base);
195
+ break;
196
+ case MODEL_PREDICATE:
197
+ models.set(base, row.object);
198
+ break;
199
+ case "rdf:type":
200
+ if (!snap && !types.has(base)) types.set(base, row.object);
201
+ break;
202
+ default:
203
+ break;
204
+ }
205
+ }
206
+
207
+ // A terminal marker only counts inside the current epoch. A recast reopens
208
+ // the same deterministic ids, so an agent eaten on the previous run must not
209
+ // arrive at the new one already dead.
210
+ const removed = new Set([...starved]);
211
+ for (const [subject, { epoch: rowEpoch }] of eatenBy) {
212
+ if (rowEpoch === epoch) removed.add(subject);
213
+ }
214
+
215
+ return { placements, mass, mood, facing, eatenBy, placedBy, types, models, starved, removed, turnCount, tickCount, epoch };
216
+ }
217
+
218
+ /** Every live subject whose id names `kind`, sorted. */
219
+ function liveOfKind(state, kind) {
220
+ const re = new RegExp(`^${kind}-\\d+$`);
221
+ return [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
222
+ }
223
+
224
+ function maxIdSuffix(ids, kind) {
225
+ const re = new RegExp(`^${kind}-(\\d+)$`);
226
+ let max = 0;
227
+ for (const id of ids) {
228
+ const m = re.exec(id);
229
+ if (m) max = Math.max(max, Number(m[1]));
230
+ }
231
+ return max;
232
+ }
233
+
234
+ // ---- movement over the world's own exit facts ----------------------------------
235
+
236
+ /** The movement applyActions closure findActionPath/findReachableSet need: one
237
+ * hop per mgx:has-exit-<direction> fact reachable from a cell, in the fixed
238
+ * direction order every search shares for deterministic tie-breaking. Built
239
+ * once per tick from the world's static exit rows — which is the whole reason
240
+ * buildings need no planner code: the world pack simply never emits an exit
241
+ * into a prop's cell, so a wall is a missing edge. */
242
+ export function gridApplyActions(factRows) {
243
+ const exits = new Map();
244
+ for (const row of factRows || []) {
245
+ const m = EXIT_PREDICATE_RE.exec(row.predicate);
246
+ if (!m) continue;
247
+ if (!exits.has(row.subject)) exits.set(row.subject, new Map());
248
+ exits.get(row.subject).set(m[1], row.object);
249
+ }
250
+ return (searchState) => {
251
+ const out = [];
252
+ const dirs = exits.get(cellId(searchState.x, searchState.y));
253
+ if (!dirs) return out;
254
+ for (const direction of Object.keys(DIRECTION_DELTA)) {
255
+ const target = dirs.get(direction);
256
+ if (!target) continue;
257
+ const parsed = parseCellId(target);
258
+ if (!parsed) continue;
259
+ out.push({ action: direction, nextState: { x: parsed.x, y: parsed.y } });
260
+ }
261
+ return out;
262
+ };
263
+ }
264
+
265
+ /** Canonicalizes a grid-position search state onto its cell alone. */
266
+ export const pathStateKey = (searchState) => cellId(searchState.x, searchState.y);
267
+
268
+ const oneStepOptions = (fromCell, applyActions) =>
269
+ [fromCell, ...findReachableSet(fromCell, applyActions, { maxDepth: 1, stateKey: pathStateKey }).map((r) => r.node)];
270
+
271
+ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
272
+ const options = oneStepOptions(fromCell, applyActions);
273
+ let best = options[0];
274
+ let bestScore = scoreOf(best);
275
+ for (let i = 1; i < options.length; i += 1) {
276
+ const score = scoreOf(options[i]);
277
+ if (isBetter(score, bestScore)) { bestScore = score; best = options[i]; }
278
+ }
279
+ return best;
280
+ }
281
+
282
+ /** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
283
+ * 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) {
286
+ if (!awayFrom) return fromCell;
287
+ return bestOneStepBy(
288
+ fromCell, applyActions,
289
+ (cell) => chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y),
290
+ (score, bestScore) => score > bestScore,
291
+ );
292
+ }
293
+
294
+ /** One-ply greedy, scored the opposite way: close the distance instead of
295
+ * opening it. The fallback when no full path to a believed target exists. */
296
+ export function greedyToward(fromCell, towardCell, applyActions) {
297
+ if (!towardCell) return fromCell;
298
+ return bestOneStepBy(
299
+ fromCell, applyActions,
300
+ (cell) => chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y),
301
+ (score, bestScore) => score < bestScore,
302
+ );
303
+ }
304
+
305
+ /** A seeded, uniform pick among staying put or any one-ply reachable cell.
306
+ * Deterministic and replayable, and it looks random to somebody watching.
307
+ * Both roles' last rung: a motionless predator reads as a broken page. */
308
+ export function seededWander(fromCell, applyActions, { layoutName, epoch, turn, id }) {
309
+ return seededPick(oneStepOptions(fromCell, applyActions), seedKey(layoutName, epoch, turn, id, "wander"));
310
+ }
311
+
312
+ /** A one-step "plan" for a greedy or held move — the direction as a length-1
313
+ * array, or `[]` when the agent held still. A genuine multi-step search result
314
+ * supplies its own full direction list instead. Either way plan[0] is the
315
+ * direction actually taken, which is what drives facing. */
316
+ function stepPlan(fromCell, toCell) {
317
+ const direction = oneStepDirectionBetween(fromCell, toCell);
318
+ return direction ? [direction] : [];
319
+ }
320
+
321
+ const round2 = (n) => Math.round(n * 100) / 100;
322
+
323
+ // ---- the goal line and the mood word -------------------------------------------
324
+ // Every branch assigns a mood beside the goal sentence it renders, and that
325
+ // word is written as a real mgx:feels fact for the turn. The words are the four
326
+ // spider-fly already uses: a predator mid-chase is angry, one avoiding a rival
327
+ // is scared, anything wandering or foraging is calm, and anything that just ate
328
+ // is happy.
329
+
330
+ function goalLine(kind, { subject, cell, arrived } = {}) {
331
+ switch (kind) {
332
+ 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}.`;
334
+ case "evade": return `evading — last saw ${subject} at ${cell}.`;
335
+ case "forage": return arrived ? `standing on ${subject} — eating it.` : `foraging — heading for ${subject} at ${cell}.`;
336
+ default: return "nothing in sight — wandering the square.";
337
+ }
338
+ }
339
+
340
+ // ---- the ecology pass ----------------------------------------------------------
341
+ // One fixed order, and the order is the mechanic:
342
+ //
343
+ // 1. eat-agent — a predator sharing a cell with prey takes it, and gains the
344
+ // prey's remaining mass. Catch and eat are one step: a predator needs no
345
+ // apparatus, so nothing is ever carried and no state survives the turn.
346
+ // 2. eat-item — prey standing on food eat it. AFTER eat-agent, so a prey
347
+ // taken this turn is never also credited with a crumb. A prey on two
348
+ // crumbs eats both.
349
+ // 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
352
+ // perimeter minus prop cells. That subtraction is not a nicety: the
353
+ // headline layout has buildings sitting on two whole edges.
354
+ // 5. spawn-food — dropped bread lands anywhere open, so the pick is every
355
+ // open cell minus whatever stands or lies on one. Last, so it reads the
356
+ // board as every earlier step left it.
357
+ //
358
+ // Both spawns check their cap first, so a full board simply skips that turn's
359
+ // arrival.
360
+
361
+ function runEcologyPass({
362
+ state, layout, roles, config, turn, epoch,
363
+ postMovePlacements, postMoveMass, liveItemIds, itemCellOf, itemMassOf, foodIds,
364
+ }) {
365
+ const k = turn;
366
+ const writes = [];
367
+ const events = [];
368
+ const stamp = (base) => snapshotSubject(base, k, epoch);
369
+
370
+ const predators = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "predator").sort();
371
+ const prey = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "prey").sort();
372
+ const finalMass = new Map(postMoveMass);
373
+ const takenAgents = new Set();
374
+ const takenItems = new Set();
375
+
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;
383
+ takenAgents.add(preyId);
384
+ const gained = finalMass.get(preyId) ?? 0;
385
+ finalMass.set(predatorId, (finalMass.get(predatorId) ?? 0) + gained);
386
+ writes.push({ subject: stamp(preyId), predicate: EATEN_BY_PREDICATE, object: predatorId });
387
+ events.push({ type: "eat-agent", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y), massGained: round2(gained) });
388
+ }
389
+ }
390
+
391
+ // 2. eat-item
392
+ for (const preyId of prey) {
393
+ if (takenAgents.has(preyId)) continue;
394
+ const qc = postMovePlacements.get(preyId);
395
+ const here = cellId(qc.x, qc.y);
396
+ for (const itemId of liveItemIds) {
397
+ if (takenItems.has(itemId)) continue;
398
+ if (!foodIds.has(itemId)) continue;
399
+ if (itemCellOf.get(itemId) !== here) continue;
400
+ takenItems.add(itemId);
401
+ const gained = itemMassOf.get(itemId) ?? 0;
402
+ finalMass.set(preyId, (finalMass.get(preyId) ?? 0) + gained);
403
+ writes.push({ subject: stamp(itemId), predicate: EATEN_BY_PREDICATE, object: preyId });
404
+ events.push({ type: "eat-item", agent: preyId, item: itemId, cell: here, massGained: round2(gained) });
405
+ }
406
+ }
407
+
408
+ // 3. starve
409
+ const starvedThisTurn = new Set();
410
+ for (const agentId of [...predators, ...prey].sort()) {
411
+ if (takenAgents.has(agentId)) continue;
412
+ if ((finalMass.get(agentId) ?? 0) > 0) continue;
413
+ starvedThisTurn.add(agentId);
414
+ const c = postMovePlacements.get(agentId);
415
+ writes.push({ subject: stamp(agentId), predicate: STARVED_PREDICATE, object: "true" });
416
+ events.push({ type: "starve", agent: agentId, cell: cellId(c.x, c.y) });
417
+ }
418
+
419
+ const goneThisTurn = new Set([...takenAgents, ...starvedThisTurn]);
420
+ const occupied = new Set();
421
+ for (const [id, c] of postMovePlacements) {
422
+ if (goneThisTurn.has(id)) continue;
423
+ occupied.add(cellId(c.x, c.y));
424
+ }
425
+ const itemCells = new Set();
426
+ for (const itemId of liveItemIds) {
427
+ if (takenItems.has(itemId)) continue;
428
+ itemCells.add(itemCellOf.get(itemId));
429
+ }
430
+
431
+ const spawned = [];
432
+
433
+ // 4. spawn-prey
434
+ const livePreyAfter = prey.filter((id) => !goneThisTurn.has(id)).length;
435
+ if (k % config.preySpawnIntervalTurns === 0 && livePreyAfter < config.maxPreyPopulation) {
436
+ const free = perimeterCells(layout).filter((c) => !occupied.has(c));
437
+ if (free.length) {
438
+ const preyId = `${roles.prey.idPrefix}-${1 + maxIdSuffix(state.placements.keys(), roles.prey.kind)}`;
439
+ const cell = seededPick(free, seedKey(layout.name, epoch, k, preyId, "spawn"));
440
+ occupied.add(cell);
441
+ writes.push({ subject: preyId, predicate: "rdf:type", object: roles.prey.kind });
442
+ writes.push({ subject: stamp(preyId), predicate: PLACEMENT_PREDICATE, object: cell });
443
+ writes.push({ subject: stamp(preyId), predicate: MASS_PREDICATE, object: String(config.preyInitialMass) });
444
+ writes.push({ subject: stamp(preyId), predicate: FACING_PREDICATE, object: DEFAULT_FACING });
445
+ events.push({ type: "spawn-prey", agent: preyId, cell, mass: round2(config.preyInitialMass) });
446
+ spawned.push({ id: preyId, role: "prey", cell, mass: config.preyInitialMass });
447
+ }
448
+ }
449
+
450
+ // 5. spawn-food
451
+ const liveFoodAfter = liveItemIds.filter((id) => !takenItems.has(id)).length;
452
+ if (k % config.foodSpawnIntervalTurns === 0 && liveFoodAfter < config.maxFoodItems) {
453
+ const free = openCells(layout).filter((c) => !occupied.has(c) && !itemCells.has(c));
454
+ if (free.length) {
455
+ const itemId = `${roles.food.spawnedKind}-${1 + maxIdSuffix(state.placements.keys(), roles.food.spawnedKind)}`;
456
+ const cell = seededPick(free, seedKey(layout.name, epoch, k, itemId, "spawn"));
457
+ writes.push({ subject: itemId, predicate: "rdf:type", object: roles.food.spawnedKind });
458
+ writes.push({ subject: stamp(itemId), predicate: PLACEMENT_PREDICATE, object: cell });
459
+ writes.push({ subject: stamp(itemId), predicate: MASS_PREDICATE, object: String(config.spawnedFoodMass) });
460
+ events.push({ type: "spawn-food", item: itemId, kind: roles.food.spawnedKind, cell, mass: round2(config.spawnedFoodMass) });
461
+ spawned.push({ id: itemId, kind: roles.food.spawnedKind, cell, mass: config.spawnedFoodMass, isItem: true });
462
+ }
463
+ }
464
+
465
+ // Every surviving agent's mass goes on record with whatever it ended the turn
466
+ // holding, so an eat and the mass it bought land in the same write.
467
+ for (const agentId of [...predators, ...prey].sort()) {
468
+ if (goneThisTurn.has(agentId)) continue;
469
+ writes.push({ subject: stamp(agentId), predicate: MASS_PREDICATE, object: String(finalMass.get(agentId)) });
470
+ }
471
+
472
+ return { writes, events, finalMass, takenAgents, takenItems, starvedThisTurn, spawned };
473
+ }
474
+
475
+ /** Which role an id names, or null for a prop, a food item, or anything this
476
+ * cast doesn't hold. Never a guessed role. */
477
+ export function roleOfId(id, roles = MUDIII_ROLES) {
478
+ if (new RegExp(`^${roles.predator.idPrefix}-\\d+$`).test(id)) return "predator";
479
+ if (new RegExp(`^${roles.prey.idPrefix}-\\d+$`).test(id)) return "prey";
480
+ return null;
481
+ }
482
+
483
+ // ---- bootstrap -----------------------------------------------------------------
484
+
485
+ /**
486
+ * Seed a fresh town square: one predator/prey roster placed on the board, each
487
+ * 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.
490
+ *
491
+ * `opts.agents` places an explicit roster (`{ id: { role, cell, facing, mass } }`)
492
+ * — how a recorded fixture pins a starting board. Without it the roster is
493
+ * sized by `opts.predatorCount`/`opts.preyCount` (defaulting to the layout's
494
+ * own cast counts) and placed at seeded picks: predators on open cells, prey on
495
+ * the perimeter they wander in from.
496
+ */
497
+ export async function startTownSquareGame(memoryDir, {
498
+ layout, agents = null, predatorCount = null, preyCount = null,
499
+ config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES, epoch = 0,
500
+ } = {}) {
501
+ const state = foldTownSquareState(readFactRows(await loadMemory(memoryDir)));
502
+ const firstPredator = `${roles.predator.idPrefix}-1`;
503
+ if (state.placements.has(firstPredator)) return { started: false, facts: [] };
504
+
505
+ const roster = agents ?? seededRoster(layout, {
506
+ predators: predatorCount ?? layout.cast.predators,
507
+ prey: preyCount ?? layout.cast.prey,
508
+ roles, epoch,
509
+ });
510
+
511
+ const facts = [];
512
+ for (const id of Object.keys(roster).sort()) {
513
+ const spec = roster[id];
514
+ const role = spec.role ?? roleOfId(id, roles);
515
+ const kind = role === "predator" ? roles.predator.kind : roles.prey.kind;
516
+ const mass = spec.mass ?? (role === "predator" ? config.predatorInitialMass : config.preyInitialMass);
517
+ 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 });
522
+ }
523
+ await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(layout.name) })));
524
+ return { started: true, facts };
525
+ }
526
+
527
+ function seededRoster(layout, { predators, prey, roles, epoch }) {
528
+ const roster = {};
529
+ const taken = new Set();
530
+ const open = openCells(layout);
531
+ const edge = perimeterCells(layout);
532
+ for (let i = 1; i <= predators; i += 1) {
533
+ const id = `${roles.predator.idPrefix}-${i}`;
534
+ const free = open.filter((c) => !taken.has(c));
535
+ const cell = seededPick(free.length ? free : open, seedKey(layout.name, epoch, 0, id, "spawn"));
536
+ taken.add(cell);
537
+ roster[id] = { role: "predator", cell, facing: DEFAULT_FACING };
538
+ }
539
+ for (let i = 1; i <= prey; i += 1) {
540
+ const id = `${roles.prey.idPrefix}-${i}`;
541
+ const free = edge.filter((c) => !taken.has(c));
542
+ const cell = seededPick(free.length ? free : edge, seedKey(layout.name, epoch, 0, id, "spawn"));
543
+ taken.add(cell);
544
+ roster[id] = { role: "prey", cell, facing: DEFAULT_FACING };
545
+ }
546
+ return roster;
547
+ }
548
+
549
+ // ---- the player's own verb: placing food -----------------------------------------
550
+
551
+ /**
552
+ * Put one piece of food on a cell, on the player's say-so. This is the world
553
+ * teach machinery of src/services/world-teach.mjs applied to one sentence: the
554
+ * same `world:<name>:taught:turnK` provenance (so the row passes the playable
555
+ * fold's `world:` filter), the same bare `rdf:type` row beside a snapshot-
556
+ * stamped placement, and the same "a teach spends a turn number" convention.
557
+ * It writes `mgx:placed-by player` besides, which is what makes "who put that
558
+ * there?" ground on an answer that names the player.
559
+ *
560
+ * Refuses, with the reason, when the cell does not parse, sits off the board,
561
+ * or holds a prop or another item. A cell an ANIMAL is standing on is allowed:
562
+ * dropping a morsel at a predator's feet is the trap the whole design hangs on,
563
+ * and refusing it would delete the mechanic.
564
+ */
565
+ export async function placeFood(memoryDir, {
566
+ layout, cell, config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
567
+ placedBy = "player", kind = null,
568
+ } = {}) {
569
+ const rows = readFactRows(await loadMemory(memoryDir));
570
+ const state = foldTownSquareState(rows);
571
+ const target = String(cell ?? "");
572
+ const parsed = parseCellId(target);
573
+ if (!parsed) return { placed: false, reason: `"${target || "that"}" doesn't name a cell — cells read like cell-3-4.` };
574
+ if (parsed.x < 1 || parsed.x > layout.gridSize || parsed.y < 1 || parsed.y > layout.gridSize) {
575
+ return { placed: false, reason: `${target} is off the board — this square runs cell-1-1 to cell-${layout.gridSize}-${layout.gridSize}.` };
576
+ }
577
+ if (layout.props.some((p) => p.cell === target)) return { placed: false, reason: `${target} is blocked.` };
578
+ const itemHere = [...state.placements.entries()].find(([id, place]) =>
579
+ isFoodId(id) && !state.removed.has(id) && place.cell === target);
580
+ if (itemHere) return { placed: false, reason: `${target} already holds ${itemHere[0]}.` };
581
+
582
+ const foodKind = kind ?? roles.food.placedKind;
583
+ const k = state.tickCount + 1;
584
+ const epoch = state.epoch;
585
+ const itemId = `${foodKind}-${1 + maxIdSuffix(state.placements.keys(), foodKind)}`;
586
+ const facts = [
587
+ { subject: itemId, predicate: "rdf:type", object: foodKind },
588
+ { subject: itemId, predicate: PLACED_BY_PREDICATE, object: placedBy },
589
+ { subject: snapshotSubject(itemId, k, epoch), predicate: PLACEMENT_PREDICATE, object: target },
590
+ { subject: snapshotSubject(itemId, k, epoch), predicate: MASS_PREDICATE, object: String(config.placedFoodMass) },
591
+ ];
592
+ const provenance = `${worldProvenanceTag(layout.name)}:taught:turn${k}`;
593
+ await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance })));
594
+ return {
595
+ placed: true,
596
+ item: itemId,
597
+ kind: foodKind,
598
+ cell: target,
599
+ mass: round2(config.placedFoodMass),
600
+ placedBy,
601
+ turn: k,
602
+ facts,
603
+ };
604
+ }
605
+
606
+ // ---- what stands on the board right now -------------------------------------------
607
+
608
+ /** Who and what is live in `state`, plus the derived maps a decision or a
609
+ * render needs: the two agent rosters, the item roster, which of those items
610
+ * count as food, and each item's cell and mass. `beliefCandidates` is the one
611
+ * sorted list every belief snapshot is taken against, so an observer's view of
612
+ * the board never depends on which caller asked.
613
+ *
614
+ * Read by both the tick and `townSquareBoard`, so a resting board and a ticking
615
+ * one can never disagree about who is on it. Pure.
616
+ *
617
+ * objectClassChain re-filters the whole row array per node it walks, so the
618
+ * food set is computed once here over the item roster and never inside an agent
619
+ * loop. Inside the prey loop it would cost roughly one full row scan per prey
620
+ * per item per tick — fine at three prey, unusable at the ten the slider
621
+ * offers. */
622
+ function readLiveBoard(rows, state, { config, roles }) {
623
+ const predators = liveOfKind(state, roles.predator.kind);
624
+ const prey = liveOfKind(state, roles.prey.kind);
625
+ const liveItemIds = [...liveOfKind(state, roles.food.spawnedKind), ...liveOfKind(state, roles.food.placedKind)].sort();
626
+ return {
627
+ predators,
628
+ prey,
629
+ liveItemIds,
630
+ foodIds: new Set(liveItemIds.filter((id) => objectClassChain(rows, id).includes("food"))),
631
+ itemCellOf: new Map(liveItemIds.map((id) => [id, state.placements.get(id).cell])),
632
+ itemMassOf: new Map(liveItemIds.map((id) => [id, state.mass.get(id)?.value ?? config.spawnedFoodMass])),
633
+ beliefCandidates: [...predators, ...prey, ...liveItemIds].sort(),
634
+ };
635
+ }
636
+
637
+ /** The render payload's `items` half — `{ id: { kind, cell } }` — over
638
+ * `liveItemIds`, skipping anything in `taken` (a tick's eaten set; a resting
639
+ * board passes none). Pure. */
640
+ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
641
+ const items = {};
642
+ for (const id of liveItemIds) {
643
+ if (taken && taken.has(id)) continue;
644
+ items[id] = { kind: state.types.get(id) ?? kindOfItem(id, roles), cell: itemCellOf.get(id) };
645
+ }
646
+ return items;
647
+ }
648
+
649
+ // ---- one tick --------------------------------------------------------------------
650
+
651
+ /**
652
+ * One full tick over `layout`: fold, believe, decide, move, run the ecology
653
+ * pass, and append everything as this turn's stamped facts in a single write.
654
+ *
655
+ * Predators move before prey, and that ordering is load-bearing for replay: a
656
+ * prey's belief is computed against the PRE-move predator positions (it reacts
657
+ * to where the predator was when it looked), while eating resolves on the
658
+ * post-move ones (it is caught where the predator actually ends up).
659
+ *
660
+ * Returns `{ turn, epoch, agents, items, ecology, rungs, writes }`. `agents`
661
+ * and `items` and `ecology` are the frozen render payload — see
662
+ * townSquareTickPayload, which projects exactly those three plus the turn.
663
+ * `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.
667
+ */
668
+ export async function runTownSquareTick(memoryDir, {
669
+ layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
670
+ } = {}) {
671
+ const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
672
+ if (!lay) throw new Error(`runTownSquareTick: no such layout "${layout}"`);
673
+ const rows = readFactRows(await loadMemory(memoryDir));
674
+ const state = foldTownSquareState(rows);
675
+ const k = state.tickCount + 1;
676
+ const epoch = state.epoch;
677
+ const stamp = (base) => snapshotSubject(base, k, epoch);
678
+ const applyActions = gridApplyActions(rows);
679
+
680
+ const { predators, prey, liveItemIds, foodIds, itemCellOf, itemMassOf, beliefCandidates } =
681
+ readLiveBoard(rows, state, { config, roles });
682
+ const movementWrites = [];
683
+ const postMovePlacements = new Map();
684
+ const agents = {};
685
+ const rungs = {};
686
+
687
+ const decide = (agentId, role) => {
688
+ const fromCell = parseCellId(state.placements.get(agentId).cell);
689
+ const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
690
+ const beliefOpts = { visionRadius, toldFacts };
691
+ const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
692
+ const threat = nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
693
+
694
+ let rung;
695
+ let nextCell;
696
+ let plan;
697
+ let goal;
698
+ let mood;
699
+ if (threat) {
700
+ rung = role === "predator" ? "avoid" : "evade";
701
+ nextCell = greedyAway(fromCell, threat.cell, applyActions);
702
+ plan = stepPlan(fromCell, nextCell);
703
+ goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
704
+ mood = "scared";
705
+ } else {
706
+ const quarry = role === "predator"
707
+ ? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
708
+ : nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, beliefOpts);
709
+ if (quarry) {
710
+ rung = role === "predator" ? "chase" : "forage";
711
+ const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
712
+ if (path && path.actions.length) {
713
+ nextCell = path.states[1];
714
+ plan = path.actions;
715
+ } else if (path) {
716
+ nextCell = fromCell;
717
+ plan = [];
718
+ } else {
719
+ nextCell = greedyToward(fromCell, quarry.cell, applyActions);
720
+ plan = stepPlan(fromCell, nextCell);
721
+ }
722
+ 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 });
724
+ mood = role === "predator" ? "angry" : "calm";
725
+ } else {
726
+ rung = "wander";
727
+ nextCell = seededWander(fromCell, applyActions, { layoutName: lay.name, epoch, turn: k, id: agentId });
728
+ plan = stepPlan(fromCell, nextCell);
729
+ goal = goalLine("wander");
730
+ mood = "calm";
731
+ }
732
+ }
733
+
734
+ const belief = beliefSnapshotFor(agentId, fromCell, beliefCandidates, state, beliefOpts);
735
+ const facing = plan[0] ?? state.facing.get(agentId)?.value ?? DEFAULT_FACING;
736
+ postMovePlacements.set(agentId, nextCell);
737
+ rungs[agentId] = rung;
738
+ agents[agentId] = { role, cell: cellId(nextCell.x, nextCell.y), facing, goal, mood, plan, mass: 0, belief };
739
+ };
740
+
741
+ for (const id of predators) decide(id, "predator");
742
+ for (const id of prey) decide(id, "prey");
743
+
744
+ const postMoveMass = new Map();
745
+ for (const id of [...predators, ...prey]) {
746
+ const role = agents[id].role;
747
+ const drain = role === "predator" ? config.predatorMassDecrementPerTurn : config.preyMassDecrementPerTurn;
748
+ const start = role === "predator" ? config.predatorInitialMass : config.preyInitialMass;
749
+ const prior = state.mass.get(id)?.value ?? start;
750
+ postMoveMass.set(id, Math.max(0, prior - drain));
751
+ }
752
+
753
+ for (const id of [...predators, ...prey]) {
754
+ const c = postMovePlacements.get(id);
755
+ movementWrites.push({ subject: stamp(id), predicate: PLACEMENT_PREDICATE, object: cellId(c.x, c.y) });
756
+ movementWrites.push({ subject: stamp(id), predicate: FACING_PREDICATE, object: agents[id].facing });
757
+ }
758
+
759
+ const pass = runEcologyPass({
760
+ state, layout: lay, roles, config, turn: k, epoch,
761
+ postMovePlacements, postMoveMass, liveItemIds, itemCellOf, itemMassOf, foodIds,
762
+ });
763
+
764
+ // A food the player put down before this tick was stamped with this same turn
765
+ // number, so it is already on the board for everyone's decisions above. It is
766
+ // reported first: a player action, not a step of the pass.
767
+ const placedThisTurn = liveItemIds
768
+ .filter((id) => state.placedBy.has(id) && state.placements.get(id).turn === k && state.placements.get(id).epoch === epoch)
769
+ .sort()
770
+ .map((id) => ({
771
+ type: "place-food",
772
+ item: id,
773
+ kind: state.types.get(id) ?? roles.food.placedKind,
774
+ cell: itemCellOf.get(id),
775
+ mass: round2(itemMassOf.get(id)),
776
+ placedBy: state.placedBy.get(id).by,
777
+ }));
778
+ const ecology = [...placedThisTurn, ...pass.events];
779
+
780
+ for (const id of Object.keys(agents)) agents[id].mass = round2(pass.finalMass.get(id) ?? 0);
781
+
782
+ // Goals were assigned during movement, before the pass resolved anything, so
783
+ // any goal naming something that died this tick is now stale — including a
784
+ // THIRD agent's, whose quarry somebody else took. Scrub those first, so the
785
+ // nicer "just ate" line below always wins for whoever actually ate.
786
+ const goneThisTurn = [...pass.takenAgents, ...pass.starvedThisTurn, ...pass.takenItems];
787
+ for (const id of Object.keys(agents)) {
788
+ for (const deadId of goneThisTurn) {
789
+ if (new RegExp(`${deadId}(?!\\d)`).test(agents[id].goal)) {
790
+ agents[id].goal = `${deadId} is gone — re-evaluating.`;
791
+ agents[id].mood = "calm";
792
+ break;
793
+ }
794
+ }
795
+ }
796
+ const ateBy = new Map();
797
+ for (const event of ecology) {
798
+ if (event.type === "eat-agent") {
799
+ if (!ateBy.has(event.predator)) ateBy.set(event.predator, []);
800
+ ateBy.get(event.predator).push(event.prey);
801
+ } else if (event.type === "eat-item") {
802
+ if (!ateBy.has(event.agent)) ateBy.set(event.agent, []);
803
+ ateBy.get(event.agent).push(event.item);
804
+ }
805
+ }
806
+ for (const id of [...pass.takenAgents, ...pass.starvedThisTurn]) delete agents[id];
807
+ for (const [eater, meals] of ateBy) {
808
+ if (!agents[eater]) continue;
809
+ agents[eater].goal = `just ate ${meals.join(" and ")}.`;
810
+ agents[eater].mood = "happy";
811
+ }
812
+ // A prey the pass minted arrives after the movement loops built `agents`, so
813
+ // without this it would be announced by its own turn's event and still be
814
+ // invisible on the board until the next one.
815
+ for (const arrival of pass.spawned) {
816
+ if (arrival.isItem) continue;
817
+ agents[arrival.id] = {
818
+ role: arrival.role, cell: arrival.cell, facing: DEFAULT_FACING,
819
+ goal: "just arrived — no goal yet.", mood: "calm", plan: [], mass: round2(arrival.mass), belief: {},
820
+ };
821
+ }
822
+
823
+ const moodWrites = Object.keys(agents).sort()
824
+ .map((id) => ({ subject: stamp(id), predicate: MOOD_PREDICATE, object: agents[id].mood }));
825
+
826
+ const items = itemsPayload(liveItemIds, { state, itemCellOf, roles, taken: pass.takenItems });
827
+ for (const arrival of pass.spawned) {
828
+ if (!arrival.isItem) continue;
829
+ items[arrival.id] = { kind: arrival.kind, cell: arrival.cell };
830
+ }
831
+
832
+ const turnMarker = { subject: stamp(BOARD_SUBJECT), predicate: TURN_PLAYED_PREDICATE, object: String(k) };
833
+ const writes = [...movementWrites, ...pass.writes, ...moodWrites, turnMarker];
834
+ await appendFacts(memoryDir, writes.map((f) => ({ ...f, provenance: `${worldProvenanceTag(lay.name)}:turn${k}` })));
835
+
836
+ return {
837
+ turn: k,
838
+ epoch,
839
+ agents: sortedByKey(agents),
840
+ items: sortedByKey(items),
841
+ ecology,
842
+ rungs: sortedByKey(rungs),
843
+ writes,
844
+ };
845
+ }
846
+
847
+ const kindOfItem = (id, roles) =>
848
+ (id.startsWith(`${roles.food.placedKind}-`) ? roles.food.placedKind : roles.food.spawnedKind);
849
+
850
+ const sortedByKey = (obj) => Object.fromEntries(Object.keys(obj).sort().map((key) => [key, obj[key]]));
851
+
852
+ /**
853
+ * The board as it stands, in a tick's own render payload shape, without
854
+ * running one: `{ turn, epoch, agents, items, ecology }`, folded straight out of
855
+ * the stored facts.
856
+ *
857
+ * This is what a renderer draws between opening a session and the first tick.
858
+ * Every field is read from state rather than decided: `cell`, `facing`, `mood`
859
+ * and `mass` are whatever the last write left, `belief` is the engine's own
860
+ * `beliefSnapshotFor` taken against the same candidate list a tick uses, and
861
+ * `plan` is empty with `goal` blank because nobody has decided anything yet —
862
+ * a resting board reports what is true, never a decision it has not made.
863
+ * `ecology` is empty for the same reason: no pass has run.
864
+ *
865
+ * `turn` is the last tick actually played (0 on a fresh board), so a caller's
866
+ * own turn counter can start from it.
867
+ */
868
+ export async function townSquareBoard(memoryDir, {
869
+ layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
870
+ } = {}) {
871
+ const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
872
+ if (!lay) throw new Error(`townSquareBoard: no such layout "${layout}"`);
873
+ const rows = readFactRows(await loadMemory(memoryDir));
874
+ const state = foldTownSquareState(rows);
875
+ const board = readLiveBoard(rows, state, { config, roles });
876
+
877
+ const agents = {};
878
+ const rostered = [
879
+ ...board.predators.map((id) => [id, "predator"]),
880
+ ...board.prey.map((id) => [id, "prey"]),
881
+ ];
882
+ for (const [id, role] of rostered) {
883
+ const cell = state.placements.get(id).cell;
884
+ const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
885
+ const initialMass = role === "predator" ? config.predatorInitialMass : config.preyInitialMass;
886
+ agents[id] = {
887
+ role,
888
+ cell,
889
+ facing: state.facing.get(id)?.value ?? DEFAULT_FACING,
890
+ goal: "",
891
+ mood: state.mood.get(id)?.value ?? "calm",
892
+ plan: [],
893
+ mass: round2(state.mass.get(id)?.value ?? initialMass),
894
+ belief: beliefSnapshotFor(id, parseCellId(cell), board.beliefCandidates, state, { visionRadius, toldFacts }),
895
+ };
896
+ }
897
+
898
+ return {
899
+ turn: state.tickCount,
900
+ epoch: state.epoch,
901
+ agents: sortedByKey(agents),
902
+ items: sortedByKey(itemsPayload(board.liveItemIds, { state, itemCellOf: board.itemCellOf, roles })),
903
+ ecology: [],
904
+ };
905
+ }
906
+
907
+ /** The frozen render payload, projected off a tick result: exactly
908
+ * `{ turn, agents, items, ecology }` and nothing else. The engine's own extras
909
+ * (the decision rungs, the raw writes, the epoch) stay out of it, so what a
910
+ * renderer or a recorded fixture sees is one shape that changes only when the
911
+ * interface itself does. */
912
+ export function townSquareTickPayload(tick) {
913
+ return { turn: tick.turn, agents: tick.agents, items: tick.items, ecology: tick.ecology };
914
+ }
915
+
916
+ export { isFoodId, isPropId };