@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.0

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.
Files changed (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -0,0 +1,572 @@
1
+ // mud-turn.mjs — one acting character's whole turn in a mud world: investigate
2
+ // the room it stands in, walk toward food it actually knows about, roll for
3
+ // digging out of the room's frontier, and (when none of that came to anything)
4
+ // set off for a room it has never stood in. The split mirrors spider-fly.mjs /
5
+ // spider-fly-turn.mjs: adventure.mjs owns the read/fold/write primitives, this
6
+ // file owns the per-tick decisions that drive them. Nothing here writes a fact
7
+ // of its own — every change goes out through runWorldCommand, recordTold,
8
+ // recordExamined or recordMassDrain.
9
+ //
10
+ // Every roll is seeded from (character, turn, room, decision name) through
11
+ // fnv1a32/mulberry32, so a run reproduces exactly from its inputs. The world
12
+ // layer writes no bare Math.random anywhere, and this file keeps that.
13
+ //
14
+ // Four things the design leaves open, settled here:
15
+ //
16
+ // A character moves once per turn. The directed walk, the edge rolls and the
17
+ // explore step draw on the same budget, in that order, so each only runs when
18
+ // the one before it came up empty. A walk that stepped reached the next room on
19
+ // a known path; it did not reach an edge.
20
+ //
21
+ // There are two separate reasons to dig — plain exploration, and following
22
+ // the level's own frontier toward food — and both cash out as a dig. They are
23
+ // kept apart by candidate set. The first ranges over every direction the
24
+ // room's kind allows and no exit already covers, vertical ones included. The
25
+ // second ranges over the lateral part of that same set: the frontier of the
26
+ // level the character already stands on. So a lateral direction draws two
27
+ // independent rolls and gets dug more often than a vertical one.
28
+ //
29
+ // The exit roll is motivated by food, so it needs food to be motivated by. It
30
+ // runs only when the character knows about some food but has no path to it —
31
+ // the one case where an exit is worth a gamble and the walk still had nothing
32
+ // to follow. A character that knows about no food at all rolls nothing: that
33
+ // silence is the honest miss, and it holds all the way down.
34
+ //
35
+ // A turn that found nothing to do anywhere above ends on the explore step, and
36
+ // that step is a different claim from the food walk. It never guesses where
37
+ // food is. It reads which rooms this character has itself stood in — its own
38
+ // placement history, written turn by turn — and steps toward the nearest one it
39
+ // has not. "I have not been down there yet" is something an animal genuinely
40
+ // knows about itself, so acting on it invents nothing; without it a character
41
+ // that digs itself into a quiet corner stands there for the rest of the run.
42
+ //
43
+ // The two limits that make that stop being a treadmill live elsewhere:
44
+ // adventure.mjs bounds how far from the origin a dig may reach, so the set of
45
+ // rooms to explore is finite, and every turn charges mass. An animal really out
46
+ // of world eventually starves, which is an ending, not a freeze.
47
+
48
+ import { mulberry32 } from "../domain/seeded-random.mjs";
49
+ import { fnv1a32 } from "../domain/hash.mjs";
50
+ import { bfsLevels } from "../domain/planning.mjs";
51
+ import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
52
+ import { DEFAULT_GAME_CONFIG, mudMassDrainPerTurn } from "../domain/game-config.mjs";
53
+ import {
54
+ foldWorldState, worldActionRows, runWorldCommand, recordTold, recordExamined,
55
+ recordMassDrain, personKnowledgeLines, objectClassChain, diggableDirections,
56
+ isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase, massDrainPerTurnOf,
57
+ } from "./adventure.mjs";
58
+
59
+ const FOOD_CLASS = "food";
60
+ const LATERAL_DIRECTIONS = ["north", "south", "east", "west"];
61
+ // Deep enough to cross a whole burrow without letting one character's
62
+ // pathfinder walk a whole grown world every tick.
63
+ const WALK_SEARCH_DEPTH = 8;
64
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
65
+
66
+ const EXIT_TOWARD_FOOD_CHANCE = 0.5;
67
+ const EDGE_FOLLOW_DIG_CHANCE = 0.25;
68
+ const EXPLORATORY_DIG_CHANCE = 0.1;
69
+
70
+ const MANIPULATIONS = ["take", "put", "eat"];
71
+
72
+ // ---- seeded decisions --------------------------------------------------------
73
+
74
+ /** One roll in [0,1) for one decision, reproducible from the four things that
75
+ * identify it. Two characters deciding the same thing in the same room on the
76
+ * same turn get different numbers; the same character re-run gets the same
77
+ * one. */
78
+ const rollFor = (character, k, room, decision) =>
79
+ mulberry32(fnv1a32(`${character}:${k}:${room}:${decision}`))();
80
+
81
+ /** The item a seeded roll selects out of `items`, or null when there is
82
+ * nothing to select. Callers sort their candidates first, so the choice does
83
+ * not ride on fact-row order. */
84
+ function pickSeeded(items, character, k, room, decision) {
85
+ if (!items.length) return null;
86
+ return items[Math.floor(rollFor(character, k, room, decision) * items.length)];
87
+ }
88
+
89
+ // ---- world reads -------------------------------------------------------------
90
+
91
+ async function readWorld(memoryDir) {
92
+ const rows = readFactRows(await loadMemory(memoryDir));
93
+ return { rows, state: foldWorldState(worldActionRows(rows)) };
94
+ }
95
+
96
+ const isTyped = (rows, subject, type) =>
97
+ (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
98
+
99
+ const isContainer = (rows, subject) =>
100
+ (rows || []).some((r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true");
101
+
102
+ /** The room a thing is on show in, mirroring the presence check every
103
+ * adventure verb already applies. adventure.mjs keeps its own copy private,
104
+ * and this file may not reach into it, so the rule is restated rather than
105
+ * approximated: a decision made on a looser notion of "present" would pick
106
+ * actions the verbs then refuse. */
107
+ function visibleRoomOf(rows, state, thing) {
108
+ const place = state.placements.get(thing);
109
+ if (!place || place.predicate === "mgx:hidden-in") return null;
110
+ if (place.predicate === "mgx:currently-in" || isTyped(rows, place.object, "room")) return place.object;
111
+ const holder = place.object;
112
+ if (!isContainer(rows, holder)) return null; // a character is carrying it
113
+ if (!state.openness.get(holder)?.open) return null;
114
+ const holderPlace = state.placements.get(holder);
115
+ return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
116
+ }
117
+
118
+ const carriedBy = (state, thing, holder) => {
119
+ const place = state.placements.get(thing);
120
+ return !!place && place.predicate === "mgx:located-in" && place.object === holder;
121
+ };
122
+
123
+ const isFood = (rows, thing) => objectClassChain(rows, thing).includes(FOOD_CLASS);
124
+
125
+ /** What `character` currently knows about, food only — the durable
126
+ * knows-about facts recordTold/recordExamined leave behind, read over the
127
+ * FULL rows because testimony is deliberately filtered out of the state
128
+ * fold. */
129
+ function knownFood(rows, state, character) {
130
+ const { aboutTopics } = personKnowledgeLines(rows, state, character);
131
+ return [...new Set(aboutTopics)].filter((thing) => isFood(rows, thing)).sort();
132
+ }
133
+
134
+ /** The food a character knows about that is still somewhere it could be
135
+ * reached — on a floor, or in an open container. An eater's own claim about a
136
+ * carrot ages out the moment it eats it, but a claim only ever HEARD stands
137
+ * until somebody says otherwise, and that animal has no way to know the meal
138
+ * is over. Anything deciding where to GO reads this list rather than the raw
139
+ * one, or an animal spends the rest of the run crossing the burrow after
140
+ * somebody else's meal. */
141
+ const standingKnownFood = (rows, state, character) =>
142
+ knownFood(rows, state, character).filter((thing) => visibleRoomOf(rows, state, thing) !== null);
143
+
144
+ const knownTopics = (rows, state, character) =>
145
+ new Set(personKnowledgeLines(rows, state, character).aboutTopics);
146
+
147
+ /** The cast standing in `room`: the world places its characters with
148
+ * currently-in and its props every other way, so that predicate is the whole
149
+ * test. Discovered from the fold rather than handed in, so a caller can never
150
+ * hand this turn a room-mate that has already walked off. */
151
+ const castIn = (state, room, exclude) => [...state.placements]
152
+ .filter(([subject, place]) => subject !== exclude && place.predicate === "mgx:currently-in" && place.object === room)
153
+ .map(([subject]) => subject)
154
+ .sort();
155
+
156
+ // ---- the directed walk -------------------------------------------------------
157
+
158
+ /**
159
+ * The first step of a shortest room-graph path from `here` to the nearest room
160
+ * `wanted` accepts, as `{ direction, room, hops }`. Null when nothing within
161
+ * the search depth qualifies. Ties break on the room name, so a fork never
162
+ * turns on fact-row order. Rooms in `avoid` are neither entered nor routed
163
+ * through.
164
+ */
165
+ function firstStepToward(state, here, wanted, avoid = new Set()) {
166
+ const successorsOf = (room) => [...(state.exits.get(room)?.entries() ?? [])]
167
+ .filter(([, target]) => !avoid.has(target))
168
+ .map(([direction, target]) => ({ room: target, direction, from: room }));
169
+
170
+ const cameFrom = new Map(); // room -> { via, from }
171
+ let hops = 0;
172
+ for (const level of bfsLevels(here, successorsOf, { maxDepth: WALK_SEARCH_DEPTH, keyOf: (item) => item.room })) {
173
+ hops += 1;
174
+ for (const item of level) cameFrom.set(item.room, { via: item.direction, from: item.from });
175
+ const goal = level.map((item) => item.room).filter(wanted).sort()[0];
176
+ if (!goal) continue;
177
+ let room = goal;
178
+ let firstStep = null;
179
+ while (room !== here) {
180
+ const trail = cameFrom.get(room);
181
+ if (!trail) return null;
182
+ firstStep = trail.via;
183
+ room = trail.from;
184
+ }
185
+ return firstStep ? { direction: firstStep, room: goal, hops } : null;
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * The first step toward the nearest room holding a food-classed thing
192
+ * `character` knows about. Null when the character knows of no food, or when
193
+ * none of it sits in a room reachable within the search depth — the honest
194
+ * "I don't know where any food is", never a fallback wander.
195
+ */
196
+ function stepTowardKnownFood(rows, state, character, here) {
197
+ const foodRooms = new Set(
198
+ standingKnownFood(rows, state, character).map((thing) => visibleRoomOf(rows, state, thing)),
199
+ );
200
+ if (!foodRooms.size || foodRooms.has(here)) return null;
201
+ return firstStepToward(state, here, (room) => foodRooms.has(room));
202
+ }
203
+
204
+ /** Every room `character` has itself stood in: its base placement and every
205
+ * @turnN snapshot of one, read over the world's OWN rows so a taught locative
206
+ * can never write a visit that never happened. */
207
+ function roomsStoodIn(rows, character) {
208
+ const visited = new Set();
209
+ for (const row of worldActionRows(rows)) {
210
+ if (row.predicate !== "mgx:currently-in") continue;
211
+ const snapshot = SNAPSHOT_RE.exec(row.subject);
212
+ if ((snapshot ? snapshot[1] : row.subject) !== character) continue;
213
+ visited.add(row.object);
214
+ }
215
+ return visited;
216
+ }
217
+
218
+ /** Every room a predator is standing in. An animal with nothing better to do
219
+ * does not go and look into one: leaving them in would make the explore step a
220
+ * funnel straight to the den, since the unvisited room nearest the burrow is
221
+ * exactly where the fox lives. The den stays reachable by the food gamble
222
+ * below, which is a gamble and is meant to be. */
223
+ const predatorRooms = (rows, state) => new Set((rows || [])
224
+ .filter((r) => r.predicate === "mgx:is-predator" && r.object === "true")
225
+ .map((r) => state.placements.get(r.subject)?.object)
226
+ .filter(Boolean));
227
+
228
+ /**
229
+ * The first step toward the nearest room `character` has never stood in. This
230
+ * is the one move that runs on the character's OWN history rather than on
231
+ * anything it knows about the world's contents — it says "I have not been that
232
+ * way yet", which is true whatever turns out to be there.
233
+ */
234
+ function stepTowardUnvisitedRoom(rows, state, character, here) {
235
+ const visited = roomsStoodIn(rows, character);
236
+ return firstStepToward(state, here, (room) => !visited.has(room), predatorRooms(rows, state));
237
+ }
238
+
239
+ // ---- the turn ----------------------------------------------------------------
240
+
241
+ /**
242
+ * Run one acting character's whole turn against a live mud world.
243
+ *
244
+ * `k` is the turn ordinal the caller drives (it tags the testimony this turn
245
+ * writes and seeds this turn's rolls); it defaults to the world's own next
246
+ * turn. Room-mates are discovered from the fold, so the caller passes no cast
247
+ * list.
248
+ *
249
+ * Returns `{ character, k, room, roomAfter, actions, learned, mass, text,
250
+ * note }`. `actions` is the machine-readable spine — one entry per sub-step
251
+ * that fired, each `{ step, kind, ..., text, miss }` — and a sub-step whose
252
+ * precondition did not hold this turn records `kind: "none"` with its reason
253
+ * rather than dropping out silently or being narrated as a success. `mass` is
254
+ * what the character weighs once this turn's drain is charged, or null when the
255
+ * world writes it no mass.
256
+ *
257
+ * A turn that ends the character's run also carries `outOfPlay: true` and
258
+ * `outOfPlayReason` ("eaten" or "starved"), and so does every turn after it.
259
+ *
260
+ * `mudConfig` is the resolved `mud` section of the game config — the fallback
261
+ * for the per-species mass drain a turn charges, used only when the world
262
+ * itself writes the character no `mgx:mass-drain-per-turn` fact.
263
+ */
264
+ export async function runMudTurn(character, {
265
+ world, memoryDir, env, graph, cache, k = null, mudConfig = DEFAULT_GAME_CONFIG.mud,
266
+ } = {}) {
267
+ const opened = await readWorld(memoryDir);
268
+ const room = opened.state.placements.get(character)?.object ?? null;
269
+ const turn = k ?? opened.state.turnCount + 1;
270
+ const actions = [];
271
+ const notes = [];
272
+ const learnedBefore = knownTopics(opened.rows, opened.state, character);
273
+
274
+ if (!room) {
275
+ return {
276
+ character, k: turn, room: null, roomAfter: null, actions, learned: [],
277
+ text: `the ${character} has no written position in this world.`,
278
+ note: `MUD — ${character} has no placement fact; the turn is declined rather than guessed`,
279
+ };
280
+ }
281
+ if (isOutOfPlay(opened.state, character)) {
282
+ const reason = outOfPlayReasonOf(opened.state, character);
283
+ return {
284
+ character, k: turn, room: null, roomAfter: null, actions, learned: [],
285
+ outOfPlay: true, outOfPlayReason: reason,
286
+ text: `${outOfPlayPhrase(character, reason)}. It takes no more turns.`,
287
+ note: `MUD — ${character} is placed out of play (${reason}); the turn is declined, and every later one will be too`,
288
+ };
289
+ }
290
+
291
+ const commandContext = { world, memoryDir, env, graph, cache, actingSubject: character };
292
+ const runCommand = async (step, cmd, detail) => {
293
+ const res = await runWorldCommand(cmd, commandContext);
294
+ actions.push({ step, kind: cmd.verb, ...detail, text: res.text, miss: !!res.miss });
295
+ notes.push(res.note);
296
+ return res;
297
+ };
298
+ const recordSkip = (step, reason, text) => {
299
+ actions.push({ step, kind: "none", reason, text, miss: false });
300
+ notes.push(`MUD — ${step}: ${reason}`);
301
+ };
302
+
303
+ await investigateRoom({ character, turn, room, memoryDir, cache, actions, notes, runCommand, recordSkip });
304
+
305
+ const walked = await readWorld(memoryDir);
306
+ const walkedRoom = walked.state.placements.get(character)?.object ?? room;
307
+ const step = stepTowardKnownFood(walked.rows, walked.state, character, walkedRoom);
308
+ let moved = false;
309
+ if (step) {
310
+ const res = await runCommand("walk", { pattern: "imperative", verb: "go", direction: step.direction }, {
311
+ direction: step.direction, toward: step.room, hops: step.hops,
312
+ });
313
+ moved = !res.miss;
314
+ } else {
315
+ recordSkip("walk", walkSkipReason(walked.rows, walked.state, character), `the ${character} has nowhere it knows to walk to.`);
316
+ }
317
+
318
+ if (!moved) {
319
+ const edge = await rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip });
320
+ if (!edge.acted) await exploreUnvisited({ character, memoryDir, runCommand, recordSkip });
321
+ }
322
+
323
+ // The world's own word first: a `mgx:mass-drain-per-turn` fact on the
324
+ // character or its species is something a player can read and change, and the
325
+ // shipped config table is only what a world that writes none falls back to.
326
+ const writtenDrain = massDrainPerTurnOf(opened.rows, character);
327
+ const drained = await recordMassDrain(memoryDir, {
328
+ world,
329
+ subject: character,
330
+ drainPerTurn: writtenDrain === null ? mudMassDrainPerTurn(mudConfig, character) : writtenDrain,
331
+ cache,
332
+ });
333
+ if (drained.mass === null) {
334
+ notes.push(`MUD — mass: the world writes ${character} no mass, so a turn costs it nothing`);
335
+ } else {
336
+ notes.push(`MUD — mass: ${character} is down to ${drained.mass}${drained.starved ? " and starves" : ""}`);
337
+ }
338
+
339
+ const closed = await readWorld(memoryDir);
340
+ const learned = [...knownTopics(closed.rows, closed.state, character)].filter((t) => !learnedBefore.has(t)).sort();
341
+ const texts = actions.map((a) => a.text).filter(Boolean);
342
+ if (drained.starved) texts.push(`the ${character} runs out of mass and starves. It takes no more turns.`);
343
+ // Read off the closed world rather than off the drain alone: a character can
344
+ // also end this turn eaten, by walking into the predator's room on it, and a
345
+ // caller watching for a fate should not have to know which of the two ways it
346
+ // was to find out that one happened.
347
+ const fate = outOfPlayReasonOf(closed.state, character);
348
+ return {
349
+ character,
350
+ k: turn,
351
+ room,
352
+ roomAfter: closed.state.placements.get(character)?.object ?? room,
353
+ actions,
354
+ learned,
355
+ mass: drained.mass,
356
+ ...(fate ? { outOfPlay: true, outOfPlayReason: fate } : {}),
357
+ text: texts.join(" "),
358
+ note: notes.join("; "),
359
+ };
360
+ }
361
+
362
+ /** Why a turn's food walk took no step — three different situations, each
363
+ * named on its own terms rather than collapsed into one shrug. */
364
+ function walkSkipReason(rows, state, character) {
365
+ if (!knownFood(rows, state, character).length) return "it knows of no food to walk toward";
366
+ if (!standingKnownFood(rows, state, character).length) return "every food it knows about has already been eaten";
367
+ return "no room it knows holds food is reachable from here";
368
+ }
369
+
370
+ /** The last move of a turn that found nothing else to do: one step toward the
371
+ * nearest room this character has never stood in. Reached only when the walk
372
+ * took no step and the edge rolls fired nothing, so it never competes for the
373
+ * move budget — and when every room within reach has already been walked, it
374
+ * says so and the character stands still. */
375
+ async function exploreUnvisited({ character, memoryDir, runCommand, recordSkip }) {
376
+ const { rows, state } = await readWorld(memoryDir);
377
+ const here = state.placements.get(character)?.object ?? null;
378
+ if (!here) return recordSkip("explore", "it has no position to set out from", "");
379
+ const step = stepTowardUnvisitedRoom(rows, state, character, here);
380
+ if (!step) {
381
+ return recordSkip(
382
+ "explore",
383
+ "it has already stood in every room it can reach",
384
+ `the ${character} has been everywhere this burrow goes.`,
385
+ );
386
+ }
387
+ return runCommand("explore", { pattern: "imperative", verb: "go", direction: step.direction }, {
388
+ direction: step.direction, toward: step.room, hops: step.hops, reason: "unvisited",
389
+ });
390
+ }
391
+
392
+ /** Step one: talk to a room-mate, examine something unexamined, then make one
393
+ * seeded attempt at take/put/eat. Talking comes first, and an animal speaks
394
+ * to a room-mate it has not met yet or that has food news for it. Once a
395
+ * partner has neither, this one has nothing left to say and spends the step
396
+ * on the room instead — two animals that stay put would otherwise trade the
397
+ * same empty greeting every turn for the rest of the run. The talk and the
398
+ * examine write testimony, which never folds into the playable state; only
399
+ * the manipulation touches the world. */
400
+ async function investigateRoom({ character, turn, room, memoryDir, cache, recordSkip, runCommand, actions, notes }) {
401
+ const { rows, state } = await readWorld(memoryDir);
402
+ const roomMates = castIn(state, room, character);
403
+ const alreadyKnown = knownTopics(rows, state, character);
404
+ const foodNewsFrom = (mate) => knownFood(rows, state, mate).filter((thing) => !alreadyKnown.has(thing));
405
+ const worthSpeakingTo = roomMates.filter((mate) => !alreadyKnown.has(mate) || foodNewsFrom(mate).length);
406
+
407
+ const teller = pickSeeded(worthSpeakingTo, character, turn, room, "ask-who");
408
+ if (!teller) {
409
+ recordSkip(
410
+ "investigate",
411
+ roomMates.length
412
+ ? `the ${character} has already heard everything the animals standing here can tell it about food`
413
+ : "no other character stands here to ask",
414
+ "",
415
+ );
416
+ } else {
417
+ const told = pickSeeded(foodNewsFrom(teller), character, turn, room, `ask-${teller}`);
418
+ // Speaking to an animal is how this one comes to know it, and that note is
419
+ // the whole escape from the loop: a pair with no news left for each other
420
+ // drops out of worthSpeakingTo, and drops back in the moment either side
421
+ // learns a food the other has not heard of.
422
+ if (!alreadyKnown.has(teller)) {
423
+ await recordExamined(memoryDir, { observer: character, thing: teller, k: turn, cache });
424
+ alreadyKnown.add(teller);
425
+ }
426
+ if (!told) {
427
+ // Still a real exchange, and still narrated as one: an animal that meets
428
+ // another animal always says something. Only the testimony is missing,
429
+ // because the teller had nothing this one didn't already know.
430
+ actions.push({
431
+ step: "investigate", kind: "ask", teller, thing: null, miss: false,
432
+ text: `the ${character} greets the ${teller}, but hears nothing new about food.`,
433
+ });
434
+ notes.push(`MUD — talk: ${character} greeted ${teller}; ${teller} knows of no food to share`);
435
+ } else {
436
+ await recordTold(memoryDir, { asker: character, teller, thing: told, k: turn, cache });
437
+ alreadyKnown.add(told);
438
+ actions.push({
439
+ step: "investigate", kind: "ask", teller, thing: told, miss: false,
440
+ text: `the ${character} asks the ${teller} about food, and hears about the ${told}.`,
441
+ });
442
+ notes.push(`MUD — ask: ${teller} told ${character} about ${told}; written as mud:${teller}:turn${turn}`);
443
+ }
444
+ }
445
+
446
+ const unexamined = [...state.placements.keys()]
447
+ .filter((thing) => thing !== character && !alreadyKnown.has(thing))
448
+ .filter((thing) => state.placements.get(thing).predicate !== "mgx:currently-in")
449
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
450
+ .sort();
451
+ const examined = pickSeeded(unexamined, character, turn, room, "examine");
452
+ if (!examined) {
453
+ recordSkip("investigate", "nothing unexamined stands here", "");
454
+ } else {
455
+ await recordExamined(memoryDir, { observer: character, thing: examined, k: turn, cache });
456
+ alreadyKnown.add(examined);
457
+ actions.push({
458
+ step: "investigate", kind: "examine", thing: examined, miss: false,
459
+ text: `the ${character} examines the ${examined}.`,
460
+ });
461
+ notes.push(`MUD — examine: ${character} looked at ${examined}; written as mud:${character}:turn${turn}`);
462
+ }
463
+
464
+ await manipulateSomething({ character, turn, room, memoryDir, runCommand, recordSkip });
465
+ }
466
+
467
+ /** Step one's last move: one of take, put or eat, chosen by a seeded roll. A
468
+ * roll that lands on something the room cannot offer this turn is a plain
469
+ * no-op — the alternative would be narrating an action that never happened.
470
+ * The hunger gate itself stays with the eat verb, which owns it. */
471
+ async function manipulateSomething({ character, turn, room, memoryDir, runCommand, recordSkip }) {
472
+ const { rows, state } = await readWorld(memoryDir);
473
+ const chosen = MANIPULATIONS[Math.floor(rollFor(character, turn, room, "manipulate") * MANIPULATIONS.length)];
474
+
475
+ // Everything the take verb would really accept here: loose on the floor, or
476
+ // sitting in an open container standing in this room. Reading only the floor
477
+ // made putting something into the basket a one-way trip, and in a room whose
478
+ // only food went into the basket that left nothing to take and nothing to
479
+ // eat for the rest of the run.
480
+ const withinReach = [...state.placements]
481
+ .filter(([, place]) => place.predicate === "mgx:located-in")
482
+ .map(([thing]) => thing)
483
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
484
+ .sort();
485
+ const carried = [...state.placements]
486
+ .filter(([, place]) => place.predicate === "mgx:located-in" && place.object === character)
487
+ .map(([thing]) => thing)
488
+ .sort();
489
+
490
+ if (chosen === "take") {
491
+ const target = pickSeeded(withinReach, character, turn, room, "take-what");
492
+ if (!target) return recordSkip("investigate", "nothing here it could pick up", "");
493
+ return runCommand("investigate", { pattern: "imperative", verb: "take", object: target }, { object: target });
494
+ }
495
+
496
+ if (chosen === "put") {
497
+ const containers = [...state.placements.keys()]
498
+ .filter((thing) => isContainer(rows, thing))
499
+ .filter((thing) => state.openness.get(thing)?.open)
500
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
501
+ .sort();
502
+ const target = pickSeeded(carried, character, turn, room, "put-what");
503
+ const container = pickSeeded(containers, character, turn, room, "put-where");
504
+ if (!target || !container) {
505
+ return recordSkip("investigate", carried.length ? "no open container stands here" : "it carries nothing to put down", "");
506
+ }
507
+ return runCommand(
508
+ "investigate",
509
+ { pattern: "imperative", verb: "put", object: target, indirectObject: container },
510
+ { object: target, indirectObject: container },
511
+ );
512
+ }
513
+
514
+ const edible = [...new Set([...withinReach, ...carried])].filter((thing) => isFood(rows, thing)).sort();
515
+ const target = pickSeeded(edible, character, turn, room, "eat-what");
516
+ if (!target) return recordSkip("investigate", "no food is within reach here", "");
517
+ return runCommand("investigate", { pattern: "imperative", verb: "eat", object: target }, { object: target });
518
+ }
519
+
520
+ /** Step three: the room's unexplored sides, one independent roll per reason
521
+ * per direction. Only reached on a turn whose walk took no step, so the move
522
+ * budget is still unspent. Reports `{ acted }` — whether a roll actually came
523
+ * up — so the caller knows whether the turn still has a move to spend. */
524
+ async function rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip }) {
525
+ const { rows, state } = await readWorld(memoryDir);
526
+ const room = state.placements.get(character)?.object ?? null;
527
+ if (!room) {
528
+ recordSkip("edge", "it has no position to roll from", "");
529
+ return { acted: false };
530
+ }
531
+
532
+ // The exit gamble runs before the dig rolls and whether or not this room can
533
+ // still be dug at all. A room mapped on every side its kind allows is
534
+ // exactly where a character with food somewhere else has nothing left but
535
+ // its existing exits to gamble on, so gating it behind a diggable direction
536
+ // stranded animals in a finished room for the rest of the run.
537
+ const foodItKnows = standingKnownFood(rows, state, character);
538
+ const foodStandsHere = foodItKnows.some((thing) => visibleRoomOf(rows, state, thing) === room);
539
+ if (foodItKnows.length && !foodStandsHere) {
540
+ for (const direction of [...(state.exits.get(room)?.keys() ?? [])].sort()) {
541
+ if (rollFor(character, turn, room, `exit-${direction}`) >= EXIT_TOWARD_FOOD_CHANCE) continue;
542
+ await runCommand("edge", { pattern: "imperative", verb: "go", direction }, { direction, reason: "exit-toward-food" });
543
+ return { acted: true };
544
+ }
545
+ }
546
+
547
+ // The room's OWN kind decides which way a dig can go — there is nothing to
548
+ // tunnel sideways through above ground, the burrow runs one level deep, and
549
+ // past the world's dig boundary nothing is offered at all — so the candidate
550
+ // set is the dig verb's own, never a flat compass. A roll on a direction the
551
+ // verb would refuse spends the turn on a decline.
552
+ const edges = diggableDirections(rows, state, room);
553
+ if (!edges.length) {
554
+ recordSkip("edge", `there is nowhere left to dig from the ${room}`, "");
555
+ return { acted: false };
556
+ }
557
+
558
+ for (const direction of edges.filter((d) => LATERAL_DIRECTIONS.includes(d))) {
559
+ if (rollFor(character, turn, room, `edge-follow-${direction}`) >= EDGE_FOLLOW_DIG_CHANCE) continue;
560
+ await runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "edge-follow" });
561
+ return { acted: true };
562
+ }
563
+
564
+ for (const direction of edges) {
565
+ if (rollFor(character, turn, room, `dig-${direction}`) >= EXPLORATORY_DIG_CHANCE) continue;
566
+ await runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "explore" });
567
+ return { acted: true };
568
+ }
569
+
570
+ recordSkip("edge", `no roll came up at the ${room}'s edge`, "");
571
+ return { acted: false };
572
+ }