@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.
- package/corpus/worlds/manifest.json +9 -9
- package/corpus/worlds/shards/town-square-chapel.jsonl.gz +0 -0
- package/corpus/worlds/shards/town-square-market.jsonl.gz +0 -0
- package/corpus/worlds/shards/town-square.jsonl.gz +0 -0
- package/corpus/worlds/src/town-square-chapel.jsonl +1 -1
- package/corpus/worlds/src/town-square-market.jsonl +1 -1
- package/corpus/worlds/src/town-square.jsonl +1 -1
- package/data/mudiii-assets.json +35 -5
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +236 -18
- package/src/domain/ask-vocab.mjs +1 -1
- package/src/domain/ask.mjs +20 -10
- package/src/domain/game-config.mjs +10 -1
- package/src/domain/interpret/normalize.mjs +4 -0
- package/src/domain/memory/retraction.mjs +232 -0
- package/src/domain/p2p/sync-filter.mjs +9 -1
- package/src/domain/spider-fly-world.mjs +80 -35
- package/src/domain/syllogise.mjs +128 -75
- package/src/services/adventure-autoplay.mjs +2 -2
- package/src/services/adventure-viz.mjs +12 -7
- package/src/services/chat.mjs +42 -14
- package/src/services/mud-turn.mjs +1 -1
- package/src/services/mud-viz.mjs +11 -2
- package/src/services/mudiii-scene.mjs +199 -55
- package/src/services/mudiii-turn.mjs +82 -1
- package/src/services/mudiii-viz.mjs +17 -7
- package/src/services/p2p-room.mjs +130 -9
- package/src/services/predator-prey.mjs +524 -69
- package/src/services/spider-fly-turn.mjs +128 -56
- package/src/services/spider-fly-viz.mjs +65 -71
- package/src/services/world-teach.mjs +3 -3
- package/src/surfaces/web/adventure-browser-entry.mjs +12 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +118 -118
- package/src/surfaces/web/mud-browser-entry.mjs +10 -3
- package/src/surfaces/web/mudiii-browser-entry.mjs +3 -3
- package/src/surfaces/web/spider-fly-browser-entry.mjs +26 -22
- 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
|
-
}
|