@polycode-projects/the-mechanical-code-talker 2.7.2 → 2.7.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.
@@ -0,0 +1,491 @@
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, visibleCells, 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.
11
+
12
+ import {
13
+ WORLD_NAME, WEB_HOME,
14
+ cellId, parseCellId, chebyshevDistance, visibleCells, isInWebBlock, perimeterCells,
15
+ DIRECTION_DELTA,
16
+ } from "../domain/spider-fly-world.mjs";
17
+ import { findActionPath, findReachableSet } from "../domain/planning.mjs";
18
+ import { appendFacts, loadMemory, readFactRows } from "../adapters/memory/core.mjs";
19
+ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
20
+
21
+ // ---- tunable constants (starting values, not fixed — the vision radius and
22
+ // mass economy all want checking against a real playable board) -------------
23
+
24
+ export const DEFAULT_VISION_RADIUS = 4;
25
+ export const FLY_INITIAL_MASS = 10;
26
+ export const FLY_MASS_DECREMENT_PER_TURN = 1;
27
+ export const EGG_HATCH_DELAY_TURNS = 3;
28
+ export const FLY_SPAWN_INTERVAL_TURNS = 3;
29
+ export const EGGS_EATEN_THRESHOLD = 2;
30
+
31
+ // ---- the state fold ----------------------------------------------------------
32
+
33
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
34
+
35
+ function splitSnapshot(subject) {
36
+ const m = SNAPSHOT_RE.exec(subject);
37
+ return m ? { base: m[1], turn: Number(m[2]) } : { base: subject, turn: 0 };
38
+ }
39
+
40
+ /** Fold fact rows into the current spider-fly world state: per-subject
41
+ * newest placement (mgx:currently-in), newest fly mass, spider's newest
42
+ * flies-eaten count, each egg's laid-at-turn, and the terminal eaten-by/
43
+ * starved/hatched-into markers that make a subject no longer live. The turn
44
+ * counter is derived, never stored — the largest @turnN suffix seen,
45
+ * exactly foldWorldState's own convention. Pure. */
46
+ export function foldSpiderFlyState(factRows) {
47
+ const placements = new Map(); // subject -> { cell, turn }
48
+ const mass = new Map(); // fly subject -> { value, turn }
49
+ const fliesEaten = new Map(); // spider subject -> { value, turn }
50
+ const laidAtTurn = new Map(); // egg subject -> { value, turn }
51
+ const eatenBy = new Map(); // fly subject -> { spider, turn }
52
+ const starved = new Set(); // fly subject
53
+ const hatchedInto = new Map(); // egg subject -> { spider, turn }
54
+ let turnCount = 0;
55
+
56
+ for (const row of factRows || []) {
57
+ const { base, turn } = splitSnapshot(row.subject);
58
+ if (turn) turnCount = Math.max(turnCount, turn);
59
+
60
+ if (row.predicate === "mgx:currently-in") {
61
+ const prior = placements.get(base);
62
+ if (!prior || turn >= prior.turn) placements.set(base, { cell: row.object, turn });
63
+ continue;
64
+ }
65
+ if (row.predicate === "mgx:mass") {
66
+ const prior = mass.get(base);
67
+ if (!prior || turn >= prior.turn) mass.set(base, { value: Number(row.object), turn });
68
+ continue;
69
+ }
70
+ if (row.predicate === "mgx:flies-eaten") {
71
+ const prior = fliesEaten.get(base);
72
+ if (!prior || turn >= prior.turn) fliesEaten.set(base, { value: Number(row.object), turn });
73
+ continue;
74
+ }
75
+ if (row.predicate === "mgx:laid-at-turn") {
76
+ const prior = laidAtTurn.get(base);
77
+ if (!prior || turn >= prior.turn) laidAtTurn.set(base, { value: Number(row.object), turn });
78
+ continue;
79
+ }
80
+ if (row.predicate === "mgx:eaten-by") {
81
+ const prior = eatenBy.get(base);
82
+ if (!prior || turn >= prior.turn) eatenBy.set(base, { spider: row.object, turn });
83
+ continue;
84
+ }
85
+ if (row.predicate === "mgx:starved") { starved.add(base); continue; }
86
+ if (row.predicate === "mgx:hatched-into") {
87
+ const prior = hatchedInto.get(base);
88
+ if (!prior || turn >= prior.turn) hatchedInto.set(base, { spider: row.object, turn });
89
+ continue;
90
+ }
91
+ }
92
+
93
+ const removed = new Set([...eatenBy.keys(), ...starved, ...hatchedInto.keys()]);
94
+ return { placements, mass, fliesEaten, laidAtTurn, eatenBy, starved, hatchedInto, removed, turnCount };
95
+ }
96
+
97
+ const sortedLiveSubjects = (state, re) =>
98
+ [...state.placements.keys()].filter((id) => re.test(id) && !state.removed.has(id)).sort();
99
+
100
+ function maxIdSuffix(ids, re) {
101
+ let max = 0;
102
+ for (const id of ids) {
103
+ const m = re.exec(id);
104
+ if (m) max = Math.max(max, Number(m[1]));
105
+ }
106
+ return max;
107
+ }
108
+
109
+ // ---- single-agent pathfinding (§5): hand-written applyActions over the
110
+ // world pack's own has-exit-<direction> facts, NOT the taught action-rule
111
+ // DSL, whose no-incoming/comparator precondition shapes cannot express grid
112
+ // adjacency (the same limitation Ashcombe's own runWorldCommand worked
113
+ // around). State is the plain {x, y} coordinate the search kernel treats as
114
+ // opaque. ---------------------------------------------------------------------
115
+
116
+ const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
117
+
118
+ /** The spider/fly movement applyActions closure findActionPath/
119
+ * findReachableSet need: one hop per has-exit-<direction> fact reachable
120
+ * from a cell, in the fixed direction order both agents' search shares for
121
+ * deterministic tie-breaking. Built once per tick from the world's own
122
+ * static exit facts (grid topology is common knowledge to both agents). */
123
+ export function gridApplyActions(factRows) {
124
+ const exits = new Map();
125
+ for (const row of factRows || []) {
126
+ const m = EXIT_PREDICATE_RE.exec(row.predicate);
127
+ if (!m) continue;
128
+ if (!exits.has(row.subject)) exits.set(row.subject, new Map());
129
+ exits.get(row.subject).set(m[1], row.object);
130
+ }
131
+ return (state) => {
132
+ const out = [];
133
+ const dirs = exits.get(cellId(state.x, state.y));
134
+ if (!dirs) return out;
135
+ for (const direction of Object.keys(DIRECTION_DELTA)) {
136
+ const target = dirs.get(direction);
137
+ if (!target) continue;
138
+ const parsed = parseCellId(target);
139
+ if (!parsed) continue;
140
+ out.push({ action: direction, nextState: { x: parsed.x, y: parsed.y } });
141
+ }
142
+ return out;
143
+ };
144
+ }
145
+
146
+ /** Canonicalizes a grid-position search state onto its cell alone — the
147
+ * only field that matters for movement dedup (mass/eaten-count ride
148
+ * elsewhere on the folded state, never on the path-search state itself). */
149
+ export const spiderPathStateKey = (state) => cellId(state.x, state.y);
150
+
151
+ /** The spider's multi-step path: findActionPath wired with isGoal =
152
+ * "co-located with the believed fly cell, and that cell is inside the web
153
+ * block." When the fly's believed cell sits outside the web, isGoal can
154
+ * never fire (it doesn't depend on the search state, only on the fixed
155
+ * target), so this returns null — an honest "no path to an eat" rather
156
+ * than a path toward a cell that would never satisfy the eat condition.
157
+ * Null also covers "no believed target at all." */
158
+ export function planSpiderPath(spiderCell, believedFlyCell, applyActions) {
159
+ if (!believedFlyCell) return null;
160
+ const isGoal = (state) =>
161
+ state.x === believedFlyCell.x && state.y === believedFlyCell.y && isInWebBlock(state.x, state.y);
162
+ return findActionPath(spiderCell, isGoal, applyActions, { stateKey: spiderPathStateKey });
163
+ }
164
+
165
+ // ---- one-ply greedy scoring, shared by the fly's evasion and the spider's
166
+ // fallback chase (§5 confirmed decision: greedy distance-scoring over
167
+ // findReachableSet's one-ply output, plus staying put — no lookahead, no
168
+ // simulation of the other agent's plan). -------------------------------------
169
+
170
+ function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
171
+ const options = [fromCell, ...findReachableSet(fromCell, applyActions, { maxDepth: 1 }).map((r) => r.node)];
172
+ let best = options[0];
173
+ let bestScore = scoreOf(best);
174
+ for (let i = 1; i < options.length; i += 1) {
175
+ const score = scoreOf(options[i]);
176
+ if (isBetter(score, bestScore)) { bestScore = score; best = options[i]; }
177
+ }
178
+ return best;
179
+ }
180
+
181
+ /** The fly's one move this turn: score every one-ply reachable cell (plus
182
+ * staying put) by Chebyshev distance from the fly's believed spider
183
+ * position, move to the highest-scoring cell. A fly with no believed
184
+ * spider position holds still. */
185
+ export function greedyFlyMove(flyCell, believedSpiderCell, applyActions) {
186
+ if (!believedSpiderCell) return flyCell;
187
+ return bestOneStepBy(
188
+ flyCell, applyActions,
189
+ (cell) => chebyshevDistance(cell.x, cell.y, believedSpiderCell.x, believedSpiderCell.y),
190
+ (score, bestScore) => score > bestScore,
191
+ );
192
+ }
193
+
194
+ /** The spider's fallback move when no in-web path exists yet (the fly's
195
+ * believed cell is outside the web, or currently unreachable): the same
196
+ * one-ply kernel as the fly's evasion, scored the opposite way — close
197
+ * distance instead of open it. */
198
+ export function greedySpiderApproach(spiderCell, believedFlyCell, applyActions) {
199
+ if (!believedFlyCell) return spiderCell;
200
+ return bestOneStepBy(
201
+ spiderCell, applyActions,
202
+ (cell) => chebyshevDistance(cell.x, cell.y, believedFlyCell.x, believedFlyCell.y),
203
+ (score, bestScore) => score < bestScore,
204
+ );
205
+ }
206
+
207
+ // ---- visibility and belief (§4): static grid topology is common knowledge
208
+ // to both agents; only dynamic entity positions are gated by vision. A told
209
+ // fact (a later chat-integration piece of work, not built here) is the one
210
+ // extension point this function leaves open via its optional toldFacts
211
+ // parameter — shaped { subject, toAgent, cell, turn }, defaulting to empty
212
+ // so today's belief is exactly "what's currently visible." -------------------
213
+
214
+ /** Whether `observerSubject` currently believes `targetSubject` to be at a
215
+ * particular cell: ground truth when the target's real cell is within the
216
+ * observer's own visibleCells radius, else the newest told fact addressed
217
+ * to this observer about this target, else null (unknown). A removed
218
+ * target (eaten/starved/hatched) is never believed present. */
219
+ export function believedCellOf(targetSubject, observerSubject, observerCell, state, opts = {}) {
220
+ const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
221
+ const place = state.placements.get(targetSubject);
222
+ if (place && !state.removed.has(targetSubject)) {
223
+ const seen = visibleCells(observerCell.x, observerCell.y, visionRadius);
224
+ if (seen.includes(place.cell)) return parseCellId(place.cell);
225
+ }
226
+ const told = toldFacts
227
+ .filter((f) => f.toAgent === observerSubject && f.subject === targetSubject)
228
+ .sort((a, b) => (b.turn ?? 0) - (a.turn ?? 0))[0];
229
+ return told ? parseCellId(told.cell) : null;
230
+ }
231
+
232
+ /** The nearest candidate (by believed Chebyshev distance) an observer has
233
+ * any belief about at all — null when the observer believes nothing about
234
+ * any candidate. `candidates` must already be in a deterministic order
235
+ * (ties favor the earlier candidate). */
236
+ export function nearestBelievedTarget(observerSubject, observerCell, candidates, state, opts = {}) {
237
+ let best = null;
238
+ let bestDist = Infinity;
239
+ for (const subject of candidates) {
240
+ const cell = believedCellOf(subject, observerSubject, observerCell, state, opts);
241
+ if (!cell) continue;
242
+ const dist = chebyshevDistance(observerCell.x, observerCell.y, cell.x, cell.y);
243
+ if (dist < bestDist) { bestDist = dist; best = { subject, cell }; }
244
+ }
245
+ return best;
246
+ }
247
+
248
+ // ---- the ecology pass (§10): eat, lay, hatch, spawn, starve, all as
249
+ // ordinary turn-gated checks in one fixed-order pass. Order matters and is
250
+ // fixed deliberately: eat first (predation resolves on the turn's fresh
251
+ // positions), then starve (a fly already claimed by an eat this turn cannot
252
+ // also starve), then lay (reads the egg slot as it stood BEFORE this tick's
253
+ // own hatch, so a hatch and a fresh lay never land the same turn), then
254
+ // hatch, then spawn (reads the board as every earlier step in this same
255
+ // pass left it, so a fly never spawns on a cell an eat/hatch just vacated
256
+ // or occupied). ---------------------------------------------------------------
257
+
258
+ function mostRecentEggLaidTurn(state) {
259
+ let max = -1;
260
+ for (const { value } of state.laidAtTurn.values()) max = Math.max(max, value);
261
+ return max;
262
+ }
263
+
264
+ function mostRecentEaterSpider(state, eatenDeltaBySpider) {
265
+ if (eatenDeltaBySpider.size) return [...eatenDeltaBySpider.keys()].sort()[0];
266
+ let best = null;
267
+ let bestTurn = -1;
268
+ for (const { spider, turn } of state.eatenBy.values()) {
269
+ if (turn > bestTurn) { bestTurn = turn; best = spider; }
270
+ }
271
+ return best;
272
+ }
273
+
274
+ /**
275
+ * One ecology pass over the tick's post-movement state: `postMovePlacements`
276
+ * is a Map(subject -> {x,y}) for every currently-live spider and fly after
277
+ * this turn's movement writes; `postMoveMassByFly` is a Map(flySubject ->
278
+ * number), the fly's mass after this turn's decrement, pre-removal. `state`
279
+ * is the PRE-move fold (for history: prior flies-eaten counts, prior eggs,
280
+ * prior eaten turns). Returns `{ writes, events }` — writes to append
281
+ * alongside the turn's movement facts, events for the tick's own return
282
+ * payload. Pure.
283
+ */
284
+ export function runEcologyPass({ state, postMovePlacements, postMoveMassByFly, turn }) {
285
+ const k = turn;
286
+ const writes = [];
287
+ const events = { eaten: [], starved: [], laid: null, hatched: [], spawned: null };
288
+
289
+ const spiders = [...postMovePlacements.keys()].filter((id) => /^spider-\d+$/.test(id)).sort();
290
+ const flies = [...postMovePlacements.keys()].filter((id) => /^fly-\d+$/.test(id)).sort();
291
+
292
+ // 1. Eat — a spider and a fly sharing an in-web cell.
293
+ const claimedFlies = new Set();
294
+ const eatenDeltaBySpider = new Map();
295
+ for (const spiderId of spiders) {
296
+ const sCell = postMovePlacements.get(spiderId);
297
+ if (!isInWebBlock(sCell.x, sCell.y)) continue;
298
+ for (const flyId of flies) {
299
+ if (claimedFlies.has(flyId)) continue;
300
+ const fCell = postMovePlacements.get(flyId);
301
+ if (sCell.x !== fCell.x || sCell.y !== fCell.y) continue;
302
+ claimedFlies.add(flyId);
303
+ eatenDeltaBySpider.set(spiderId, (eatenDeltaBySpider.get(spiderId) ?? 0) + 1);
304
+ writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:eaten-by", object: spiderId });
305
+ events.eaten.push({ fly: flyId, spider: spiderId, cell: cellId(sCell.x, sCell.y) });
306
+ }
307
+ }
308
+ for (const [spiderId, delta] of eatenDeltaBySpider) {
309
+ const newCount = (state.fliesEaten.get(spiderId)?.value ?? 0) + delta;
310
+ writes.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:flies-eaten", object: String(newCount) });
311
+ }
312
+
313
+ // 2. Starve — mass reached zero, and not already claimed by this turn's eat.
314
+ for (const flyId of flies) {
315
+ if (claimedFlies.has(flyId)) continue;
316
+ if ((postMoveMassByFly.get(flyId) ?? 0) <= 0) {
317
+ writes.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:starved", object: "true" });
318
+ events.starved.push(flyId);
319
+ }
320
+ }
321
+ const deadFliesThisTick = new Set([...claimedFlies, ...events.starved]);
322
+
323
+ // 3. Lay — the eat condition has fired enough times since the last egg (or
324
+ // once, at game start), with no live egg outstanding right now.
325
+ const liveEggId = [...state.laidAtTurn.keys()].find((id) => !state.removed.has(id));
326
+ if (!liveEggId) {
327
+ const sinceTurn = mostRecentEggLaidTurn(state);
328
+ const threshold = sinceTurn === -1 ? 1 : EGGS_EATEN_THRESHOLD;
329
+ let eatsSince = events.eaten.length;
330
+ for (const { turn: eatenTurn } of state.eatenBy.values()) if (eatenTurn > sinceTurn) eatsSince += 1;
331
+ if (eatsSince >= threshold) {
332
+ const eggSpider = mostRecentEaterSpider(state, eatenDeltaBySpider);
333
+ const eggCell = eggSpider ? (postMovePlacements.get(eggSpider) ?? parseCellId(state.placements.get(eggSpider)?.cell)) : null;
334
+ if (eggCell) {
335
+ const eggId = `egg-${1 + maxIdSuffix(state.placements.keys(), /^egg-(\d+)$/)}`;
336
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(eggCell.x, eggCell.y) });
337
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:laid-at-turn", object: String(k) });
338
+ events.laid = eggId;
339
+ }
340
+ }
341
+ }
342
+
343
+ // 4. Hatch — any live egg laid exactly EGG_HATCH_DELAY_TURNS turns ago.
344
+ const liveEggIds = [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort();
345
+ let nextSpiderNum = 1 + maxIdSuffix(state.placements.keys(), /^spider-(\d+)$/);
346
+ for (const eggId of liveEggIds) {
347
+ const laidTurn = state.laidAtTurn.get(eggId).value;
348
+ if (laidTurn + EGG_HATCH_DELAY_TURNS !== k) continue;
349
+ const eggCell = state.placements.get(eggId)?.cell;
350
+ if (!eggCell) continue;
351
+ const newSpiderId = `spider-${nextSpiderNum}`;
352
+ nextSpiderNum += 1;
353
+ writes.push({ subject: `${newSpiderId}@turn${k}`, predicate: "mgx:currently-in", object: eggCell });
354
+ writes.push({ subject: `${eggId}@turn${k}`, predicate: "mgx:hatched-into", object: newSpiderId });
355
+ events.hatched.push({ egg: eggId, spider: newSpiderId, cell: eggCell });
356
+ }
357
+
358
+ // 5. Spawn — every third turn, a new fly at an uncontested perimeter cell.
359
+ if (k % FLY_SPAWN_INTERVAL_TURNS === 0) {
360
+ const occupied = new Set();
361
+ for (const spiderId of spiders) { const c = postMovePlacements.get(spiderId); occupied.add(cellId(c.x, c.y)); }
362
+ for (const flyId of flies) {
363
+ if (deadFliesThisTick.has(flyId)) continue;
364
+ const c = postMovePlacements.get(flyId);
365
+ occupied.add(cellId(c.x, c.y));
366
+ }
367
+ for (const eggId of liveEggIds) {
368
+ if (events.hatched.some((h) => h.egg === eggId)) continue;
369
+ occupied.add(state.placements.get(eggId)?.cell);
370
+ }
371
+ for (const h of events.hatched) occupied.add(h.cell);
372
+ const cell = perimeterCells().find((c) => !occupied.has(c));
373
+ if (cell) {
374
+ const newFlyId = `fly-${1 + maxIdSuffix(state.placements.keys(), /^fly-(\d+)$/)}`;
375
+ writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:currently-in", object: cell });
376
+ writes.push({ subject: `${newFlyId}@turn${k}`, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
377
+ events.spawned = newFlyId;
378
+ }
379
+ }
380
+
381
+ return { writes, events };
382
+ }
383
+
384
+ // ---- bootstrap and the per-tick orchestration --------------------------------
385
+
386
+ /** Mints spider-1 at the web's home cell and a spread of flies onto the
387
+ * board perimeter — a fresh session's own starting state, never part of
388
+ * the shipped (reusable, static) world pack itself. A no-op when spider-1
389
+ * already exists (idempotent — safe to call from a caller unsure whether
390
+ * the game has already started). */
391
+ export async function startSpiderFlyGame(memoryDir, { flyCount = 1 } = {}) {
392
+ const state = foldSpiderFlyState(readFactRows(await loadMemory(memoryDir)));
393
+ if (state.placements.has("spider-1")) return { started: false, facts: [] };
394
+
395
+ const perimeter = perimeterCells();
396
+ const facts = [{ subject: "spider-1", predicate: "mgx:currently-in", object: cellId(WEB_HOME.x, WEB_HOME.y) }];
397
+ for (let i = 0; i < flyCount; i += 1) {
398
+ const cell = perimeter[Math.floor((perimeter.length * (i + 1)) / (flyCount + 1)) % perimeter.length];
399
+ facts.push({ subject: `fly-${i + 1}`, predicate: "mgx:currently-in", object: cell });
400
+ facts.push({ subject: `fly-${i + 1}`, predicate: "mgx:mass", object: String(FLY_INITIAL_MASS) });
401
+ }
402
+ await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(WORLD_NAME) })));
403
+ return { started: true, facts };
404
+ }
405
+
406
+ function goalLineFor(subject, believed, arrived, kind) {
407
+ if (!believed) return kind === "spider" ? "no fly in sight — holding position in the web." : "no spider in sight — holding position.";
408
+ const seenAt = cellId(believed.cell.x, believed.cell.y);
409
+ if (kind === "spider") {
410
+ return arrived
411
+ ? `co-located with ${believed.subject} in the web.`
412
+ : `chasing ${believed.subject}, last seen at ${seenAt}.`;
413
+ }
414
+ return `evading — last saw ${believed.subject} at ${seenAt}.`;
415
+ }
416
+
417
+ /**
418
+ * One full tick: fold state, compute each live spider's and fly's belief,
419
+ * replan/re-score, execute one movement step per agent, run the ecology
420
+ * pass, and append everything as this turn's @turnN facts in one write.
421
+ * `opts.toldFacts` is the belief layer's chat-integration extension point
422
+ * (§4) — an array of `{ subject, toAgent, cell, turn }` rows, empty until a
423
+ * later piece of work wires chat-told positions through it.
424
+ *
425
+ * Returns `{ turn, writes, agents, ecology }`: `agents` is keyed by every
426
+ * live spider/fly subject after this tick, each `{ cell, goal, plan }` (the
427
+ * spider's `plan` is its found path's remaining directions, or null when it
428
+ * has none this tick); `ecology` is the tick's eaten/starved/laid/hatched/
429
+ * spawned event summary.
430
+ */
431
+ export async function runSpiderFlyTick(memoryDir, opts = {}) {
432
+ const { visionRadius = DEFAULT_VISION_RADIUS, toldFacts = [] } = opts;
433
+ const rows = readFactRows(await loadMemory(memoryDir));
434
+ const state = foldSpiderFlyState(rows);
435
+ const k = state.turnCount + 1;
436
+ const applyActions = gridApplyActions(rows);
437
+
438
+ const spiders = sortedLiveSubjects(state, /^spider-\d+$/);
439
+ const flies = sortedLiveSubjects(state, /^fly-\d+$/);
440
+
441
+ const movementWrites = [];
442
+ const postMovePlacements = new Map();
443
+ const postMoveMassByFly = new Map();
444
+ const agents = {};
445
+
446
+ for (const spiderId of spiders) {
447
+ const spiderCell = parseCellId(state.placements.get(spiderId).cell);
448
+ const target = nearestBelievedTarget(spiderId, spiderCell, flies, state, { visionRadius, toldFacts });
449
+ let nextCell = spiderCell;
450
+ let plan = null;
451
+ if (target) {
452
+ const path = planSpiderPath(spiderCell, target.cell, applyActions);
453
+ if (path) {
454
+ if (path.actions.length) { nextCell = path.states[1]; plan = path.actions; }
455
+ } else {
456
+ nextCell = greedySpiderApproach(spiderCell, target.cell, applyActions);
457
+ }
458
+ }
459
+ postMovePlacements.set(spiderId, nextCell);
460
+ movementWrites.push({ subject: `${spiderId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
461
+ // "Arrived" is the real eat precondition (co-located with the believed
462
+ // target, inside the web) — NOT merely "didn't move this turn", which a
463
+ // greedy-approach spider also does whenever it's already at its closest
464
+ // reachable cell but still a step away (Chebyshev-adjacent isn't
465
+ // co-located; has-exit-* edges have no diagonal hop). Using "didn't move"
466
+ // as the proxy previously mislabeled that stuck-but-not-there case as
467
+ // "co-located ... in the web" even when nowhere near the web.
468
+ const arrived = !!target && nextCell.x === target.cell.x && nextCell.y === target.cell.y && isInWebBlock(nextCell.x, nextCell.y);
469
+ agents[spiderId] = { cell: cellId(nextCell.x, nextCell.y), goal: goalLineFor(spiderId, target, arrived, "spider"), plan };
470
+ }
471
+
472
+ for (const flyId of flies) {
473
+ const flyCell = parseCellId(state.placements.get(flyId).cell);
474
+ const believedSpider = nearestBelievedTarget(flyId, flyCell, spiders, state, { visionRadius, toldFacts });
475
+ const nextCell = greedyFlyMove(flyCell, believedSpider?.cell ?? null, applyActions);
476
+ postMovePlacements.set(flyId, nextCell);
477
+ movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:currently-in", object: cellId(nextCell.x, nextCell.y) });
478
+ const priorMass = state.mass.get(flyId)?.value ?? FLY_INITIAL_MASS;
479
+ const newMass = Math.max(0, priorMass - FLY_MASS_DECREMENT_PER_TURN);
480
+ postMoveMassByFly.set(flyId, newMass);
481
+ movementWrites.push({ subject: `${flyId}@turn${k}`, predicate: "mgx:mass", object: String(newMass) });
482
+ agents[flyId] = { cell: cellId(nextCell.x, nextCell.y), goal: goalLineFor(flyId, believedSpider, true, "fly") };
483
+ }
484
+
485
+ const ecology = runEcologyPass({ state, postMovePlacements, postMoveMassByFly, turn: k });
486
+ const writes = [...movementWrites, ...ecology.writes];
487
+ const provenance = `${worldProvenanceTag(WORLD_NAME)}:turn${k}`;
488
+ await appendFacts(memoryDir, writes.map((f) => ({ ...f, provenance })));
489
+
490
+ return { turn: k, writes, agents, ecology: ecology.events };
491
+ }
@@ -0,0 +1,119 @@
1
+ // viz-ticker.mjs — the play/pause/step/reset control PATTERN, extracted once
2
+ // from plan-viz.mjs's own inlined ticker script (PLAN_SPIDER_FLY.md §11: the
3
+ // `step`/`playing`/`animating` state triple, `playRange`/`wait(300)`, the
4
+ // `prefers-reduced-motion` guard). plan-viz.mjs's own version stays exactly
5
+ // where it is — it closes directly over Hanoi/river's block-board DOM
6
+ // (`animateMove`/`drawState`/`PLAN.layouts`) and was never generic. This file
7
+ // is the reusable part only, written once so more than one self-contained
8
+ // page can share it instead of re-implementing it.
9
+ //
10
+ // `createTicker` is written to run standalone: it closes over nothing but its
11
+ // own parameters and the ambient browser globals (`window`, `setTimeout`)
12
+ // every self-contained tmct page already assumes, so it can be spliced
13
+ // verbatim into a page's own inlined <script> via `createTicker.toString()`
14
+ // — exactly how ledger-viz.mjs inlines `facetCounts`/`resolveAnsweredTerm`
15
+ // (see src/services/spider-fly-viz.mjs for the first caller). A later ES
16
+ // import (`import { createTicker } from "./viz-ticker.mjs"`) works
17
+ // identically, for a caller with its own module graph.
18
+ //
19
+ // This helper deliberately owns NO notion of "step number" or "total step
20
+ // count" — plan-viz.mjs's own ticker assumes a fixed, precomputed sequence
21
+ // (Hanoi's move list), but a live simulation (spider-and-fly's turn-by-turn
22
+ // engine) has no such fixed length. Both shapes fit the one contract below:
23
+ // a fixed-length replay passes `hasNext: () => getStep() < N`; an open-ended
24
+ // simulation passes `hasNext: () => true` (or its own stopping condition,
25
+ // e.g. "the board still has agents on it").
26
+ //
27
+ // What a caller supplies, via `opts`:
28
+ // - `onTick()` (required): advance by exactly one step. May be async —
29
+ // this is the caller's chance to run one real engine turn, or animate one
30
+ // move — and is awaited before the ticker considers the next step. Its
31
+ // return value is ignored; the caller updates its own state/DOM inside.
32
+ // - `onRender(state)` (optional): called after every state change, with a
33
+ // plain `{ playing, animating }` snapshot, so the caller can update its
34
+ // own button labels/disabled-state. This helper touches no DOM itself —
35
+ // unlike plan-viz.mjs's inlined version, it assumes no button ids.
36
+ // - `onReset()` (optional, async): called by `reset()` — this helper has no
37
+ // opinion on what "back to the start" means (replay from step 0? a fresh
38
+ // session for a live simulation?), so that decision is entirely the
39
+ // caller's; `reset()` is a no-op without it beyond clearing `playing`.
40
+ // - `hasNext()` (default: always true): whether another tick should run.
41
+ // Checked before every tick, both for a single step and inside `play()`'s
42
+ // loop.
43
+ // - `waitMs` (default 300): the pacing delay between auto-played ticks —
44
+ // matches plan-viz.mjs's own `wait(300)`.
45
+ // - `wait` (default a real `setTimeout` promise): injectable for tests, so
46
+ // a suite can pass an instant resolver instead of waiting in real time.
47
+ //
48
+ // Returns `{ play, pause, stepOnce, reset, getState }` — the four verbs a
49
+ // play/pause/step/reset button row wires to a click handler, plus a read of
50
+ // the current `{ playing, animating }` snapshot.
51
+ export function createTicker({
52
+ onTick, onRender = () => {}, onReset = null,
53
+ hasNext = () => true, waitMs = 300,
54
+ wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
55
+ } = {}) {
56
+ const state = { playing: false, animating: false };
57
+ const render = () => onRender({ ...state });
58
+
59
+ /** Advance exactly one step, honoring `hasNext`/`animating` guards. Returns
60
+ * whether it actually advanced. */
61
+ async function stepOnce() {
62
+ if (state.animating || !hasNext()) return false;
63
+ state.animating = true;
64
+ render();
65
+ await onTick();
66
+ state.animating = false;
67
+ render();
68
+ return true;
69
+ }
70
+
71
+ /** Keep advancing, paced by `waitMs`, until paused or `hasNext()` says
72
+ * stop. A no-op while a step is already animating (never overlaps a
73
+ * running tick) or already playing (toggling play again pauses instead —
74
+ * see `play()`). */
75
+ async function play() {
76
+ if (state.animating) return;
77
+ if (state.playing) { pause(); return; }
78
+ state.playing = true;
79
+ render();
80
+ while (state.playing && hasNext()) {
81
+ // eslint-disable-next-line no-await-in-loop
82
+ const advanced = await stepOnce();
83
+ if (!advanced) break;
84
+ if (state.playing && hasNext()) {
85
+ // eslint-disable-next-line no-await-in-loop
86
+ await wait(waitMs);
87
+ }
88
+ }
89
+ state.playing = false;
90
+ render();
91
+ }
92
+
93
+ function pause() {
94
+ state.playing = false;
95
+ render();
96
+ }
97
+
98
+ async function reset() {
99
+ if (state.animating) return;
100
+ state.playing = false;
101
+ if (onReset) await onReset();
102
+ render();
103
+ }
104
+
105
+ return { play, pause, stepOnce, reset, getState: () => ({ ...state }) };
106
+ }
107
+
108
+ /** The one-line `prefers-reduced-motion` read plan-viz.mjs's inlined script
109
+ * performs itself (`window.matchMedia("(prefers-reduced-motion: reduce)")
110
+ * .matches`) — pulled out only so a caller doesn't retype the media query.
111
+ * This helper never consults it itself: what "reduced motion" should change
112
+ * (skip a CSS transition? jump straight to the end?) is entirely up to
113
+ * what `onTick` does with the value. Self-contained, `.toString()`-splice
114
+ * safe, same as `createTicker` above. */
115
+ export function prefersReducedMotion() {
116
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
117
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
118
+ : false;
119
+ }