@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,34 +1,32 @@
|
|
|
1
|
-
// predator-prey.mjs — the headless
|
|
1
|
+
// predator-prey.mjs — the headless predator/prey turn engine: an (epoch, turn)
|
|
2
2
|
// state fold, vision-gated belief over agents AND inert food, the two decision
|
|
3
3
|
// chains, and one fixed-order ecology pass, all reading and writing plain fact
|
|
4
4
|
// rows through the shared memory store. No chat, no rendering.
|
|
5
5
|
//
|
|
6
|
-
// Role-parameterized from the first line. Nothing below names a fox
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Role-parameterized from the first line. Nothing below names a fox, a goblin,
|
|
7
|
+
// a spider or a fly: the cast arrives as a roles object, the numbers arrive
|
|
8
|
+
// role-keyed in `config`, and the board arrives as a layout. A new cast is a
|
|
9
|
+
// new roles object plus new model keys, with no edit here. Two casts run on it
|
|
10
|
+
// today — the town square (fox and goblin) and the spider-and-fly board.
|
|
10
11
|
//
|
|
11
12
|
// Grid geometry, the prop tables and what "solid" means all come from
|
|
12
13
|
// domain/town-square-world.mjs. Belief comes from domain/agent-belief.mjs,
|
|
13
14
|
// which is board-size agnostic and models vision as plain Chebyshev distance
|
|
14
15
|
// with no line of sight — a building blocks movement, never sight.
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
17
|
+
// Three mechanics are OPT-IN, off unless `config` asks for them by name, so a
|
|
18
|
+
// cast that does not want them sees the same board it always did:
|
|
18
19
|
//
|
|
19
|
-
// -
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// spider-fly.mjs on purpose: spider-fly is the pattern this engine mirrors,
|
|
30
|
-
// not a library underneath it, and the design has spider-fly migrating onto
|
|
31
|
-
// this engine later — an import in this direction would become a cycle then.
|
|
20
|
+
// - `carryPreyToWeb` splits catching from eating. A predator that shares a
|
|
21
|
+
// cell with prey claims it (mgx:carrying) and only eats standing in a web.
|
|
22
|
+
// Off, a predator eats where it catches, in one step.
|
|
23
|
+
// - `buildWebs` gives the predator's last rung a job — hold and spin a web
|
|
24
|
+
// (web-N + mgx:web-built-at-turn) instead of wandering — and traps prey
|
|
25
|
+
// that ends a move inside a live one. A layout may also declare an
|
|
26
|
+
// always-on web block through `staticWebAt`.
|
|
27
|
+
// - `layEggs` adds the reproduction stage: an egg (mgx:laid-at-turn) laid in
|
|
28
|
+
// a web once mass crosses a threshold, hatching (mgx:hatched-into) into
|
|
29
|
+
// hatchlings that split the egg's mass.
|
|
32
30
|
|
|
33
31
|
import {
|
|
34
32
|
DEFAULT_FACING, TOWN_SQUARE_LAYOUTS,
|
|
@@ -44,7 +42,7 @@ import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
|
|
|
44
42
|
import { mulberry32 } from "../domain/seeded-random.mjs";
|
|
45
43
|
import { fnv1a32 } from "../domain/hash.mjs";
|
|
46
44
|
import { DEFAULT_GAME_CONFIG } from "../domain/game-config.mjs";
|
|
47
|
-
import { objectClassChain, parseSnapshotSubject, snapshotSubject, WORLD_EPOCH_PREDICATE } from "./adventure.mjs";
|
|
45
|
+
import { objectClassChain, parseSnapshotSubject, snapshotSubject, worldEpochFact, WORLD_EPOCH_PREDICATE } from "./adventure.mjs";
|
|
48
46
|
|
|
49
47
|
export { DEFAULT_VISION_RADIUS, believedCellOf, nearestBelievedTarget, beliefSnapshotFor };
|
|
50
48
|
|
|
@@ -67,6 +65,18 @@ const MODEL_PREDICATE = "mgx:model";
|
|
|
67
65
|
const ROTATION_PREDICATE = "mgx:rotation";
|
|
68
66
|
const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
|
|
69
67
|
|
|
68
|
+
// The four predicates only an opt-in mechanic ever writes. They are listed
|
|
69
|
+
// here, and folded below, whatever the cast: a fold that skipped them would
|
|
70
|
+
// have to know which features its caller turned on, and the rows simply never
|
|
71
|
+
// appear on a board that never writes them.
|
|
72
|
+
const CARRYING_PREDICATE = "mgx:carrying";
|
|
73
|
+
const PREY_EATEN_PREDICATE = "mgx:prey-eaten";
|
|
74
|
+
const WEB_BUILT_PREDICATE = "mgx:web-built-at-turn";
|
|
75
|
+
const LAID_AT_TURN_PREDICATE = "mgx:laid-at-turn";
|
|
76
|
+
const HATCHED_INTO_PREDICATE = "mgx:hatched-into";
|
|
77
|
+
|
|
78
|
+
const EGG_KIND = "egg";
|
|
79
|
+
|
|
70
80
|
// One row per tick, whatever else the turn did, on a subject the board itself
|
|
71
81
|
// owns. It is what the next tick's turn number counts, and it exists as its own
|
|
72
82
|
// predicate rather than being read off the mood rows because a board with
|
|
@@ -79,6 +89,8 @@ const MUDIII_STATE_PREDICATE_SET = new Set([
|
|
|
79
89
|
PLACEMENT_PREDICATE, MASS_PREDICATE, MOOD_PREDICATE, FACING_PREDICATE,
|
|
80
90
|
EATEN_BY_PREDICATE, STARVED_PREDICATE, PLACED_BY_PREDICATE,
|
|
81
91
|
MODEL_PREDICATE, ROTATION_PREDICATE, TURN_PLAYED_PREDICATE,
|
|
92
|
+
CARRYING_PREDICATE, PREY_EATEN_PREDICATE, WEB_BUILT_PREDICATE,
|
|
93
|
+
LAID_AT_TURN_PREDICATE, HATCHED_INTO_PREDICATE,
|
|
82
94
|
"rdf:type", WORLD_EPOCH_PREDICATE,
|
|
83
95
|
]);
|
|
84
96
|
|
|
@@ -109,6 +121,14 @@ function seededPick(options, contextString) {
|
|
|
109
121
|
return options[Math.floor(rng() * options.length)];
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
/** One placement cell drawn on this engine's own seed scheme, for a cast layer
|
|
125
|
+
* minting a starting roster of its own rather than taking seededRoster's.
|
|
126
|
+
* Exported so a hand-built roster and an engine spawn draw from the same
|
|
127
|
+
* keyspace instead of a second, silently different one. */
|
|
128
|
+
export function seededSpawnCell(options, { layoutName, epoch = 0, turn = 0, id }) {
|
|
129
|
+
return seededPick(options, seedKey(layoutName, epoch, turn, id, "spawn"));
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
// ---- the (epoch, turn) fold ---------------------------------------------------
|
|
113
133
|
|
|
114
134
|
/** Fold fact rows into the current town-square state.
|
|
@@ -151,6 +171,12 @@ export function foldTownSquareState(factRows) {
|
|
|
151
171
|
const types = new Map(); // subject -> class named by its bare rdf:type row
|
|
152
172
|
const models = new Map(); // prop subject -> asset key
|
|
153
173
|
const starved = new Set();
|
|
174
|
+
const carrying = new Map(); // predator -> { prey, turn, epoch }
|
|
175
|
+
const carryingRank = new Map(); // predator -> the (epoch, turn) its newest carrying row carries
|
|
176
|
+
const preyEaten = new Map(); // predator -> { value, turn, epoch }
|
|
177
|
+
const laidAtTurn = new Map(); // egg -> { value, turn, epoch }
|
|
178
|
+
const webBuiltAt = new Map(); // web -> { value, turn, epoch }
|
|
179
|
+
const hatchedInto = new Map();// egg -> { into, turn, epoch }
|
|
154
180
|
let turnCount = 0;
|
|
155
181
|
let tickCount = 0;
|
|
156
182
|
|
|
@@ -193,6 +219,36 @@ export function foldTownSquareState(factRows) {
|
|
|
193
219
|
case STARVED_PREDICATE:
|
|
194
220
|
if (rowEpoch === epoch) starved.add(base);
|
|
195
221
|
break;
|
|
222
|
+
// A predator re-asserts what it holds every turn, and "none" is a real
|
|
223
|
+
// answer. The rank is tracked apart from the value so a dropped catch
|
|
224
|
+
// cannot be undone by an older row arriving later in the array.
|
|
225
|
+
case CARRYING_PREDICATE:
|
|
226
|
+
if (!outranks(rowEpoch, turn, carryingRank.get(base))) break;
|
|
227
|
+
carryingRank.set(base, { turn, epoch: rowEpoch });
|
|
228
|
+
if (row.object === "none") carrying.delete(base);
|
|
229
|
+
else carrying.set(base, { prey: row.object, turn, epoch: rowEpoch });
|
|
230
|
+
break;
|
|
231
|
+
case PREY_EATEN_PREDICATE: {
|
|
232
|
+
const value = Number(row.object);
|
|
233
|
+
if (!Number.isFinite(value)) break;
|
|
234
|
+
if (outranks(rowEpoch, turn, preyEaten.get(base))) preyEaten.set(base, { value, turn, epoch: rowEpoch });
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case LAID_AT_TURN_PREDICATE: {
|
|
238
|
+
const value = Number(row.object);
|
|
239
|
+
if (!Number.isInteger(value)) break;
|
|
240
|
+
if (outranks(rowEpoch, turn, laidAtTurn.get(base))) laidAtTurn.set(base, { value, turn, epoch: rowEpoch });
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
case WEB_BUILT_PREDICATE: {
|
|
244
|
+
const value = Number(row.object);
|
|
245
|
+
if (!Number.isInteger(value)) break;
|
|
246
|
+
if (outranks(rowEpoch, turn, webBuiltAt.get(base))) webBuiltAt.set(base, { value, turn, epoch: rowEpoch });
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
case HATCHED_INTO_PREDICATE:
|
|
250
|
+
if (outranks(rowEpoch, turn, hatchedInto.get(base))) hatchedInto.set(base, { into: row.object, turn, epoch: rowEpoch });
|
|
251
|
+
break;
|
|
196
252
|
case MODEL_PREDICATE:
|
|
197
253
|
models.set(base, row.object);
|
|
198
254
|
break;
|
|
@@ -211,8 +267,24 @@ export function foldTownSquareState(factRows) {
|
|
|
211
267
|
for (const [subject, { epoch: rowEpoch }] of eatenBy) {
|
|
212
268
|
if (rowEpoch === epoch) removed.add(subject);
|
|
213
269
|
}
|
|
270
|
+
for (const [subject, { epoch: rowEpoch }] of hatchedInto) {
|
|
271
|
+
if (rowEpoch === epoch) removed.add(subject);
|
|
272
|
+
}
|
|
214
273
|
|
|
215
|
-
|
|
274
|
+
// A web is a placed individual plus the turn it was spun. Both halves are
|
|
275
|
+
// needed, and only this epoch's webs stand: a recast leaves the board bare.
|
|
276
|
+
const webs = new Map(); // web subject -> { cell, builtAtTurn }
|
|
277
|
+
for (const [id, built] of webBuiltAt) {
|
|
278
|
+
if (built.epoch !== epoch) continue;
|
|
279
|
+
const place = placements.get(id);
|
|
280
|
+
if (place) webs.set(id, { cell: place.cell, builtAtTurn: built.value });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
placements, mass, mood, facing, eatenBy, placedBy, types, models, starved,
|
|
285
|
+
carrying, preyEaten, laidAtTurn, hatchedInto, webs,
|
|
286
|
+
removed, turnCount, tickCount, epoch,
|
|
287
|
+
};
|
|
216
288
|
}
|
|
217
289
|
|
|
218
290
|
/** Every live subject whose id names `kind`, sorted. */
|
|
@@ -268,26 +340,41 @@ export const pathStateKey = (searchState) => cellId(searchState.x, searchState.y
|
|
|
268
340
|
const oneStepOptions = (fromCell, applyActions) =>
|
|
269
341
|
[fromCell, ...findReachableSet(fromCell, applyActions, { maxDepth: 1, stateKey: pathStateKey }).map((r) => r.node)];
|
|
270
342
|
|
|
271
|
-
function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter) {
|
|
343
|
+
function bestOneStepBy(fromCell, applyActions, scoreOf, isBetter, tieBreakScoreOf = null) {
|
|
272
344
|
const options = oneStepOptions(fromCell, applyActions);
|
|
273
345
|
let best = options[0];
|
|
274
346
|
let bestScore = scoreOf(best);
|
|
347
|
+
let bestTieScore = tieBreakScoreOf ? tieBreakScoreOf(best) : null;
|
|
275
348
|
for (let i = 1; i < options.length; i += 1) {
|
|
276
349
|
const score = scoreOf(options[i]);
|
|
277
|
-
if (isBetter(score, bestScore)) {
|
|
350
|
+
if (isBetter(score, bestScore)) {
|
|
351
|
+
bestScore = score; best = options[i];
|
|
352
|
+
bestTieScore = tieBreakScoreOf ? tieBreakScoreOf(options[i]) : null;
|
|
353
|
+
} else if (tieBreakScoreOf && score === bestScore) {
|
|
354
|
+
const tieScore = tieBreakScoreOf(options[i]);
|
|
355
|
+
if (tieScore < bestTieScore) { bestTieScore = tieScore; best = options[i]; }
|
|
356
|
+
}
|
|
278
357
|
}
|
|
279
358
|
return best;
|
|
280
359
|
}
|
|
281
360
|
|
|
282
361
|
/** One-ply greedy: the reachable cell (or staying put) furthest in Chebyshev
|
|
283
362
|
* terms from `awayFrom`. Both the prey's evade rung and the predator's avoid
|
|
284
|
-
* rung are this function.
|
|
285
|
-
|
|
363
|
+
* rung are this function.
|
|
364
|
+
*
|
|
365
|
+
* `opts.towardCell`, when given, breaks a tie among equally-safe cells in
|
|
366
|
+
* favor of whichever is closest to it — a fleeing prey that knows where food
|
|
367
|
+
* is should flee toward it, not toward whichever direction DIRECTION_DELTA's
|
|
368
|
+
* key order happens to check first. Opt-in and null by default, so a caller
|
|
369
|
+
* that never passes it (the predator's own avoid rung) sees no change at
|
|
370
|
+
* all: same options, same scores, same first-wins tie order. */
|
|
371
|
+
export function greedyAway(fromCell, awayFrom, applyActions, { towardCell = null } = {}) {
|
|
286
372
|
if (!awayFrom) return fromCell;
|
|
287
373
|
return bestOneStepBy(
|
|
288
374
|
fromCell, applyActions,
|
|
289
375
|
(cell) => chebyshevDistance(cell.x, cell.y, awayFrom.x, awayFrom.y),
|
|
290
376
|
(score, bestScore) => score > bestScore,
|
|
377
|
+
towardCell ? (cell) => chebyshevDistance(cell.x, cell.y, towardCell.x, towardCell.y) : null,
|
|
291
378
|
);
|
|
292
379
|
}
|
|
293
380
|
|
|
@@ -309,6 +396,49 @@ export function seededWander(fromCell, applyActions, { layoutName, epoch, turn,
|
|
|
309
396
|
return seededPick(oneStepOptions(fromCell, applyActions), seedKey(layoutName, epoch, turn, id, "wander"));
|
|
310
397
|
}
|
|
311
398
|
|
|
399
|
+
// ---- webs, when a cast asks for them ---------------------------------------------
|
|
400
|
+
// One predicate answers "is this cell webbed right now", and both the eat gate
|
|
401
|
+
// and the prey's movement gate read it, so a layout's always-on block and a
|
|
402
|
+
// predator-spun web are ONE concept rather than two code paths.
|
|
403
|
+
|
|
404
|
+
/** Whether (x, y) is webbed on `turn`: inside the layout's own always-on block
|
|
405
|
+
* (`layout.staticWebAt`, absent on a layout with no such block), or covered by
|
|
406
|
+
* a predator-built web still inside `webDurationTurns` of the turn it was
|
|
407
|
+
* spun. False for a board that has neither. Pure. */
|
|
408
|
+
export function hasActiveWebAt(layout, state, turn, x, y, webDurationTurns) {
|
|
409
|
+
if (layout?.staticWebAt?.(x, y)) return true;
|
|
410
|
+
if (!state?.webs?.size || !webDurationTurns) return false;
|
|
411
|
+
const target = cellId(x, y);
|
|
412
|
+
for (const { cell, builtAtTurn } of state.webs.values()) {
|
|
413
|
+
if (cell === target && builtAtTurn + webDurationTurns > turn) return true;
|
|
414
|
+
}
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Every unexpired predator-built web in `websMap` (a folded state's own
|
|
419
|
+
* `.webs`, or that widened with one spun THIS tick before it has been read
|
|
420
|
+
* back), as `{ id, cell, builtAtTurn, expiresAtTurn }` — a renderer draws
|
|
421
|
+
* these apart from the layout's static block, which is fixed geometry rather
|
|
422
|
+
* than runtime state. Pure. */
|
|
423
|
+
export function liveWebs(websMap, turn, webDurationTurns) {
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const [id, { cell, builtAtTurn }] of websMap || []) {
|
|
426
|
+
if (builtAtTurn + webDurationTurns > turn) out.push({ id, cell, builtAtTurn, expiresAtTurn: builtAtTurn + webDurationTurns });
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** A carrying predator's own path home: the shortest route to ANY webbed cell,
|
|
432
|
+
* rather than to one named target. Null when no web is reachable at all. */
|
|
433
|
+
function pathToAnyWeb(fromCell, applyActions, { layout, state, turn, webDurationTurns }) {
|
|
434
|
+
return findActionPath(
|
|
435
|
+
fromCell,
|
|
436
|
+
(s) => hasActiveWebAt(layout, state, turn, s.x, s.y, webDurationTurns),
|
|
437
|
+
applyActions,
|
|
438
|
+
{ stateKey: pathStateKey },
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
312
442
|
/** A one-step "plan" for a greedy or held move — the direction as a length-1
|
|
313
443
|
* array, or `[]` when the agent held still. A genuine multi-step search result
|
|
314
444
|
* supplies its own full direction list instead. Either way plan[0] is the
|
|
@@ -327,13 +457,21 @@ const round2 = (n) => Math.round(n * 100) / 100;
|
|
|
327
457
|
// is scared, anything wandering or foraging is calm, and anything that just ate
|
|
328
458
|
// is happy.
|
|
329
459
|
|
|
330
|
-
function goalLine(kind, { subject, cell, arrived } = {}) {
|
|
460
|
+
function goalLine(kind, { subject, cell, arrived, boardNoun = "square", catches = false } = {}) {
|
|
331
461
|
switch (kind) {
|
|
332
462
|
case "avoid": return `avoiding ${subject}, last seen at ${cell}.`;
|
|
333
|
-
case "chase":
|
|
463
|
+
case "chase":
|
|
464
|
+
if (!arrived) return `chasing ${subject}, last seen at ${cell}.`;
|
|
465
|
+
return catches ? `co-located with ${subject} — catching it.` : `standing over ${subject} — taking it.`;
|
|
334
466
|
case "evade": return `evading — last saw ${subject} at ${cell}.`;
|
|
335
467
|
case "forage": return arrived ? `standing on ${subject} — eating it.` : `foraging — heading for ${subject} at ${cell}.`;
|
|
336
|
-
|
|
468
|
+
case "carry": return `carrying ${subject} toward the web.`;
|
|
469
|
+
case "deliver": return `carrying ${subject} — already in the web, delivering it now.`;
|
|
470
|
+
case "carried": return `being carried by ${subject}.`;
|
|
471
|
+
case "trapped": return "trapped in an active web — can't move.";
|
|
472
|
+
case "hold-web": return "nothing in sight — holding position in the web.";
|
|
473
|
+
case "build-web": return "nothing in sight — building a web here.";
|
|
474
|
+
default: return `nothing in sight — wandering the ${boardNoun}.`;
|
|
337
475
|
}
|
|
338
476
|
}
|
|
339
477
|
|
|
@@ -343,20 +481,30 @@ function goalLine(kind, { subject, cell, arrived } = {}) {
|
|
|
343
481
|
// 1. eat-agent — a predator sharing a cell with prey takes it, and gains the
|
|
344
482
|
// prey's remaining mass. Catch and eat are one step: a predator needs no
|
|
345
483
|
// apparatus, so nothing is ever carried and no state survives the turn.
|
|
484
|
+
// Under `carryPreyToWeb` this splits in two — catch-prey claims the prey
|
|
485
|
+
// wherever they meet, and the eat waits until the captor is standing in a
|
|
486
|
+
// web. At most one catch per predator per turn.
|
|
346
487
|
// 2. eat-item — prey standing on food eat it. AFTER eat-agent, so a prey
|
|
347
488
|
// taken this turn is never also credited with a crumb. A prey on two
|
|
348
489
|
// crumbs eats both.
|
|
349
490
|
// 3. starve — mass reached zero, and nothing this turn already claimed it.
|
|
350
|
-
// Whatever ate this turn survives it, because eating resolved first.
|
|
351
|
-
//
|
|
491
|
+
// Whatever ate this turn survives it, because eating resolved first. A
|
|
492
|
+
// carried prey starves like a free one, and its captor's claim drops.
|
|
493
|
+
// 4. lay-egg — `layEggs` only. A predator standing in a web whose mass has
|
|
494
|
+
// reached the threshold lays one egg and resets to its own starting mass;
|
|
495
|
+
// the surplus becomes the egg's. One live egg at a time.
|
|
496
|
+
// 5. hatch-egg — `layEggs` only. An egg laid exactly eggHatchDelayTurns ago
|
|
497
|
+
// splits its mass across its hatchlings, fewer of them rather than
|
|
498
|
+
// emaciated ones when the egg is small.
|
|
499
|
+
// 6. spawn-prey — arrivals wander in from the edge, so the pick is the
|
|
352
500
|
// perimeter minus prop cells. That subtraction is not a nicety: the
|
|
353
501
|
// headline layout has buildings sitting on two whole edges.
|
|
354
|
-
//
|
|
502
|
+
// 7. spawn-food — dropped bread lands anywhere open, so the pick is every
|
|
355
503
|
// open cell minus whatever stands or lies on one. Last, so it reads the
|
|
356
504
|
// board as every earlier step left it.
|
|
357
505
|
//
|
|
358
506
|
// Both spawns check their cap first, so a full board simply skips that turn's
|
|
359
|
-
// arrival.
|
|
507
|
+
// arrival, and a cast that sets no interval never spawns that kind at all.
|
|
360
508
|
|
|
361
509
|
function runEcologyPass({
|
|
362
510
|
state, layout, roles, config, turn, epoch,
|
|
@@ -366,6 +514,9 @@ function runEcologyPass({
|
|
|
366
514
|
const writes = [];
|
|
367
515
|
const events = [];
|
|
368
516
|
const stamp = (base) => snapshotSubject(base, k, epoch);
|
|
517
|
+
const carriesPrey = config.carryPreyToWeb === true;
|
|
518
|
+
const laysEggs = config.layEggs === true;
|
|
519
|
+
const webbedAt = (c) => hasActiveWebAt(layout, state, k, c.x, c.y, config.webDurationTurns);
|
|
369
520
|
|
|
370
521
|
const predators = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "predator").sort();
|
|
371
522
|
const prey = [...postMovePlacements.keys()].filter((id) => roleOfId(id, roles) === "prey").sort();
|
|
@@ -373,19 +524,60 @@ function runEcologyPass({
|
|
|
373
524
|
const takenAgents = new Set();
|
|
374
525
|
const takenItems = new Set();
|
|
375
526
|
|
|
376
|
-
// 1. eat-agent
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
for (const
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
527
|
+
// 1. eat-agent, or catch-then-eat for a cast that carries.
|
|
528
|
+
const carriedBy = new Map(); // predator -> prey it holds at the end of this turn
|
|
529
|
+
if (!carriesPrey) {
|
|
530
|
+
for (const predatorId of predators) {
|
|
531
|
+
const pc = postMovePlacements.get(predatorId);
|
|
532
|
+
for (const preyId of prey) {
|
|
533
|
+
if (takenAgents.has(preyId)) continue;
|
|
534
|
+
const qc = postMovePlacements.get(preyId);
|
|
535
|
+
if (pc.x !== qc.x || pc.y !== qc.y) continue;
|
|
536
|
+
takenAgents.add(preyId);
|
|
537
|
+
const gained = finalMass.get(preyId) ?? 0;
|
|
538
|
+
finalMass.set(predatorId, (finalMass.get(predatorId) ?? 0) + gained);
|
|
539
|
+
writes.push({ subject: stamp(preyId), predicate: EATEN_BY_PREDICATE, object: predatorId });
|
|
540
|
+
events.push({ type: "eat-agent", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y), massGained: round2(gained) });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
// Whatever each predator was already holding before this tick, dropped for
|
|
545
|
+
// a captor or a prey that has since left the board.
|
|
546
|
+
for (const [predatorId, { prey: preyId }] of state.carrying) {
|
|
547
|
+
if (postMovePlacements.has(predatorId) && postMovePlacements.has(preyId)) carriedBy.set(predatorId, preyId);
|
|
548
|
+
}
|
|
549
|
+
const claimed = new Set(carriedBy.values());
|
|
550
|
+
for (const predatorId of predators) {
|
|
551
|
+
if (carriedBy.has(predatorId)) continue;
|
|
552
|
+
const pc = postMovePlacements.get(predatorId);
|
|
553
|
+
for (const preyId of prey) {
|
|
554
|
+
if (claimed.has(preyId)) continue;
|
|
555
|
+
const qc = postMovePlacements.get(preyId);
|
|
556
|
+
if (pc.x !== qc.x || pc.y !== qc.y) continue;
|
|
557
|
+
carriedBy.set(predatorId, preyId);
|
|
558
|
+
claimed.add(preyId);
|
|
559
|
+
events.push({ type: "catch-prey", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y) });
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const eatenDelta = new Map();
|
|
564
|
+
for (const predatorId of predators) {
|
|
565
|
+
const preyId = carriedBy.get(predatorId);
|
|
566
|
+
if (!preyId) continue;
|
|
567
|
+
const pc = postMovePlacements.get(predatorId);
|
|
568
|
+
if (!webbedAt(pc)) continue;
|
|
569
|
+
carriedBy.delete(predatorId);
|
|
383
570
|
takenAgents.add(preyId);
|
|
384
571
|
const gained = finalMass.get(preyId) ?? 0;
|
|
385
572
|
finalMass.set(predatorId, (finalMass.get(predatorId) ?? 0) + gained);
|
|
573
|
+
eatenDelta.set(predatorId, (eatenDelta.get(predatorId) ?? 0) + 1);
|
|
386
574
|
writes.push({ subject: stamp(preyId), predicate: EATEN_BY_PREDICATE, object: predatorId });
|
|
387
575
|
events.push({ type: "eat-agent", predator: predatorId, prey: preyId, cell: cellId(pc.x, pc.y), massGained: round2(gained) });
|
|
388
576
|
}
|
|
577
|
+
for (const [predatorId, delta] of eatenDelta) {
|
|
578
|
+
const count = (state.preyEaten.get(predatorId)?.value ?? 0) + delta;
|
|
579
|
+
writes.push({ subject: stamp(predatorId), predicate: PREY_EATEN_PREDICATE, object: String(count) });
|
|
580
|
+
}
|
|
389
581
|
}
|
|
390
582
|
|
|
391
583
|
// 2. eat-item
|
|
@@ -417,22 +609,93 @@ function runEcologyPass({
|
|
|
417
609
|
}
|
|
418
610
|
|
|
419
611
|
const goneThisTurn = new Set([...takenAgents, ...starvedThisTurn]);
|
|
612
|
+
const spawned = [];
|
|
613
|
+
|
|
614
|
+
// A predator re-asserts what it holds every turn, whether or not that
|
|
615
|
+
// changed: a write that merely stopped would leave a stale claim standing
|
|
616
|
+
// for the fold to keep honoring. A prey that starved mid-carry is dropped.
|
|
617
|
+
if (carriesPrey) {
|
|
618
|
+
for (const [predatorId, preyId] of carriedBy) {
|
|
619
|
+
if (starvedThisTurn.has(preyId)) carriedBy.delete(predatorId);
|
|
620
|
+
}
|
|
621
|
+
for (const predatorId of predators) {
|
|
622
|
+
writes.push({ subject: stamp(predatorId), predicate: CARRYING_PREDICATE, object: carriedBy.get(predatorId) ?? "none" });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// 4. lay-egg
|
|
627
|
+
const liveEggIds = laysEggs ? [...state.laidAtTurn.keys()].filter((id) => !state.removed.has(id)).sort() : [];
|
|
628
|
+
if (laysEggs && !liveEggIds.length) {
|
|
629
|
+
for (const predatorId of predators) {
|
|
630
|
+
if (goneThisTurn.has(predatorId)) continue;
|
|
631
|
+
if (!webbedAt(postMovePlacements.get(predatorId))) continue;
|
|
632
|
+
const predatorMass = finalMass.get(predatorId) ?? 0;
|
|
633
|
+
if (predatorMass < config.eggLayMassThreshold) continue;
|
|
634
|
+
const eggMass = predatorMass - config.predatorInitialMass;
|
|
635
|
+
finalMass.set(predatorId, config.predatorInitialMass);
|
|
636
|
+
const c = postMovePlacements.get(predatorId);
|
|
637
|
+
const eggCell = cellId(c.x, c.y);
|
|
638
|
+
const eggId = `${EGG_KIND}-${1 + maxIdSuffix(state.placements.keys(), EGG_KIND)}`;
|
|
639
|
+
writes.push({ subject: eggId, predicate: "rdf:type", object: EGG_KIND });
|
|
640
|
+
writes.push({ subject: stamp(eggId), predicate: PLACEMENT_PREDICATE, object: eggCell });
|
|
641
|
+
writes.push({ subject: stamp(eggId), predicate: LAID_AT_TURN_PREDICATE, object: String(k) });
|
|
642
|
+
writes.push({ subject: stamp(eggId), predicate: MASS_PREDICATE, object: String(eggMass) });
|
|
643
|
+
events.push({ type: "lay-egg", agent: predatorId, egg: eggId, cell: eggCell, mass: round2(eggMass) });
|
|
644
|
+
break; // one live egg at a time — the first qualifying predator lays it
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// 5. hatch-egg. Reads the egg slot as it stood BEFORE this tick's own lay, so
|
|
649
|
+
// a hatch and a fresh lay never land on the same turn.
|
|
650
|
+
const hatchedCells = new Set();
|
|
651
|
+
if (laysEggs) {
|
|
652
|
+
let nextHatchlingNum = 1 + maxIdSuffix(state.placements.keys(), roles.predator.kind);
|
|
653
|
+
for (const eggId of liveEggIds) {
|
|
654
|
+
if (state.laidAtTurn.get(eggId).value + config.eggHatchDelayTurns !== k) continue;
|
|
655
|
+
const eggCell = state.placements.get(eggId)?.cell;
|
|
656
|
+
if (!eggCell) continue;
|
|
657
|
+
const eggMass = state.mass.get(eggId)?.value ?? config.predatorInitialMass;
|
|
658
|
+
const hatchCount = Math.max(1, Math.min(config.eggHatchCount, Math.floor(eggMass / config.minHatchlingMass)));
|
|
659
|
+
const share = Math.floor(eggMass / hatchCount);
|
|
660
|
+
const remainder = eggMass - share * hatchCount;
|
|
661
|
+
const hatchlings = [];
|
|
662
|
+
for (let i = 0; i < hatchCount; i += 1) {
|
|
663
|
+
const hatchlingId = `${roles.predator.idPrefix}-${nextHatchlingNum}`;
|
|
664
|
+
nextHatchlingNum += 1;
|
|
665
|
+
const hatchlingMass = share + (i === 0 ? remainder : 0);
|
|
666
|
+
writes.push({ subject: hatchlingId, predicate: "rdf:type", object: roles.predator.kind });
|
|
667
|
+
writes.push({ subject: stamp(hatchlingId), predicate: PLACEMENT_PREDICATE, object: eggCell });
|
|
668
|
+
writes.push({ subject: stamp(hatchlingId), predicate: MASS_PREDICATE, object: String(hatchlingMass) });
|
|
669
|
+
writes.push({ subject: stamp(hatchlingId), predicate: FACING_PREDICATE, object: DEFAULT_FACING });
|
|
670
|
+
hatchlings.push({ id: hatchlingId, mass: round2(hatchlingMass) });
|
|
671
|
+
spawned.push({ id: hatchlingId, role: "predator", cell: eggCell, mass: hatchlingMass, goal: "just hatched — no goal yet." });
|
|
672
|
+
}
|
|
673
|
+
writes.push({ subject: stamp(eggId), predicate: HATCHED_INTO_PREDICATE, object: hatchlings[0].id });
|
|
674
|
+
events.push({ type: "hatch-egg", egg: eggId, cell: eggCell, hatchlings });
|
|
675
|
+
hatchedCells.add(eggCell);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
420
679
|
const occupied = new Set();
|
|
421
680
|
for (const [id, c] of postMovePlacements) {
|
|
422
681
|
if (goneThisTurn.has(id)) continue;
|
|
423
682
|
occupied.add(cellId(c.x, c.y));
|
|
424
683
|
}
|
|
684
|
+
for (const eggId of liveEggIds) {
|
|
685
|
+
const eggCell = state.placements.get(eggId)?.cell;
|
|
686
|
+
if (eggCell) occupied.add(eggCell);
|
|
687
|
+
}
|
|
688
|
+
for (const cell of hatchedCells) occupied.add(cell);
|
|
425
689
|
const itemCells = new Set();
|
|
426
690
|
for (const itemId of liveItemIds) {
|
|
427
691
|
if (takenItems.has(itemId)) continue;
|
|
428
692
|
itemCells.add(itemCellOf.get(itemId));
|
|
429
693
|
}
|
|
430
694
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
// 4. spawn-prey
|
|
695
|
+
// 6. spawn-prey
|
|
434
696
|
const livePreyAfter = prey.filter((id) => !goneThisTurn.has(id)).length;
|
|
435
|
-
|
|
697
|
+
const preyCap = config.maxPreyPopulation ?? Infinity;
|
|
698
|
+
if (config.preySpawnIntervalTurns && k % config.preySpawnIntervalTurns === 0 && livePreyAfter < preyCap) {
|
|
436
699
|
const free = perimeterCells(layout).filter((c) => !occupied.has(c));
|
|
437
700
|
if (free.length) {
|
|
438
701
|
const preyId = `${roles.prey.idPrefix}-${1 + maxIdSuffix(state.placements.keys(), roles.prey.kind)}`;
|
|
@@ -447,9 +710,10 @@ function runEcologyPass({
|
|
|
447
710
|
}
|
|
448
711
|
}
|
|
449
712
|
|
|
450
|
-
//
|
|
713
|
+
// 7. spawn-food
|
|
451
714
|
const liveFoodAfter = liveItemIds.filter((id) => !takenItems.has(id)).length;
|
|
452
|
-
|
|
715
|
+
const foodCap = config.maxFoodItems ?? Infinity;
|
|
716
|
+
if (config.foodSpawnIntervalTurns && k % config.foodSpawnIntervalTurns === 0 && liveFoodAfter < foodCap) {
|
|
453
717
|
const free = openCells(layout).filter((c) => !occupied.has(c) && !itemCells.has(c));
|
|
454
718
|
if (free.length) {
|
|
455
719
|
const itemId = `${roles.food.spawnedKind}-${1 + maxIdSuffix(state.placements.keys(), roles.food.spawnedKind)}`;
|
|
@@ -485,8 +749,24 @@ export function roleOfId(id, roles = MUDIII_ROLES) {
|
|
|
485
749
|
/**
|
|
486
750
|
* Seed a fresh town square: one predator/prey roster placed on the board, each
|
|
487
751
|
* with its role's starting mass, a `calm` mood and a facing. A no-op when the
|
|
488
|
-
*
|
|
489
|
-
* whether the game has started
|
|
752
|
+
* given `epoch` already has a roster minted — safe to call from a caller
|
|
753
|
+
* unsure whether the game has started, and safe to call again from
|
|
754
|
+
* recastTownSquare below without double-minting the epoch it just opened.
|
|
755
|
+
*
|
|
756
|
+
* The guard checks the EPOCH the existing roster's own placement is stamped
|
|
757
|
+
* to, not merely whether one exists: `state.placements.has(firstPredator)`
|
|
758
|
+
* alone would still be true on a store recastTownSquare just moved onto a new
|
|
759
|
+
* epoch, because it only ever looks at the base id, never at which run
|
|
760
|
+
* planted it. Every roster fact this function writes is stamped through
|
|
761
|
+
* `snapshotSubject(id, 0, epoch)` for exactly that reason — a bare "fox-1"
|
|
762
|
+
* placement fact from one epoch and another from the next would collide as
|
|
763
|
+
* the SAME (subject, predicate) at the memory layer and have to be resolved
|
|
764
|
+
* there (by observation time, falling back to trust and then to the object
|
|
765
|
+
* string) instead of by this file's own epoch-and-turn fold, which is a
|
|
766
|
+
* needless and less predictable path to the same answer. rdf:type stays
|
|
767
|
+
* bare on purpose: a roster id's class does not change on a recast, and
|
|
768
|
+
* foldTownSquareState's own fold only ever takes the first unstamped type row
|
|
769
|
+
* it sees for a subject.
|
|
490
770
|
*
|
|
491
771
|
* `opts.agents` places an explicit roster (`{ id: { role, cell, facing, mass } }`)
|
|
492
772
|
* — how a recorded fixture pins a starting board. Without it the roster is
|
|
@@ -500,7 +780,7 @@ export async function startTownSquareGame(memoryDir, {
|
|
|
500
780
|
} = {}) {
|
|
501
781
|
const state = foldTownSquareState(readFactRows(await loadMemory(memoryDir)));
|
|
502
782
|
const firstPredator = `${roles.predator.idPrefix}-1`;
|
|
503
|
-
if (state.placements.
|
|
783
|
+
if (state.placements.get(firstPredator)?.epoch === epoch) return { started: false, facts: [] };
|
|
504
784
|
|
|
505
785
|
const roster = agents ?? seededRoster(layout, {
|
|
506
786
|
predators: predatorCount ?? layout.cast.predators,
|
|
@@ -508,6 +788,7 @@ export async function startTownSquareGame(memoryDir, {
|
|
|
508
788
|
roles, epoch,
|
|
509
789
|
});
|
|
510
790
|
|
|
791
|
+
const stamp = (base) => snapshotSubject(base, 0, epoch);
|
|
511
792
|
const facts = [];
|
|
512
793
|
for (const id of Object.keys(roster).sort()) {
|
|
513
794
|
const spec = roster[id];
|
|
@@ -515,15 +796,53 @@ export async function startTownSquareGame(memoryDir, {
|
|
|
515
796
|
const kind = role === "predator" ? roles.predator.kind : roles.prey.kind;
|
|
516
797
|
const mass = spec.mass ?? (role === "predator" ? config.predatorInitialMass : config.preyInitialMass);
|
|
517
798
|
facts.push({ subject: id, predicate: "rdf:type", object: kind });
|
|
518
|
-
facts.push({ subject: id, predicate: PLACEMENT_PREDICATE, object: spec.cell });
|
|
519
|
-
facts.push({ subject: id, predicate: MASS_PREDICATE, object: String(mass) });
|
|
520
|
-
facts.push({ subject: id, predicate: MOOD_PREDICATE, object: "calm" });
|
|
521
|
-
facts.push({ subject: id, predicate: FACING_PREDICATE, object: spec.facing ?? DEFAULT_FACING });
|
|
799
|
+
facts.push({ subject: stamp(id), predicate: PLACEMENT_PREDICATE, object: spec.cell });
|
|
800
|
+
facts.push({ subject: stamp(id), predicate: MASS_PREDICATE, object: String(mass) });
|
|
801
|
+
facts.push({ subject: stamp(id), predicate: MOOD_PREDICATE, object: "calm" });
|
|
802
|
+
facts.push({ subject: stamp(id), predicate: FACING_PREDICATE, object: spec.facing ?? DEFAULT_FACING });
|
|
522
803
|
}
|
|
523
804
|
await appendFacts(memoryDir, facts.map((f) => ({ ...f, provenance: worldProvenanceTag(layout.name) })));
|
|
524
805
|
return { started: true, facts };
|
|
525
806
|
}
|
|
526
807
|
|
|
808
|
+
/**
|
|
809
|
+
* Move a LIVE town square onto a new epoch, in place, and re-mint its roster
|
|
810
|
+
* for that epoch — the recast an EDIT-mode change or an epoch bump needs
|
|
811
|
+
* without re-opening the whole session over a brand new store. Two steps:
|
|
812
|
+
* append the epoch marker (worldEpochFact, the same bare triple adventure.mjs
|
|
813
|
+
* writes for its own recast), then call startTownSquareGame for the new
|
|
814
|
+
* epoch.
|
|
815
|
+
*
|
|
816
|
+
* That second call is not redundant with a plain startTownSquareGame call:
|
|
817
|
+
* this only works because startTownSquareGame's own idempotency guard is
|
|
818
|
+
* epoch-aware (see its doc comment) and because its roster facts are
|
|
819
|
+
* epoch-stamped. Without both, the previous epoch's still-placed roster would
|
|
820
|
+
* read as "already minted" for the epoch being opened now, and this function
|
|
821
|
+
* would silently do nothing.
|
|
822
|
+
*
|
|
823
|
+
* `epoch` defaults to one past whatever the store is currently on, so a
|
|
824
|
+
* caller that just wants "recast, fresh epoch" carries no bookkeeping of its
|
|
825
|
+
* own. Throws if given an epoch that would not actually move the store
|
|
826
|
+
* forward — epochs are add-only by construction (the fold takes the max), so
|
|
827
|
+
* a non-increasing one could never become current no matter what it wrote.
|
|
828
|
+
*/
|
|
829
|
+
export async function recastTownSquare(memoryDir, {
|
|
830
|
+
layout, epoch = null, agents = null, predatorCount = null, preyCount = null,
|
|
831
|
+
config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
|
|
832
|
+
} = {}) {
|
|
833
|
+
const lay = typeof layout === "string" ? TOWN_SQUARE_LAYOUTS[layout] : layout;
|
|
834
|
+
if (!lay) throw new Error(`recastTownSquare: no such layout "${layout}"`);
|
|
835
|
+
const state = foldTownSquareState(readFactRows(await loadMemory(memoryDir)));
|
|
836
|
+
const nextEpoch = epoch ?? state.epoch + 1;
|
|
837
|
+
if (!Number.isInteger(nextEpoch) || nextEpoch <= state.epoch) {
|
|
838
|
+
throw new Error(`recastTownSquare: epoch must be an integer greater than the current epoch (${state.epoch}), got ${nextEpoch}`);
|
|
839
|
+
}
|
|
840
|
+
await appendFacts(memoryDir, [{ ...worldEpochFact(nextEpoch), provenance: worldProvenanceTag(lay.name) }]);
|
|
841
|
+
return startTownSquareGame(memoryDir, {
|
|
842
|
+
layout: lay, agents, predatorCount, preyCount, config, roles, epoch: nextEpoch,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
|
|
527
846
|
function seededRoster(layout, { predators, prey, roles, epoch }) {
|
|
528
847
|
const roster = {};
|
|
529
848
|
const taken = new Set();
|
|
@@ -622,7 +941,11 @@ export async function placeFood(memoryDir, {
|
|
|
622
941
|
function readLiveBoard(rows, state, { config, roles }) {
|
|
623
942
|
const predators = liveOfKind(state, roles.predator.kind);
|
|
624
943
|
const prey = liveOfKind(state, roles.prey.kind);
|
|
625
|
-
|
|
944
|
+
// `roles.food` is null for a cast with nothing inert on its board. Every
|
|
945
|
+
// reader downstream walks the item roster, so an empty one is all it takes.
|
|
946
|
+
const liveItemIds = roles.food
|
|
947
|
+
? [...liveOfKind(state, roles.food.spawnedKind), ...liveOfKind(state, roles.food.placedKind)].sort()
|
|
948
|
+
: [];
|
|
626
949
|
return {
|
|
627
950
|
predators,
|
|
628
951
|
prey,
|
|
@@ -657,13 +980,16 @@ function itemsPayload(liveItemIds, { state, itemCellOf, roles, taken = null }) {
|
|
|
657
980
|
* to where the predator was when it looked), while eating resolves on the
|
|
658
981
|
* post-move ones (it is caught where the predator actually ends up).
|
|
659
982
|
*
|
|
660
|
-
* Returns `{ turn, epoch, agents, items, ecology, rungs, writes }`.
|
|
661
|
-
* and `items` and `ecology` are the frozen render payload — see
|
|
983
|
+
* Returns `{ turn, epoch, agents, items, ecology, rungs, activeWebs, writes }`.
|
|
984
|
+
* `agents` and `items` and `ecology` are the frozen render payload — see
|
|
662
985
|
* townSquareTickPayload, which projects exactly those three plus the turn.
|
|
663
986
|
* `rungs` is the decision each live agent reached this turn ("chase", "evade",
|
|
664
|
-
* "forage", "avoid", "wander"
|
|
665
|
-
*
|
|
666
|
-
*
|
|
987
|
+
* "forage", "avoid", "wander", and — on a cast that carries or spins webs —
|
|
988
|
+
* "carry", "deliver", "carried", "trapped", "hold-web", "build-web"); an agent
|
|
989
|
+
* that decided and then died still has a rung and no longer has an `agents`
|
|
990
|
+
* entry, which is the difference between a decision and a survivor.
|
|
991
|
+
* `activeWebs` is every unexpired predator-built web, empty on a board with
|
|
992
|
+
* none.
|
|
667
993
|
*/
|
|
668
994
|
export async function runTownSquareTick(memoryDir, {
|
|
669
995
|
layout, toldFacts = [], config = DEFAULT_GAME_CONFIG.mudiii, roles = MUDIII_ROLES,
|
|
@@ -684,10 +1010,40 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
684
1010
|
const agents = {};
|
|
685
1011
|
const rungs = {};
|
|
686
1012
|
|
|
1013
|
+
const carriesPrey = config.carryPreyToWeb === true;
|
|
1014
|
+
const buildsWebs = config.buildWebs === true;
|
|
1015
|
+
const boardNoun = lay.boardNoun ?? "square";
|
|
1016
|
+
const webbedAt = (c) => hasActiveWebAt(lay, state, k, c.x, c.y, config.webDurationTurns);
|
|
1017
|
+
// Widened in place as predators spin webs this tick, for the renderer's own
|
|
1018
|
+
// list. The gates above keep reading `state`, so a web spun this turn is not
|
|
1019
|
+
// yet active for this turn's eat, lay or trap.
|
|
1020
|
+
const tickWebs = new Map(state.webs);
|
|
1021
|
+
let nextWebNum = 1 + maxIdSuffix(state.webs.keys(), "web");
|
|
1022
|
+
// Prey a still-live predator was already holding when the tick opened. It
|
|
1023
|
+
// rides its captor's move instead of scoring one, and a captor that has left
|
|
1024
|
+
// the board simply isn't here, which frees the prey again.
|
|
1025
|
+
const captorOfPrey = new Map();
|
|
1026
|
+
if (carriesPrey) {
|
|
1027
|
+
for (const [predatorId, { prey: preyId }] of state.carrying) {
|
|
1028
|
+
if (predators.includes(predatorId) && prey.includes(preyId)) captorOfPrey.set(preyId, predatorId);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
687
1032
|
const decide = (agentId, role) => {
|
|
688
1033
|
const fromCell = parseCellId(state.placements.get(agentId).cell);
|
|
689
1034
|
const visionRadius = role === "predator" ? config.predatorVisionRadius : config.preyVisionRadius;
|
|
690
1035
|
const beliefOpts = { visionRadius, toldFacts };
|
|
1036
|
+
// True unless a caller explicitly turns it off. A config object built by
|
|
1037
|
+
// hand (a fixture, a test, an older tmct.toml read before this key
|
|
1038
|
+
// existed) can omit the key entirely, and `undefined` must still mean
|
|
1039
|
+
// "gated" — the shipped default — never silently flip to omniscient food.
|
|
1040
|
+
const foodVisionGated = config.foodVisionGated !== false;
|
|
1041
|
+
// Every food lookup goes through this instead of beliefOpts. Gated (the
|
|
1042
|
+
// default) it is beliefOpts itself, unchanged; ungated it is the same
|
|
1043
|
+
// opts widened to see the whole board — omniscient food is a
|
|
1044
|
+
// visionRadius: Infinity call, not new belief machinery. The rival-threat
|
|
1045
|
+
// lookup below stays on beliefOpts either way: this switch is about food.
|
|
1046
|
+
const foodBeliefOpts = foodVisionGated ? beliefOpts : { ...beliefOpts, visionRadius: Infinity };
|
|
691
1047
|
const rivals = role === "predator" ? predators.filter((id) => id !== agentId) : predators;
|
|
692
1048
|
const threat = nearestBelievedTarget(agentId, fromCell, rivals, state, beliefOpts);
|
|
693
1049
|
|
|
@@ -696,16 +1052,64 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
696
1052
|
let plan;
|
|
697
1053
|
let goal;
|
|
698
1054
|
let mood;
|
|
699
|
-
|
|
1055
|
+
// A carried prey and its captor both leave the ordinary chain. A carrying
|
|
1056
|
+
// predator never drops its catch to dodge a rival or chase a second one:
|
|
1057
|
+
// nothing here eats a predator, so "avoid" is contention, not survival.
|
|
1058
|
+
const carriedPreyId = (carriesPrey && role === "predator") ? (state.carrying.get(agentId)?.prey ?? null) : null;
|
|
1059
|
+
const isCarrying = Boolean(carriedPreyId) && prey.includes(carriedPreyId);
|
|
1060
|
+
const captorId = (carriesPrey && role === "prey") ? (captorOfPrey.get(agentId) ?? null) : null;
|
|
1061
|
+
let beliefCell = fromCell;
|
|
1062
|
+
|
|
1063
|
+
if (isCarrying && webbedAt(fromCell)) {
|
|
1064
|
+
// Already standing in a web: hold, so the pass's own eat gate reads this
|
|
1065
|
+
// same cell and resolves the delivery on this exact tick.
|
|
1066
|
+
rung = "deliver";
|
|
1067
|
+
nextCell = fromCell;
|
|
1068
|
+
plan = [];
|
|
1069
|
+
goal = goalLine("deliver", { subject: carriedPreyId });
|
|
1070
|
+
mood = "angry";
|
|
1071
|
+
} else if (isCarrying) {
|
|
1072
|
+
rung = "carry";
|
|
1073
|
+
const path = pathToAnyWeb(fromCell, applyActions, { layout: lay, state, turn: k, webDurationTurns: config.webDurationTurns });
|
|
1074
|
+
if (path && path.actions.length) {
|
|
1075
|
+
nextCell = path.states[1];
|
|
1076
|
+
plan = path.actions;
|
|
1077
|
+
} else {
|
|
1078
|
+
nextCell = greedyToward(fromCell, parseCellId(lay.webHomeCell) ?? fromCell, applyActions);
|
|
1079
|
+
plan = stepPlan(fromCell, nextCell);
|
|
1080
|
+
}
|
|
1081
|
+
goal = goalLine("carry", { subject: carriedPreyId });
|
|
1082
|
+
mood = "angry";
|
|
1083
|
+
} else if (captorId) {
|
|
1084
|
+
rung = "carried";
|
|
1085
|
+
nextCell = postMovePlacements.get(captorId);
|
|
1086
|
+
beliefCell = nextCell;
|
|
1087
|
+
plan = [];
|
|
1088
|
+
goal = goalLine("carried", { subject: captorId });
|
|
1089
|
+
mood = "scared";
|
|
1090
|
+
} else if (role === "prey" && buildsWebs && webbedAt(fromCell)) {
|
|
1091
|
+
rung = "trapped";
|
|
1092
|
+
nextCell = fromCell;
|
|
1093
|
+
plan = [];
|
|
1094
|
+
goal = goalLine("trapped");
|
|
1095
|
+
mood = "scared";
|
|
1096
|
+
} else if (threat) {
|
|
700
1097
|
rung = role === "predator" ? "avoid" : "evade";
|
|
701
|
-
|
|
1098
|
+
// A fleeing prey that already knows where food is should flee toward
|
|
1099
|
+
// it, not away from it — among cells that are equally safe, break the
|
|
1100
|
+
// tie toward the nearest believed crumb. Prey-only: the predator's own
|
|
1101
|
+
// avoid rung passes nothing, so its ties still resolve the old way.
|
|
1102
|
+
const towardFood = role === "prey"
|
|
1103
|
+
? nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts)
|
|
1104
|
+
: null;
|
|
1105
|
+
nextCell = greedyAway(fromCell, threat.cell, applyActions, { towardCell: towardFood?.cell ?? null });
|
|
702
1106
|
plan = stepPlan(fromCell, nextCell);
|
|
703
1107
|
goal = goalLine(rung, { subject: threat.subject, cell: cellId(threat.cell.x, threat.cell.y) });
|
|
704
1108
|
mood = "scared";
|
|
705
1109
|
} else {
|
|
706
1110
|
const quarry = role === "predator"
|
|
707
1111
|
? nearestBelievedTarget(agentId, fromCell, prey, state, beliefOpts)
|
|
708
|
-
: nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state,
|
|
1112
|
+
: nearestBelievedTarget(agentId, fromCell, [...foodIds].sort(), state, foodBeliefOpts);
|
|
709
1113
|
if (quarry) {
|
|
710
1114
|
rung = role === "predator" ? "chase" : "forage";
|
|
711
1115
|
const path = findActionPath(fromCell, (s) => s.x === quarry.cell.x && s.y === quarry.cell.y, applyActions, { stateKey: pathStateKey });
|
|
@@ -720,18 +1124,48 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
720
1124
|
plan = stepPlan(fromCell, nextCell);
|
|
721
1125
|
}
|
|
722
1126
|
const arrived = nextCell.x === quarry.cell.x && nextCell.y === quarry.cell.y;
|
|
723
|
-
goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived });
|
|
1127
|
+
goal = goalLine(rung, { subject: quarry.subject, cell: cellId(quarry.cell.x, quarry.cell.y), arrived, catches: carriesPrey });
|
|
724
1128
|
mood = role === "predator" ? "angry" : "calm";
|
|
1129
|
+
} else if (role === "predator" && buildsWebs) {
|
|
1130
|
+
// A web-spinning predator with nothing in sight has something better to
|
|
1131
|
+
// do than wander: hold this cell, and spin a web here unless a live one
|
|
1132
|
+
// already covers it.
|
|
1133
|
+
nextCell = fromCell;
|
|
1134
|
+
plan = [];
|
|
1135
|
+
mood = "calm";
|
|
1136
|
+
if (webbedAt(fromCell)) {
|
|
1137
|
+
rung = "hold-web";
|
|
1138
|
+
goal = goalLine("hold-web");
|
|
1139
|
+
} else {
|
|
1140
|
+
rung = "build-web";
|
|
1141
|
+
const webId = `web-${nextWebNum}`;
|
|
1142
|
+
nextWebNum += 1;
|
|
1143
|
+
const here = cellId(fromCell.x, fromCell.y);
|
|
1144
|
+
tickWebs.set(webId, { cell: here, builtAtTurn: k });
|
|
1145
|
+
movementWrites.push({ subject: stamp(webId), predicate: PLACEMENT_PREDICATE, object: here });
|
|
1146
|
+
movementWrites.push({ subject: stamp(webId), predicate: WEB_BUILT_PREDICATE, object: String(k) });
|
|
1147
|
+
goal = goalLine("build-web");
|
|
1148
|
+
}
|
|
725
1149
|
} else {
|
|
726
1150
|
rung = "wander";
|
|
727
1151
|
nextCell = seededWander(fromCell, applyActions, { layoutName: lay.name, epoch, turn: k, id: agentId });
|
|
728
1152
|
plan = stepPlan(fromCell, nextCell);
|
|
729
|
-
goal = goalLine("wander");
|
|
1153
|
+
goal = goalLine("wander", { boardNoun });
|
|
730
1154
|
mood = "calm";
|
|
731
1155
|
}
|
|
732
1156
|
}
|
|
733
1157
|
|
|
734
|
-
const belief = beliefSnapshotFor(agentId,
|
|
1158
|
+
const belief = beliefSnapshotFor(agentId, beliefCell, beliefCandidates, state, beliefOpts);
|
|
1159
|
+
// beliefSnapshotFor's one call covers every candidate, food included, so
|
|
1160
|
+
// an ungated food call needs the food entries re-taken at the widened
|
|
1161
|
+
// radius too — otherwise the panel would show a crumb null while the
|
|
1162
|
+
// goal line right beside it names that same crumb as the target. Values
|
|
1163
|
+
// only: every key already exists from the call above, so this can never
|
|
1164
|
+
// reorder the panel.
|
|
1165
|
+
if (!foodVisionGated) {
|
|
1166
|
+
const foodCandidates = beliefCandidates.filter((id) => foodIds.has(id));
|
|
1167
|
+
Object.assign(belief, beliefSnapshotFor(agentId, beliefCell, foodCandidates, state, foodBeliefOpts));
|
|
1168
|
+
}
|
|
735
1169
|
const facing = plan[0] ?? state.facing.get(agentId)?.value ?? DEFAULT_FACING;
|
|
736
1170
|
postMovePlacements.set(agentId, nextCell);
|
|
737
1171
|
rungs[agentId] = rung;
|
|
@@ -806,17 +1240,32 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
806
1240
|
for (const id of [...pass.takenAgents, ...pass.starvedThisTurn]) delete agents[id];
|
|
807
1241
|
for (const [eater, meals] of ateBy) {
|
|
808
1242
|
if (!agents[eater]) continue;
|
|
809
|
-
agents[eater].goal = `just ate ${meals.join(" and ")}.`;
|
|
1243
|
+
agents[eater].goal = carriesPrey ? `just ate ${meals.join(" and ")} in the web.` : `just ate ${meals.join(" and ")}.`;
|
|
810
1244
|
agents[eater].mood = "happy";
|
|
811
1245
|
}
|
|
1246
|
+
// A catch that wasn't also delivered and eaten this same tick leaves both
|
|
1247
|
+
// sides' goals stale: they were assigned during movement, before the pass
|
|
1248
|
+
// resolved the catch, so neither one mentions it.
|
|
1249
|
+
for (const event of ecology) {
|
|
1250
|
+
if (event.type !== "catch-prey") continue;
|
|
1251
|
+
if (ateBy.has(event.predator)) continue;
|
|
1252
|
+
if (agents[event.predator]) {
|
|
1253
|
+
agents[event.predator].goal = goalLine("carry", { subject: event.prey });
|
|
1254
|
+
agents[event.predator].mood = "angry";
|
|
1255
|
+
}
|
|
1256
|
+
if (agents[event.prey]) {
|
|
1257
|
+
agents[event.prey].goal = `just caught by ${event.predator} — being carried.`;
|
|
1258
|
+
agents[event.prey].mood = "scared";
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
812
1261
|
// A prey the pass minted arrives after the movement loops built `agents`, so
|
|
813
1262
|
// without this it would be announced by its own turn's event and still be
|
|
814
|
-
// invisible on the board until the next one.
|
|
1263
|
+
// invisible on the board until the next one. A hatchling is the same case.
|
|
815
1264
|
for (const arrival of pass.spawned) {
|
|
816
1265
|
if (arrival.isItem) continue;
|
|
817
1266
|
agents[arrival.id] = {
|
|
818
1267
|
role: arrival.role, cell: arrival.cell, facing: DEFAULT_FACING,
|
|
819
|
-
goal: "just arrived — no goal yet.", mood: "calm", plan: [], mass: round2(arrival.mass), belief: {},
|
|
1268
|
+
goal: arrival.goal ?? "just arrived — no goal yet.", mood: "calm", plan: [], mass: round2(arrival.mass), belief: {},
|
|
820
1269
|
};
|
|
821
1270
|
}
|
|
822
1271
|
|
|
@@ -840,6 +1289,7 @@ export async function runTownSquareTick(memoryDir, {
|
|
|
840
1289
|
items: sortedByKey(items),
|
|
841
1290
|
ecology,
|
|
842
1291
|
rungs: sortedByKey(rungs),
|
|
1292
|
+
activeWebs: liveWebs(tickWebs, k, config.webDurationTurns),
|
|
843
1293
|
writes,
|
|
844
1294
|
};
|
|
845
1295
|
}
|
|
@@ -895,13 +1345,18 @@ export async function townSquareBoard(memoryDir, {
|
|
|
895
1345
|
};
|
|
896
1346
|
}
|
|
897
1347
|
|
|
898
|
-
return
|
|
1348
|
+
// A resting board's whole return IS the render payload, so `activeWebs`
|
|
1349
|
+
// appears only for a cast that spins webs. A board with none would otherwise
|
|
1350
|
+
// grow a field that is always empty for it.
|
|
1351
|
+
const resting = {
|
|
899
1352
|
turn: state.tickCount,
|
|
900
1353
|
epoch: state.epoch,
|
|
901
1354
|
agents: sortedByKey(agents),
|
|
902
1355
|
items: sortedByKey(itemsPayload(board.liveItemIds, { state, itemCellOf: board.itemCellOf, roles })),
|
|
903
1356
|
ecology: [],
|
|
904
1357
|
};
|
|
1358
|
+
if (config.buildWebs === true) resting.activeWebs = liveWebs(state.webs, state.tickCount, config.webDurationTurns);
|
|
1359
|
+
return resting;
|
|
905
1360
|
}
|
|
906
1361
|
|
|
907
1362
|
/** The frozen render payload, projected off a tick result: exactly
|