@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,943 +0,0 @@
1
- // spider-fly.mjs — the headless spider-and-fly turn engine: state fold,
2
- // single-agent pathfinding, belief/visibility, greedy fly evasion, and the
3
- // egg/hatch/spawn/starve ecology pass, all reading and writing plain fact
4
- // rows through the shared memory store. No chat, no rendering — a later
5
- // piece of work wraps runSpiderFlyTick's return shape for a chat turn.
6
- //
7
- // Grid geometry (cellId, parseCellId, chebyshevDistance, isInWebBlock,
8
- // perimeterCells, DIRECTION_DELTA) is never redefined here — it all comes
9
- // from spider-fly-world.mjs, the one source of truth both the shipped world
10
- // pack and this engine read from. Vision-gated belief is the same deal: it
11
- // lives in the board-size-agnostic domain/agent-belief.mjs and is re-exported
12
- // below, so this file's public surface is unchanged by where it sits.
13
-
14
- import {
15
- WORLD_NAME, WEB_HOME, WEB_DURATION_TURNS, SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN,
16
- cellId, parseCellId, chebyshevDistance, isInWebBlock, perimeterCells,
17
- DIRECTION_DELTA, oneStepDirectionBetween,
18
- } from "../domain/spider-fly-world.mjs";
19
- import {
20
- DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor,
21
- } from "../domain/agent-belief.mjs";
22
- import { findActionPath, findReachableSet } from "../domain/planning.mjs";
23
- import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
24
- import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
25
- import { mulberry32 } from "../domain/seeded-random.mjs";
26
- import { fnv1a32 } from "../domain/hash.mjs";
27
- import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
28
-
29
- // ---- tunable constants (starting values, not fixed — the vision radius and
30
- // mass economy all want checking against a real playable board) -------------
31
-
32
- export const FLY_INITIAL_MASS = 10;
33
- export const FLY_MASS_DECREMENT_PER_TURN = 1;
34
- export const EGG_HATCH_DELAY_TURNS = 3;
35
- export const FLY_SPAWN_INTERVAL_TURNS = 3;
36
- export const EGG_LAY_MASS_THRESHOLD = 25;
37
- export const EGG_HATCH_COUNT = 2;
38
- export const MIN_HATCHLING_MASS = 3;
39
- export { SPIDER_INITIAL_MASS, SPIDER_MASS_DECREMENT_PER_TURN, WEB_DURATION_TURNS };
40
- export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
41
-
42
- // ---- seeded "randomness" (never Math.random) ---------------------------------
43
- // Every "random" decision (fly wander, fly/spawn placement) is a mulberry32
44
- // draw seeded by an fnv1a32 hash of a context string built from data already
45
- // in the facts (world name, turn number, the subject's own id, a purpose
46
- // tag) — the same hash-seeds-a-PRNG idiom answer-variants.mjs's phrase
47
- // selection already uses. Two runs from the same starting facts produce the
48
- // byte-identical sequence of "random" choices, never wall-clock driven.
49
-
50
- /** Deterministically pick one of `options` (must be non-empty), keyed on
51
- * `contextString`. */
52
- function seededPick(options, contextString) {
53
- const rng = mulberry32(fnv1a32(contextString));
54
- return options[Math.floor(rng() * options.length)];
55
- }
56
-
57
- // ---- the state fold ----------------------------------------------------------
58
-
59
- const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
60
-
61
- function splitSnapshot(subject) {
62
- const m = SNAPSHOT_RE.exec(subject);
63
- return m ? { base: m[1], turn: Number(m[2]) } : { base: subject, turn: 0 };
64
- }
65
-
66
- const WEB_ID_RE = /^web-\d+$/;
67
-
68
- /** Fold fact rows into the current spider-fly world state: per-subject
69
- * newest placement (mgx:currently-in), newest fly mass, spider's newest
70
- * flies-eaten count, each spider's newest carrying status (mgx:carrying —
71
- * a fly id, or "none"), each egg's laid-at-turn, each dynamic web's cell +
72
- * built-at turn, and the terminal eaten-by/starved/hatched-into markers that
73
- * make a subject no longer live. The turn counter is derived, never stored —
74
- * the largest @turnN suffix seen, exactly foldWorldState's own convention.
75
- * Pure. */
76
- export function foldSpiderFlyState(factRows) {
77
- const placements = new Map(); // subject -> { cell, turn }
78
- const mass = new Map(); // fly/spider subject -> { value, turn }
79
- const fliesEaten = new Map(); // spider subject -> { value, turn }
80
- const carrying = new Map(); // spider subject -> { flyId, turn } (flyId "none" omitted)
81
- const laidAtTurn = new Map(); // egg subject -> { value, turn }
82
- const webCell = new Map(); // web subject -> { cell, turn }
83
- const webBuiltAt = new Map(); // web subject -> { value, turn }
84
- const eatenBy = new Map(); // fly subject -> { spider, turn }
85
- const starved = new Set(); // fly/spider subject
86
- const hatchedInto = new Map(); // egg subject -> { spider, turn }
87
- let turnCount = 0;
88
-
89
- for (const row of factRows || []) {
90
- const { base, turn } = splitSnapshot(row.subject);
91
- if (turn) turnCount = Math.max(turnCount, turn);
92
-
93
- if (row.predicate === "mgx:currently-in") {
94
- const prior = placements.get(base);
95
- if (!prior || turn >= prior.turn) placements.set(base, { cell: row.object, turn });
96
- if (WEB_ID_RE.test(base)) {
97
- const priorWeb = webCell.get(base);
98
- if (!priorWeb || turn >= priorWeb.turn) webCell.set(base, { cell: row.object, turn });
99
- }
100
- continue;
101
- }
102
- if (row.predicate === "mgx:mass") {
103
- const prior = mass.get(base);
104
- if (!prior || turn >= prior.turn) mass.set(base, { value: Number(row.object), turn });
105
- continue;
106
- }
107
- if (row.predicate === "mgx:flies-eaten") {
108
- const prior = fliesEaten.get(base);
109
- if (!prior || turn >= prior.turn) fliesEaten.set(base, { value: Number(row.object), turn });
110
- continue;
111
- }
112
- if (row.predicate === "mgx:carrying") {
113
- const prior = carrying.get(base);
114
- if (!prior || turn >= prior.turn) {
115
- if (row.object === "none") carrying.delete(base);
116
- else carrying.set(base, { flyId: row.object, turn });
117
- }
118
- continue;
119
- }
120
- if (row.predicate === "mgx:laid-at-turn") {
121
- const prior = laidAtTurn.get(base);
122
- if (!prior || turn >= prior.turn) laidAtTurn.set(base, { value: Number(row.object), turn });
123
- continue;
124
- }
125
- if (row.predicate === "mgx:web-built-at-turn") {
126
- const prior = webBuiltAt.get(base);
127
- if (!prior || turn >= prior.turn) webBuiltAt.set(base, { value: Number(row.object), turn });
128
- continue;
129
- }
130
- if (row.predicate === "mgx:eaten-by") {
131
- const prior = eatenBy.get(base);
132
- if (!prior || turn >= prior.turn) eatenBy.set(base, { spider: row.object, turn });
133
- continue;
134
- }
135
- if (row.predicate === "mgx:starved") { starved.add(base); continue; }
136
- if (row.predicate === "mgx:hatched-into") {
137
- const prior = hatchedInto.get(base);
138
- if (!prior || turn >= prior.turn) hatchedInto.set(base, { spider: row.object, turn });
139
- continue;
140
- }
141
- }
142
-
143
- const webs = new Map(); // web subject -> { cell, builtAtTurn }
144
- for (const [id, { cell }] of webCell) {
145
- const builtAtTurn = webBuiltAt.get(id)?.value;
146
- if (builtAtTurn !== undefined) webs.set(id, { cell, builtAtTurn });
147
- }
148
-
149
- const removed = new Set([...eatenBy.keys(), ...starved, ...hatchedInto.keys()]);
150
- return { placements, mass, fliesEaten, carrying, laidAtTurn, webs, eatenBy, starved, hatchedInto, removed, turnCount };
151
- }
152
-
153
- const sortedLiveSubjects = (state, re) =>
154
- [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
155
-
156
- function maxIdSuffix(ids, re) {
157
- let max = 0;
158
- for (const id of ids) {
159
- const m = re.exec(id);
160
- if (m) max = Math.max(max, Number(m[1]));
161
- }
162
- return max;
163
- }
164
-
165
- // ---- single-agent pathfinding (§5): hand-written applyActions over the
166
- // world pack's own has-exit-<direction> facts, NOT the taught action-rule
167
- // DSL, whose no-incoming/comparator precondition shapes cannot express grid
168
- // adjacency (the same limitation Ashcombe's own runWorldCommand worked
169
- // around). State is the plain {x, y} coordinate the search kernel treats as
170
- // opaque. ---------------------------------------------------------------------
171
-
172
- const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
173
-
174
- /** The spider/fly movement applyActions closure findActionPath/
175
- * findReachableSet need: one hop per has-exit-<direction> fact reachable
176
- * from a cell, in the fixed direction order both agents' search shares for
177
- * deterministic tie-breaking. Built once per tick from the world's own
178
- * static exit facts (grid topology is common knowledge to both agents). */
179
- export function gridApplyActions(factRows) {
180
- const exits = new Map();
181
- for (const row of factRows || []) {
182
- const m = EXIT_PREDICATE_RE.exec(row.predicate);
183
- if (!m) continue;
184
- if (!exits.has(row.subject)) exits.set(row.subject, new Map());
185
- exits.get(row.subject).set(m[1], row.object);
186
- }
187
- return (state) => {
188
- const out = [];
189
- const dirs = exits.get(cellId(state.x, state.y));
190
- if (!dirs) return out;
191
- for (const direction of Object.keys(DIRECTION_DELTA)) {
192
- const target = dirs.get(direction);
193
- if (!target) continue;
194
- const parsed = parseCellId(target);
195
- if (!parsed) continue;
196
- out.push({ action: direction, nextState: { x: parsed.x, y: parsed.y } });
197
- }
198
- return out;
199
- };
200
- }
201
-
202
- /** Canonicalizes a grid-position search state onto its cell alone — the
203
- * only field that matters for movement dedup (mass/eaten-count ride
204
- * elsewhere on the folded state, never on the path-search state itself). */
205
- export const spiderPathStateKey = (state) => cellId(state.x, state.y);
206
-
207
- /** Whether (x, y) is currently webbed — the static home zone (always active)
208
- * OR a live spider-built web (mgx:web-built-at-turn + webDurationTurns >
209
- * turn). The one predicate every eat precondition and the fly's movement
210
- * gate consult, so the static zone and dynamic webs are ONE concept. `state`
211
- * may be omitted (or carry no `webs` map) — the static-zone check alone
212
- * still answers correctly, just blind to dynamic webs; every real caller
213
- * threads the folded state through. `webDurationTurns` defaults to the
214
- * shipped WEB_DURATION_TURNS; a caller holding a resolved game config passes
215
- * its own webDurationTurns instead. */
216
- export function hasActiveWebAt(x, y, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
217
- if (isInWebBlock(x, y)) return true;
218
- if (!state?.webs?.size) return false;
219
- const target = cellId(x, y);
220
- for (const { cell, builtAtTurn } of state.webs.values()) {
221
- if (cell === target && builtAtTurn + webDurationTurns > turn) return true;
222
- }
223
- return false;
224
- }
225
-
226
- /** The spider's multi-step path: findActionPath wired with isGoal =
227
- * "co-located with the believed fly cell" — mere co-location, not
228
- * co-location-in-a-web. Catching a fly (the ecology pass's own catch step)
229
- * never needs a web; only the SEPARATE eat step does, once a caught fly is
230
- * actually carried into one (planSpiderPathToWeb's own job, run by the
231
- * movement priority a carrying spider takes over next tick). Null when no
232
- * believed target exists at all. `state`/`turn`/`webDurationTurns` are
233
- * accepted for signature symmetry with planSpiderPathToWeb and every other
234
- * caller in this file, but this function's own isGoal no longer reads
235
- * them. */
236
- export function planSpiderPath(spiderCell, believedFlyCell, applyActions, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
237
- if (!believedFlyCell) return null;
238
- const isGoal = (s) => s.x === believedFlyCell.x && s.y === believedFlyCell.y;
239
- return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
240
- }
241
-
242
- /** A carrying spider's own multi-step path home: findActionPath wired with
243
- * isGoal = "any actively-webbed cell" (hasActiveWebAt, reused directly,
244
- * unlike planSpiderPath there is no specific target cell — any live web
245
- * will do). Used by the movement priority (below) for a spider already
246
- * holding a fly: it races the fly to the nearest web rather than
247
- * continuing to chase. Returns null when no active web is reachable at all
248
- * (the caller falls back to greedySpiderApproach toward the static web's
249
- * home cell). */
250
- export function planSpiderPathToWeb(spiderCell, applyActions, state, turn, webDurationTurns = WEB_DURATION_TURNS) {
251
- const isGoal = (s) => hasActiveWebAt(s.x, s.y, state, turn, webDurationTurns);
252
- return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
253
- }
254
-
255
- // ---- one-ply greedy scoring, shared by the fly's evasion and the spider's
256
- // fallback chase (§5 confirmed decision: greedy distance-scoring over
257
- // findReachableSet's one-ply output, plus staying put — no lookahead, no
258
- // simulation of the other agent's plan). -------------------------------------
259
-
260
- function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
261
- const options = [fromCell, ...findReachableSet(fromCell, applyActions, { maxDepth: 1 }).map((r) => r.node)];
262
- let best = options[0];
263
- let bestScore = scoreOf(best);
264
- for (let i = 1; i < options.length; i += 1) {
265
- const score = scoreOf(options[i]);
266
- if (isBetter(score, bestScore)) { bestScore = score; best = options[i]; }
267
- }
268
- return best;
269
- }
270
-
271
- /** A fly with no believed spider position wanders instead of holding still:
272
- * a seeded, uniform pick among staying put or any one-ply reachable cell,
273
- * keyed on this turn + the fly's own id (purpose "wander") — deterministic
274
- * and replayable, never Math.random. Looks random to a human watching. The
275
- * caller is responsible for skipping this entirely when the fly sits in an
276
- * active web this tick (a webbed fly can't move at all, wander or not). */
277
- export function randomFlyWander(flyCell, applyActions, turn, flyId) {
278
- const options = [flyCell, ...findReachableSet(flyCell, applyActions, { maxDepth: 1 }).map((r) => r.node)];
279
- return seededPick(options, `${WORLD_NAME}:${turn}:${flyId}:wander`);
280
- }
281
-
282
- /** The fly's one move this turn: score every one-ply reachable cell (plus
283
- * staying put) by Chebyshev distance from the fly's believed spider
284
- * position, move to the highest-scoring cell. A fly with no believed
285
- * spider position wanders instead (randomFlyWander) — `turn`/`flyId` key
286
- * that seeded draw. */
287
- export function greedyFlyMove(flyCell, believedSpiderCell, applyActions, turn, flyId) {
288
- if (!believedSpiderCell) return randomFlyWander(flyCell, applyActions, turn, flyId);
289
- return bestOneStepBy(
290
- flyCell, applyActions,
291
- (cell) => chebyshevDistance(cell.x, cell.y, believedSpiderCell.x, believedSpiderCell.y),
292
- (score, bestScore) => score > bestScore,
293
- );
294
- }
295
-
296
- /** The spider's fallback move when no in-web path exists yet (the fly's
297
- * believed cell is outside the web, or currently unreachable): the same
298
- * one-ply kernel as the fly's evasion, scored the opposite way — close
299
- * distance instead of open it. */
300
- export function greedySpiderApproach(spiderCell, believedFlyCell, applyActions) {
301
- if (!believedFlyCell) return spiderCell;
302
- return bestOneStepBy(
303
- spiderCell, applyActions,
304
- (cell) => chebyshevDistance(cell.x, cell.y, believedFlyCell.x, believedFlyCell.y),
305
- (score, bestScore) => score < bestScore,
306
- );
307
- }
308
-
309
- /** A spider's move when another live spider is believed visible: the mirror
310
- * image of greedyFlyMove's evasion — score every one-ply reachable cell
311
- * (plus staying put) by Chebyshev distance from the other spider's believed
312
- * position, move to the highest-scoring (furthest) cell. Priority branch 1
313
- * of §5's avoid-spiders > chase-flies > hold-and-web ordering. */
314
- export function greedySpiderAvoid(spiderCell, believedOtherSpiderCell, applyActions) {
315
- if (!believedOtherSpiderCell) return spiderCell;
316
- return bestOneStepBy(
317
- spiderCell, applyActions,
318
- (cell) => chebyshevDistance(cell.x, cell.y, believedOtherSpiderCell.x, believedOtherSpiderCell.y),
319
- (score, bestScore) => score > bestScore,
320
- );
321
- }
322
-
323
- // ---- visibility and belief (§4): static grid topology is common knowledge
324
- // to both agents; only dynamic entity positions are gated by vision, in
325
- // domain/agent-belief.mjs (imported and re-exported above). A told fact —
326
- // shaped { subject, toAgent, cell, turn } — is the chat-integration channel
327
- // into it, defaulting to empty so belief with no chat is exactly "what's
328
- // currently visible." --------------------------------------------------------
329
-
330
- // ---- the ecology pass (§10): catch, eat, lay, hatch, spawn, starve, all as
331
- // ordinary turn-gated checks in one fixed-order pass. Order matters and is
332
- // fixed deliberately: catch first (a spider not yet carrying claims an
333
- // uncarried live fly it now shares a cell with, web or not), then eat (only
334
- // a carrying spider standing in an active web actually consumes its catch —
335
- // predation resolves on the turn's fresh positions), then starve (a fly
336
- // already claimed by an eat this turn cannot also starve), then lay (reads
337
- // the egg slot as it stood BEFORE this tick's own hatch, so a hatch and a
338
- // fresh lay never land the same turn), then hatch, then spawn (reads the
339
- // board as every earlier step in this same pass left it, so a fly never
340
- // spawns on a cell an eat/hatch just vacated or occupied). ------------------
341
-
342
- /**
343
- * One ecology pass over the tick's post-movement state: `postMovePlacements`
344
- * is a Map(subject -> {x,y}) for every currently-live spider and fly after
345
- * this turn's movement writes; `postMoveMassByFly`/`postMoveMassBySpider` are
346
- * Map(subject -> number), the mass after this turn's decrement, pre-removal
347
- * (`postMoveMassBySpider` is optional — a spider absent from it is simply
348
- * never starve-checked, so callers that don't track spider mass, e.g. older
349
- * tests, see no behavior change). `state` is the PRE-move fold (for history:
350
- * prior flies-eaten counts, prior carrying status, prior eggs, live webs).
351
- * `config` (default DEFAULT_GAME_CONFIG.spiderFly) supplies every tunable
352
- * this pass reads: the initial masses a fallback/hatch/spawn mints, the
353
- * egg-lay mass threshold, the hatch delay/count, the minimum hatchling mass,
354
- * the spawn interval, and the web duration the eat precondition checks
355
- * against.
356
- * Returns `{ writes, events }` — writes to append alongside the turn's
357
- * movement facts, events for the tick's own return payload. Pure.
358
- */
359
- export function runEcologyPass({
360
- state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider = new Map(), turn,
361
- config = DEFAULT_GAME_CONFIG.spiderFly,
362
- }) {
363
- const k = turn;
364
- const writes = [];
365
- const events = {
366
- caught: [], eaten: [], starved: [], laid: null, hatched: [], spawned: null, spawnedCell: null,
367
- massAfterEating: new Map(),
368
- };
369
-
370
- const spiders = [...postMovePlacements.keys()].filter((id) => /^spider-\d+$/.test(id)).sort();
371
- const flies = [...postMovePlacements.keys()].filter((id) => /^fly-\d+$/.test(id)).sort();
372
-
373
- // 1. Catch — a spider not already carrying a live fly claims the first
374
- // uncarried live fly it shares a cell with this tick (web or not — the web
375
- // only matters for the EAT step below). At most one catch per spider per
376
- // tick. Starts from whatever every live spider was already carrying
377
- // BEFORE this tick (state.carrying, filtered to a still-live captor and a
378
- // still-live fly — a captor that died drops its stale carrying claim,
379
- // freeing the fly to move independently again from the next tick on).
380
- const carryingBySpider = new Map(); // spiderId -> flyId, this tick's working belief
381
- for (const [spiderId, { flyId }] of state.carrying) {
382
- if (postMovePlacements.has(spiderId) && postMovePlacements.has(flyId)) carryingBySpider.set(spiderId, flyId);
383
- }
384
- const carriedFlyIds = new Set(carryingBySpider.values());
385
- for (const spiderId of spiders) {
386
- if (carryingBySpider.has(spiderId)) continue;
387
- const sCell = postMovePlacements.get(spiderId);
388
- for (const flyId of flies) {
389
- if (carriedFlyIds.has(flyId)) continue;
390
- const fCell = postMovePlacements.get(flyId);
391
- if (sCell.x !== fCell.x || sCell.y !== fCell.y) continue;
392
- carryingBySpider.set(spiderId, flyId);
393
- carriedFlyIds.add(flyId);
394
- events.caught.push({ spider: spiderId, fly: flyId, cell: cellId(sCell.x, sCell.y) });
395
- break;
396
- }
397
- }
398
-
399
- // 2. Eat — a spider carrying a fly AND standing in an actively-webbed cell
400
- // (static home zone or a live dynamic web) consumes it. The eating spider
401
- // gains exactly the fly's post-decrement remaining mass, not a flat bonus.
402
- const claimedFlies = new Set();
403
- const eatenDeltaBySpider = new Map();
404
- const eatenMassBySpider = new Map();
405
- for (const spiderId of spiders) {
406
- const flyId = carryingBySpider.get(spiderId);
407
- if (!flyId) continue;
408
- const sCell = postMovePlacements.get(spiderId);
409
- if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
410
- carryingBySpider.delete(spiderId);
411
- claimedFlies.add(flyId);
412
- eatenDeltaBySpider.set(spiderId, (eatenDeltaBySpider.get(spiderId) ?? 0) + 1);
413
- eatenMassBySpider.set(spiderId, (eatenMassBySpider.get(spiderId) ?? 0) + (postMoveMassByFly.get(flyId) ?? 0));
414
- writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:eaten-by", object: spiderId });
415
- events.eaten.push({ fly: flyId, spider: spiderId, cell: cellId(sCell.x, sCell.y) });
416
- }
417
- for (const [spiderId, delta] of eatenDeltaBySpider) {
418
- const newCount = (state.fliesEaten.get(spiderId)?.value ?? 0) + delta;
419
- writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:flies-eaten", object: String(newCount) });
420
- const priorSpiderMass = postMoveMassBySpider.get(spiderId) ?? (state.mass.get(spiderId)?.value ?? config.spiderInitialMass);
421
- const newSpiderMass = priorSpiderMass + (eatenMassBySpider.get(spiderId) ?? 0);
422
- writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newSpiderMass) });
423
- events.massAfterEating.set(spiderId, newSpiderMass);
424
- }
425
-
426
- // 3. Starve — mass reached zero, and not already claimed by this turn's
427
- // eat. Spiders waste away the same as flies; a spider that just ate
428
- // survives regardless (eat resolves first). A carried-but-not-yet-eaten
429
- // fly starves exactly like a free one — its captor's carrying claim is
430
- // dropped below so the spider doesn't keep "holding" a dead fly.
431
- for (const flyId of flies) {
432
- if (claimedFlies.has(flyId)) continue;
433
- if ((postMoveMassByFly.get(flyId) ?? 0) <= 0) {
434
- writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:starved", object: "true" });
435
- events.starved.push(flyId);
436
- }
437
- }
438
- const deadFliesThisTick = new Set([...claimedFlies, ...events.starved]);
439
- for (const [spiderId, flyId] of carryingBySpider) {
440
- if (events.starved.includes(flyId)) carryingBySpider.delete(spiderId);
441
- }
442
- for (const spiderId of spiders) {
443
- if (eatenDeltaBySpider.has(spiderId)) continue;
444
- if (!postMoveMassBySpider.has(spiderId)) continue;
445
- if (postMoveMassBySpider.get(spiderId) <= 0) {
446
- writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:starved", object: "true" });
447
- events.starved.push(spiderId);
448
- }
449
- }
450
-
451
- // Every live spider re-asserts its own mgx:carrying fact every tick (the
452
- // same always-rewritten idiom mgx:currently-in/mgx:mass already use) — a
453
- // merely-stopped write would leave a stale "carrying" row standing forever
454
- // for the fold to keep honoring long after this tick's catch/eat actually
455
- // changed it.
456
- for (const spiderId of spiders) {
457
- writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:carrying", object: carryingBySpider.get(spiderId) ?? "none" });
458
- }
459
-
460
- // 4. Lay — a spider's mass has reached the lay threshold AND it is
461
- // standing in an active web, with no live egg outstanding right now. The
462
- // laying spider resets to exactly its own initial mass; the surplus
463
- // becomes the egg's own starting mass (an egg is never laid below the
464
- // initial-mass reset, so the surplus is always >= 0).
465
- const liveEggId = [...state.laidAtTurn.keys()].find((id) => !state.removed.has(id));
466
- if (!liveEggId) {
467
- for (const spiderId of spiders) {
468
- const sCell = postMovePlacements.get(spiderId);
469
- if (!hasActiveWebAt(sCell.x, sCell.y, state, k, config.webDurationTurns)) continue;
470
- const spiderMass = eatenDeltaBySpider.has(spiderId)
471
- ? events.massAfterEating.get(spiderId)
472
- : (postMoveMassBySpider.get(spiderId) ?? state.mass.get(spiderId)?.value ?? config.spiderInitialMass);
473
- if (spiderMass < config.eggLayMassThreshold) continue;
474
- const eggMass = spiderMass - config.spiderInitialMass;
475
- writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(config.spiderInitialMass) });
476
- const eggId = `egg-${1 + maxIdSuffix(state.placements.keys(), /^egg-(\d+)$/)}`;
477
- const eggCellId = cellId(sCell.x, sCell.y);
478
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:currently-in", object: eggCellId });
479
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:laid-at-turn", object: String(k) });
480
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:mass", object: String(eggMass) });
481
- events.laid = eggId;
482
- break; // one-egg-at-a-time cap — the first qualifying spider lays it
483
- }
484
- }
485
-
486
- // 5. Hatch — any live egg laid exactly config.eggHatchDelayTurns turns ago
487
- // hatches into config.eggHatchCount spiders, capped by a floor on COUNT
488
- // (never on hatch mass): actualHatchCount = max(1, min(eggHatchCount,
489
- // floor(eggMass / minHatchlingMass))) — a too-small egg produces fewer,
490
- // still-viable hatchlings rather than emaciated ones. The egg's mass
491
- // splits evenly, remainder to the lowest-numbered hatchling.
492
- const liveEggIds = [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort();
493
- let nextSpiderNum = 1 + maxIdSuffix(state.placements.keys(), /^spider-(\d+)$/);
494
- for (const eggId of liveEggIds) {
495
- const laidTurn = state.laidAtTurn.get(eggId).value;
496
- if (laidTurn + config.eggHatchDelayTurns !== k) continue;
497
- const eggCell = state.placements.get(eggId)?.cell;
498
- if (!eggCell) continue;
499
- const eggMass = state.mass.get(eggId)?.value ?? config.spiderInitialMass;
500
- const hatchCount = Math.max(1, Math.min(config.eggHatchCount, Math.floor(eggMass / config.minHatchlingMass)));
501
- const share = Math.floor(eggMass / hatchCount);
502
- const remainder = eggMass - share * hatchCount;
503
- const hatchlings = [];
504
- for (let i = 0; i < hatchCount; i += 1) {
505
- const newSpiderId = `spider-${nextSpiderNum}`;
506
- nextSpiderNum += 1;
507
- const hatchlingMass = share + (i === 0 ? remainder : 0);
508
- writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
509
- writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:mass", object: String(hatchlingMass) });
510
- hatchlings.push({ spider: newSpiderId, mass: hatchlingMass });
511
- }
512
- writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: hatchlings[0].spider });
513
- events.hatched.push({ egg: eggId, cell: eggCell, spiders: hatchlings });
514
- }
515
-
516
- // 6. Spawn — every third turn, a new fly at a seeded pick among the
517
- // currently-uncontested perimeter cells (never Math.random — see
518
- // seededPick's own header comment).
519
- if (k % config.flySpawnIntervalTurns === 0) {
520
- const occupied = new Set();
521
- for (const spiderId of spiders) { const c = postMovePlacements.get(spiderId); occupied.add(cellId(c.x, c.y)); }
522
- for (const flyId of flies) {
523
- if (deadFliesThisTick.has(flyId)) continue;
524
- const c = postMovePlacements.get(flyId);
525
- occupied.add(cellId(c.x, c.y));
526
- }
527
- for (const eggId of liveEggIds) {
528
- if (events.hatched.some((h) => h.egg === eggId)) continue;
529
- occupied.add(state.placements.get(eggId)?.cell);
530
- }
531
- for (const h of events.hatched) occupied.add(h.cell);
532
- const uncontested = perimeterCells().filter((c) => !occupied.has(c));
533
- if (uncontested.length) {
534
- const newFlyId = `fly-${1 + maxIdSuffix(state.placements.keys(), /^fly-(\d+)$/)}`;
535
- const cell = seededPick(uncontested, `${WORLD_NAME}:${k}:${newFlyId}:spawn`);
536
- writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:currently-in", object: cell });
537
- writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:mass", object: String(config.flyInitialMass) });
538
- events.spawned = newFlyId;
539
- events.spawnedCell = cell;
540
- }
541
- }
542
-
543
- return { writes, events };
544
- }
545
-
546
- // ---- bootstrap and the per-tick orchestration --------------------------------
547
-
548
- /** Mints spider-1 at the web's home cell and a spread of flies onto the
549
- * board perimeter — a fresh session's own starting state, never part of
550
- * the shipped (reusable, static) world pack itself. A no-op when spider-1
551
- * already exists (idempotent — safe to call from a caller unsure whether
552
- * the game has already started). `config` (default
553
- * DEFAULT_GAME_CONFIG.spiderFly) supplies the starting masses. Every minted
554
- * agent also gets a starting mgx:feels mood (`calm` — nothing has a goal
555
- * yet), so a mood fact exists for an individual from the moment the
556
- * individual does. */
557
- export async function startSpiderFlyGame(memoryDir, { flyCount = 1, config = DEFAULT_GAME_CONFIG.spiderFly } = {}) {
558
- const state = foldSpiderFlyState(readFactRows(await loadMemory(memoryDir)));
559
- if (state.placements.has("spider-1")) return { started: false, facts: [] };
560
-
561
- const perimeter = perimeterCells();
562
- const facts = [
563
- { subject: "spider-1", predicate: "mgx:currently-in", object: cellId(WEB_HOME.x, WEB_HOME.y) },
564
- { subject: "spider-1", predicate: "mgx:mass", object: String(config.spiderInitialMass) },
565
- { subject: "spider-1", predicate: "mgx:feels", object: "calm" },
566
- ];
567
- const occupied = new Set([cellId(WEB_HOME.x, WEB_HOME.y)]);
568
- for (let i = 0; i < flyCount; i += 1) {
569
- const flyId = `fly-${i + 1}`;
570
- const uncontested = perimeter.filter((c) => !occupied.has(c));
571
- const cell = seededPick(uncontested.length ? uncontested : perimeter, `${WORLD_NAME}:0:${flyId}:spawn`);
572
- occupied.add(cell);
573
- facts.push({ subject: flyId, predicate: "mgx:currently-in", object: cell });
574
- facts.push({ subject: flyId, predicate: "mgx:mass", object: String(config.flyInitialMass) });
575
- facts.push({ subject: flyId, predicate: "mgx:feels", object: "calm" });
576
- }
577
- await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(WORLD_NAME) })));
578
- return { started: true, facts };
579
- }
580
-
581
- // ---- the goal line and the mood word -----------------------------------------
582
- // Every branch of the tick's own priority chain assigns a `mood` word beside
583
- // the `goal` sentence it renders, and that word is appended as a real
584
- // mgx:feels fact for the turn (runSpiderFlyTick's moodWrites), the same
585
- // per-agent-per-turn treatment mgx:currently-in and mgx:mass already get.
586
- // Two reasons it is a fact rather than something a renderer re-derives. A
587
- // question about "a happy spider" has to bind against the store. And this
588
- // branch is the one that knows the mood, so recovering it downstream means
589
- // re-reading the prose this file already wrote.
590
- //
591
- // The words are a subset of sprite-expressions.mjs's own six-word
592
- // EXPRESSION_PALETTE (`sad` and `surprised` have no state in this game's goal
593
- // chain). Operator-confirmed mapping: a spider that just ate is happy; a
594
- // spider carrying a fly or mid-chase is angry (predatory focus); a spider
595
- // avoiding another spider is scared; a spider holding position or building a
596
- // web is calm. A fly evading a believed-visible spider is scared, and so is
597
- // the sharper form of the same fear — just caught, being carried, or trapped
598
- // in a web; a fly with nothing in sight is calm. `calm` is also the
599
- // no-strong-emotion baseline for an agent with no goal yet: freshly hatched,
600
- // freshly spawned, or re-evaluating because the agent its goal named died
601
- // this tick.
602
-
603
- function goalLineFor(subject, believed, arrived, kind) {
604
- if (kind === "spider-carrying") return `carrying ${believed.subject} toward the web.`;
605
- if (kind === "spider-carrying-delivered") return `carrying ${believed.subject} — already in the web, delivering it now.`;
606
- if (kind === "spider-avoid") return `avoiding ${believed.subject}, last seen at ${cellId(believed.cell.x, believed.cell.y)}.`;
607
- if (!believed) return kind === "spider" ? "no fly in sight — holding position in the web." : "no spider in sight — wandering.";
608
- const seenAt = cellId(believed.cell.x, believed.cell.y);
609
- if (kind === "spider") {
610
- return arrived
611
- ? `co-located with ${believed.subject} — catching it.`
612
- : `chasing ${believed.subject}, last seen at ${seenAt}.`;
613
- }
614
- return `evading — last saw ${believed.subject} at ${seenAt}.`;
615
- }
616
-
617
- /** A one-step "plan" for a greedy or held move — the direction from
618
- * `fromCell` to `toCell` as a length-1 array, or `[]` when the agent held
619
- * still. Every agent's `plan` field is populated this way when its move
620
- * came from one-ply greedy scoring or holding; a genuine multi-step search
621
- * result (planSpiderPath/planSpiderPathToWeb) supplies its own full
622
- * direction list instead — either way, `plan[0]` is always the direction
623
- * actually taken this tick — the facing driver a renderer keys sprite
624
- * orientation on. */
625
- function stepPlan(fromCell, toCell) {
626
- const direction = oneStepDirectionBetween(fromCell, toCell);
627
- return direction ? [direction] : [];
628
- }
629
-
630
- /** Live (unexpired, by `turn`) dynamic webs from a `Map(webId -> {cell,
631
- * builtAtTurn})` (either a folded state's own `.webs`, or that widened with
632
- * web(s) minted THIS tick before they've been written/read back), as a
633
- * plain array of { id, cell, builtAtTurn, expiresAtTurn }. Excludes the
634
- * always-on static home zone (that's WEB_HOME/WEB_RADIUS, drawn separately —
635
- * this is only the spider-built kind), for a renderer to draw distinctly.
636
- * `webDurationTurns` defaults to the shipped WEB_DURATION_TURNS; a caller
637
- * holding a resolved game config passes its own webDurationTurns instead. */
638
- export function liveWebs(websMap, turn, webDurationTurns = WEB_DURATION_TURNS) {
639
- const out = [];
640
- for (const [id, { cell, builtAtTurn }] of websMap) {
641
- if (builtAtTurn + webDurationTurns > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + webDurationTurns });
642
- }
643
- return out;
644
- }
645
-
646
- /**
647
- * One full tick: fold state, compute each live spider's and fly's belief,
648
- * replan/re-score, execute one movement step per agent (spiders: carrying-
649
- * not-yet-delivered > avoid other spiders > chase flies > hold-and-web;
650
- * flies: carried (inert) > evade > wander, unless trapped in an active web),
651
- * run the ecology pass, and append everything as this turn's @turnN facts in
652
- * one write. `opts.toldFacts` is the belief layer's chat-integration
653
- * extension point (§4) — an array of `{ subject, toAgent, cell, turn }`
654
- * rows, empty until a later piece of work wires chat-told positions through
655
- * it.
656
- *
657
- * Returns `{ turn, writes, agents, ecology, activeWebs }`: `agents` is keyed
658
- * by every live spider/fly subject after this tick, each `{ cell, goal, mood,
659
- * plan, mass, belief }` — `mood` is the branch's own mood word (see the goal
660
- * line and mood word section above), also appended as this turn's mgx:feels
661
- * fact, so a renderer reads the structured word rather than parsing `goal`
662
- * back apart; `plan` is the direction sequence that produced
663
- * THIS tick's move (a full multi-step search result when one was found,
664
- * else a length-1 array for a single greedy step, else `[]` when the agent
665
- * held still — `plan[0]` is always the direction actually taken, the facing
666
- * driver a renderer keys sprite orientation on); `belief` is
667
- * `{ [otherAgentId]: cellId | null }`,
668
- * this agent's own believed position for every other live agent (never
669
- * ground truth — see beliefSnapshotFor). `ecology` is the tick's own
670
- * caught/eaten/starved/laid/hatched/spawned event summary; `activeWebs` is
671
- * every currently-live dynamic web (static home zone excluded — that's
672
- * fixed grid geometry, not runtime state), for a renderer to draw
673
- * distinctly.
674
- *
675
- * `opts.config` (default DEFAULT_GAME_CONFIG.spiderFly) supplies every
676
- * tunable this tick reads: each class's own vision radius, both agents'
677
- * starting/decrement masses, and the web duration, and is forwarded
678
- * unchanged into runEcologyPass.
679
- */
680
- export async function runSpiderFlyTick(memoryDir, opts = {}) {
681
- const { toldFacts = [], config = DEFAULT_GAME_CONFIG.spiderFly } = opts;
682
- const rows = readFactRows(await loadMemory(memoryDir));
683
- const state = foldSpiderFlyState(rows);
684
- const k = state.turnCount + 1;
685
- const applyActions = gridApplyActions(rows);
686
-
687
- const spiders = sortedLiveSubjects(state, /^spider-\d+$/);
688
- const flies = sortedLiveSubjects(state, /^fly-\d+$/);
689
-
690
- const movementWrites = [];
691
- const postMovePlacements = new Map();
692
- const postMoveMassByFly = new Map();
693
- const postMoveMassBySpider = new Map();
694
- const agents = {};
695
- const tickWebs = new Map(state.webs); // widened in place as spiders build/refresh this tick
696
- let nextWebNum = 1 + maxIdSuffix(state.webs.keys(), /^web-(\d+)$/);
697
-
698
- for (const spiderId of spiders) {
699
- const spiderCell = parseCellId(state.placements.get(spiderId).cell);
700
- const priorMass = state.mass.get(spiderId)?.value ?? config.spiderInitialMass;
701
- const newMass = Math.max(0, priorMass - config.spiderMassDecrementPerTurn);
702
- postMoveMassBySpider.set(spiderId, newMass);
703
-
704
- const otherSpiders = spiders.filter((id) => id !== spiderId);
705
- const belief = beliefSnapshotFor(spiderId, spiderCell, [...otherSpiders, ...flies], state, { visionRadius: config.spiderVisionRadius, toldFacts });
706
-
707
- // Priority 0: carrying a fly, above everything else — a
708
- // carrying spider never drops its catch to avoid another spider or
709
- // chase a second one (no spider-eats-spider mechanic exists anywhere in
710
- // this engine, so "avoid" is resource contention, not survival, and
711
- // dropping/abandoning the catch would undermine the whole mass-economy
712
- // goal chain). Not yet delivered: race the shortest path to any active
713
- // web (planSpiderPathToWeb), falling back to a greedy approach toward
714
- // the static web's home cell when no path is found. Already delivered
715
- // (already standing in an active web): hold still rather than run the
716
- // ordinary priority chain, so the ecology pass's own eat gate (which
717
- // reads this SAME post-move position) reliably resolves the delivery
718
- // this exact tick instead of risking the spider wandering back out
719
- // first.
720
- const carriedFlyId = state.carrying.get(spiderId)?.flyId;
721
- const isCarrying = Boolean(carriedFlyId) && !state.removed.has(carriedFlyId);
722
- const alreadyDelivered = isCarrying && hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns);
723
-
724
- let nextCell;
725
- let plan;
726
- let goal;
727
- let mood;
728
- if (isCarrying && alreadyDelivered) {
729
- nextCell = spiderCell;
730
- plan = [];
731
- goal = goalLineFor(spiderId, { subject: carriedFlyId }, false, "spider-carrying-delivered");
732
- mood = "angry";
733
- } else if (isCarrying) {
734
- const path = planSpiderPathToWeb(spiderCell, applyActions, state, k, config.webDurationTurns);
735
- if (path && path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
736
- else { nextCell = greedySpiderApproach(spiderCell, WEB_HOME, applyActions); plan = stepPlan(spiderCell, nextCell); }
737
- goal = goalLineFor(spiderId, { subject: carriedFlyId }, false, "spider-carrying");
738
- mood = "angry";
739
- } else {
740
- // Priority 1: avoid any OTHER live spider believed visible.
741
- const avoidTarget = nearestBelievedTarget(spiderId, spiderCell, otherSpiders, state, { visionRadius: config.spiderVisionRadius, toldFacts });
742
- if (avoidTarget) {
743
- nextCell = greedySpiderAvoid(spiderCell, avoidTarget.cell, applyActions);
744
- plan = stepPlan(spiderCell, nextCell);
745
- goal = goalLineFor(spiderId, avoidTarget, false, "spider-avoid");
746
- mood = "scared";
747
- } else {
748
- // Priority 2: chase a believed-visible fly, exactly as before.
749
- const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius: config.spiderVisionRadius, toldFacts });
750
- if (target) {
751
- nextCell = spiderCell;
752
- const path = planSpiderPath(spiderCell, target.cell, applyActions, state, k, config.webDurationTurns);
753
- if (path) {
754
- if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
755
- else plan = [];
756
- } else {
757
- nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
758
- plan = stepPlan(spiderCell, nextCell);
759
- }
760
- // "Arrived" is the real catch precondition (co-located with the
761
- // believed target — a catch never needs a web, only the SEPARATE
762
- // eat step that follows once it's carried into one) — NOT merely
763
- // "didn't move this turn", which a greedy-approach spider also
764
- // does whenever it's already at its closest reachable cell but
765
- // still a step away (Chebyshev-adjacent isn't co-located;
766
- // has-exit-* edges have no diagonal hop).
767
- const arrived = nextCell.x === target.cell.x && nextCell.y === target.cell.y;
768
- goal = goalLineFor(spiderId, target, arrived, "spider");
769
- mood = "angry";
770
- } else {
771
- // Priority 3: hold position, and build/refresh a web there unless an
772
- // unexpired web already covers this exact cell.
773
- nextCell = spiderCell;
774
- plan = [];
775
- mood = "calm";
776
- const heldCellId = cellId(spiderCell.x, spiderCell.y);
777
- if (!hasActiveWebAt(spiderCell.x, spiderCell.y, state, k, config.webDurationTurns)) {
778
- const webId = `web-${nextWebNum}`;
779
- nextWebNum += 1;
780
- tickWebs.set(webId, { cell: heldCellId, builtAtTurn: k });
781
- movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:currently-in", object: heldCellId });
782
- movementWrites.push({ subject: `${webId}@turn${k}`, predicate: "mgx:web-built-at-turn", object: String(k) });
783
- goal = "no fly in sight — building a web here.";
784
- } else {
785
- goal = goalLineFor(spiderId, null, false, "spider");
786
- }
787
- }
788
- }
789
- }
790
-
791
- postMovePlacements.set(spiderId, nextCell);
792
- movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
793
- movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
794
- agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal, mood, plan, mass: newMass, belief };
795
- }
796
-
797
- // A fly currently carried by a still-live spider (state.carrying, keyed by
798
- // captor) rides that captor's own just-chosen cell instead of scoring its
799
- // own move — fully inert, but its mass still decrements and it can still
800
- // starve mid-transit below (an intended emergent failure mode). A captor
801
- // that died since being folded self-heals the fly to independent movement
802
- // for free: it's simply absent from this map (state.removed.has(spiderId)
803
- // was never true for a captor still in `spiders`, so only a captor gone
804
- // BEFORE this tick's fold — already excluded from `spiders` — is missing).
805
- const captorOfFly = new Map();
806
- for (const [spiderId, { flyId }] of state.carrying) {
807
- if (spiders.includes(spiderId) && flies.includes(flyId)) captorOfFly.set(flyId, spiderId);
808
- }
809
-
810
- for (const flyId of flies) {
811
- const flyCell = parseCellId(state.placements.get(flyId).cell);
812
- const captorId = captorOfFly.get(flyId);
813
- let nextCell;
814
- let plan;
815
- let goal;
816
- let mood;
817
- if (captorId) {
818
- nextCell = postMovePlacements.get(captorId);
819
- plan = [];
820
- goal = `being carried by ${captorId}.`;
821
- mood = "scared";
822
- } else {
823
- const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius: config.flyVisionRadius, toldFacts });
824
- const webbed = hasActiveWebAt(flyCell.x, flyCell.y, state, k, config.webDurationTurns);
825
- if (webbed) {
826
- nextCell = flyCell;
827
- plan = [];
828
- goal = "trapped in an active web — can't move.";
829
- mood = "scared";
830
- } else {
831
- nextCell = greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions, k, flyId);
832
- plan = stepPlan(flyCell, nextCell);
833
- goal = goalLineFor(flyId, believedSpider, true, "fly");
834
- mood = believedSpider ? "scared" : "calm";
835
- }
836
- }
837
- const belief = beliefSnapshotFor(flyId, captorId ? nextCell : flyCell, [...spiders, ...flies.filter((id) => id !== flyId)], state, { visionRadius: config.flyVisionRadius, toldFacts });
838
-
839
- postMovePlacements.set(flyId, nextCell);
840
- movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
841
- const priorMass = state.mass.get(flyId)?.value ?? config.flyInitialMass;
842
- const newMass = Math.max(0, priorMass - config.flyMassDecrementPerTurn);
843
- postMoveMassByFly.set(flyId, newMass);
844
- movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
845
- agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal, mood, plan, mass: newMass, belief };
846
- }
847
-
848
- const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, postMoveMassBySpider, turn: k, config });
849
- // Every agent's goal was assigned during movement, before this same tick's
850
- // ecology pass resolves eating/starving — so a THIRD agent's goal can name
851
- // a subject that dies in this exact tick just as easily as the dying
852
- // subject's own goal can (a fly's "evading — last saw spider-1 ..." is
853
- // just as stale as spider-1's own entry once spider-1 starves this tick).
854
- // Scrub every remaining agent's goal of a died-this-tick reference BEFORE
855
- // the specific eaten-pair handling below, so that handling's own nicer
856
- // "just ate X" message (set second) always wins for the eating spider,
857
- // whose pre-scrub goal also names its prey. `(?!\d)` keeps "fly-1" from
858
- // false-matching inside "fly-10".
859
- const diedThisTick = [...ecology.events.eaten.map((e) => e.fly), ...ecology.events.starved];
860
- for (const id of Object.keys(agents)) {
861
- for (const deadId of diedThisTick) {
862
- if (new RegExp(`${deadId}(?!\\d)`).test(agents[id].goal)) {
863
- agents[id].goal = `${deadId} is gone — re-evaluating.`;
864
- agents[id].mood = "calm";
865
- break;
866
- }
867
- }
868
- }
869
- // A fly eaten or starved THIS tick was already assigned a pre-ecology
870
- // goal/position above (movement runs before the ecology pass resolves
871
- // eating) — left as-is, the same response would both announce "fly-5 was
872
- // eaten" and still list fly-5's stale "trapped, can't move" goal one clause
873
- // later, as if it were still on the board. Drop it from `agents` (its own
874
- // goal is moot) and let the eating spider's line say what actually
875
- // happened instead of the now-false "co-located with fly-5 — catching it".
876
- // A spider can eat more than one fly in the same tick (several flies
877
- // co-located with it on the same cell) — group by spider first, rather
878
- // than overwriting the goal once per eaten fly, which silently credited
879
- // only the LAST one and dropped every earlier fly from the summary line
880
- // (the "Turn N — ..." event text already lists every eaten fly correctly;
881
- // only this goal-line summary was collapsing them).
882
- const eatenBySpider = new Map();
883
- for (const { fly, spider } of ecology.events.eaten) {
884
- delete agents[fly];
885
- if (!eatenBySpider.has(spider)) eatenBySpider.set(spider, []);
886
- eatenBySpider.get(spider).push(fly);
887
- }
888
- for (const [spider, flyIds] of eatenBySpider) {
889
- if (agents[spider]) {
890
- agents[spider].goal = `just ate ${flyIds.join(" and ")} in the web.`;
891
- agents[spider].mood = "happy";
892
- agents[spider].mass = ecology.events.massAfterEating.get(spider) ?? agents[spider].mass;
893
- }
894
- }
895
- for (const flyId of ecology.events.starved) delete agents[flyId];
896
- // A fresh catch that wasn't ALSO delivered+eaten this same tick (the
897
- // "just ate" override above already wins for that case) leaves both the
898
- // spider's and the fly's pre-ecology goal stale (assigned during movement,
899
- // before the catch resolved) — the spider's own chase/hold text and the
900
- // fly's own evade/wander text neither one mentions the catch.
901
- for (const { spider, fly } of ecology.events.caught) {
902
- if (eatenBySpider.has(spider)) continue;
903
- if (agents[spider]) {
904
- agents[spider].goal = goalLineFor(spider, { subject: fly }, false, "spider-carrying");
905
- agents[spider].mood = "angry";
906
- }
907
- if (agents[fly]) {
908
- agents[fly].goal = `just caught by ${spider} — being carried.`;
909
- agents[fly].mood = "scared";
910
- }
911
- }
912
- // A hatched spider or a spawned fly is minted by the ecology pass, which
913
- // runs AFTER the movement loops above already built `agents` from the
914
- // pre-tick roster — so without this, a brand-new individual is absent from
915
- // this tick's own returned agents (and so invisible on the board/HUD) even
916
- // though the very same tick's event text already announces it, only
917
- // catching up the following tick once the fold picks it up naturally. One
918
- // egg can hatch into more than one spider — every hatchling gets its own
919
- // agents[] entry, sharing the egg's own cell.
920
- for (const h of ecology.events.hatched) {
921
- for (const { spider, mass } of h.spiders) {
922
- agents[spider] = { cell: h.cell, goal: "just hatched — no goal yet.", mood: "calm", plan: [], mass, belief: {} };
923
- }
924
- }
925
- if (ecology.events.spawned && ecology.events.spawnedCell) {
926
- agents[ecology.events.spawned] = { cell: ecology.events.spawnedCell, goal: "just arrived — no goal yet.", mood: "calm", plan: [], mass: config.flyInitialMass, belief: {} };
927
- }
928
- // Each remaining agent's mood goes on record as this turn's own mgx:feels
929
- // fact, exactly the way its mgx:currently-in placement did. Built here,
930
- // after the ecology pass, so the fact carries the mood the tick ENDED on
931
- // (a spider that just ate is happy, not still angry mid-chase) and covers
932
- // the hatchlings and the spawned fly the ecology pass just minted. An
933
- // agent eaten or starved this tick is already out of `agents`, so nothing
934
- // records a mood for it.
935
- const moodWrites = Object.entries(agents)
936
- .filter(([, agent]) => agent.mood)
937
- .map(([id, agent]) => ({ subject: `${id}@turn${k}`, predicate: "mgx:feels", object: agent.mood }));
938
- const writes = [...movementWrites, ...ecology.writes, ...moodWrites];
939
- const provenance = `${worldProvenanceTag(WORLD_NAME)}:turn${k}`;
940
- await appendFacts(memoryDir, writes.map((f) => ({ ...f, provenance })));
941
-
942
- return { turn: k, writes, agents, ecology: ecology.events, activeWebs: liveWebs(tickWebs, k, config.webDurationTurns) };
943
- }