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