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