@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1

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 (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -1,28 +1,28 @@
1
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, and (only
3
- // when that walk has nowhere to go) roll for digging out of the room's
4
- // frontier. The split mirrors spider-fly.mjs / spider-fly-turn.mjs: adventure
5
- // .mjs owns the read/fold/write primitives, this file owns the per-tick
6
- // decisions that drive them. Nothing here writes a fact of its own — every
7
- // change goes out through runWorldCommand, recordTold or recordExamined.
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.
8
9
  //
9
10
  // Every roll is seeded from (character, turn, room, decision name) through
10
11
  // fnv1a32/mulberry32, so a run reproduces exactly from its inputs. The world
11
12
  // layer writes no bare Math.random anywhere, and this file keeps that.
12
13
  //
13
- // Three things the design leaves open, settled here:
14
+ // Four things the design leaves open, settled here:
14
15
  //
15
- // A character moves once per turn. The directed walk and the edge rolls draw
16
- // on the same budget, so the edge rolls only run when the walk found nowhere
17
- // to go. A walk that stepped reached the next room on a known path; it did not
18
- // reach an edge.
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.
19
20
  //
20
- // There are two separate reasons to dig — "each available dig direction
21
- // (including down)" and "each edge direction, to keep following the edge
22
- // toward food" and both cash out as a dig. They are kept apart by candidate
23
- // set, which is what the "(including down)" aside marks. The first ranges over
24
- // every exit-less direction, vertical ones included, and is plain exploration.
25
- // The second ranges over the four lateral directions only: the frontier of the
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
26
  // level the character already stands on. So a lateral direction draws two
27
27
  // independent rolls and gets dug more often than a vertical one.
28
28
  //
@@ -31,22 +31,37 @@
31
31
  // the one case where an exit is worth a gamble and the walk still had nothing
32
32
  // to follow. A character that knows about no food at all rolls nothing: that
33
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.
34
47
 
35
48
  import { mulberry32 } from "../domain/seeded-random.mjs";
36
49
  import { fnv1a32 } from "../domain/hash.mjs";
37
50
  import { bfsLevels } from "../domain/planning.mjs";
38
51
  import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
52
+ import { DEFAULT_GAME_CONFIG, mudMassDrainPerTurn } from "../domain/game-config.mjs";
39
53
  import {
40
54
  foldWorldState, worldActionRows, runWorldCommand, recordTold, recordExamined,
41
- personKnowledgeLines, objectClassChain,
55
+ recordMassDrain, personKnowledgeLines, objectClassChain, diggableDirections,
56
+ isOutOfPlay, outOfPlayReasonOf, outOfPlayPhrase, massDrainPerTurnOf,
42
57
  } from "./adventure.mjs";
43
58
 
44
59
  const FOOD_CLASS = "food";
45
60
  const LATERAL_DIRECTIONS = ["north", "south", "east", "west"];
46
- const ALL_DIRECTIONS = [...LATERAL_DIRECTIONS, "up", "down"];
47
- // Deep enough to cross a burrow several levels down without letting one
48
- // character's pathfinder walk a whole grown world every tick.
61
+ // Deep enough to cross a whole burrow without letting one character's
62
+ // pathfinder walk a whole grown world every tick.
49
63
  const WALK_SEARCH_DEPTH = 8;
64
+ const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
50
65
 
51
66
  const EXIT_TOWARD_FOOD_CHANCE = 0.5;
52
67
  const EDGE_FOLLOW_DIG_CHANCE = 0.25;
@@ -116,6 +131,16 @@ function knownFood(rows, state, character) {
116
131
  return [...new Set(aboutTopics)].filter((thing) => isFood(rows, thing)).sort();
117
132
  }
118
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
+
119
144
  const knownTopics = (rows, state, character) =>
120
145
  new Set(personKnowledgeLines(rows, state, character).aboutTopics);
121
146
 
@@ -132,20 +157,14 @@ const castIn = (state, room, exclude) => [...state.placements]
132
157
 
133
158
  /**
134
159
  * The first step of a shortest room-graph path from `here` to the nearest room
135
- * holding a food-classed thing `character` knows about, as
136
- * `{ direction, room, hops }`. Null when the character knows of no food, or
137
- * when none of it sits in a room reachable within the search depth — the
138
- * honest "I don't know where any food is", never a fallback wander.
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.
139
164
  */
140
- function stepTowardKnownFood(rows, state, character, here) {
141
- const foodRooms = new Set(
142
- knownFood(rows, state, character)
143
- .map((thing) => visibleRoomOf(rows, state, thing))
144
- .filter(Boolean),
145
- );
146
- if (!foodRooms.size || foodRooms.has(here)) return null;
147
-
165
+ function firstStepToward(state, here, wanted, avoid = new Set()) {
148
166
  const successorsOf = (room) => [...(state.exits.get(room)?.entries() ?? [])]
167
+ .filter(([, target]) => !avoid.has(target))
149
168
  .map(([direction, target]) => ({ room: target, direction, from: room }));
150
169
 
151
170
  const cameFrom = new Map(); // room -> { via, from }
@@ -153,7 +172,7 @@ function stepTowardKnownFood(rows, state, character, here) {
153
172
  for (const level of bfsLevels(here, successorsOf, { maxDepth: WALK_SEARCH_DEPTH, keyOf: (item) => item.room })) {
154
173
  hops += 1;
155
174
  for (const item of level) cameFrom.set(item.room, { via: item.direction, from: item.from });
156
- const goal = level.map((item) => item.room).filter((room) => foodRooms.has(room)).sort()[0];
175
+ const goal = level.map((item) => item.room).filter(wanted).sort()[0];
157
176
  if (!goal) continue;
158
177
  let room = goal;
159
178
  let firstStep = null;
@@ -168,15 +187,56 @@ function stepTowardKnownFood(rows, state, character, here) {
168
187
  return null;
169
188
  }
170
189
 
171
- // ---- the turn ----------------------------------------------------------------
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
+ }
172
203
 
173
- /** A room's unexplored sides: every compass direction with no exit written
174
- * from here yet. A room with none is fully mapped and has no edge to roll
175
- * against. */
176
- const edgeDirectionsOf = (state, room) => {
177
- const exits = state.exits.get(room);
178
- return ALL_DIRECTIONS.filter((direction) => !exits?.has(direction));
179
- };
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 ----------------------------------------------------------------
180
240
 
181
241
  /**
182
242
  * Run one acting character's whole turn against a live mud world.
@@ -186,13 +246,24 @@ const edgeDirectionsOf = (state, room) => {
186
246
  * turn. Room-mates are discovered from the fold, so the caller passes no cast
187
247
  * list.
188
248
  *
189
- * Returns `{ character, k, room, roomAfter, actions, learned, text, note }`.
190
- * `actions` is the machine-readable spine — one entry per sub-step that fired,
191
- * each `{ step, kind, ..., text, miss }` — and a sub-step whose precondition
192
- * did not hold this turn records `kind: "none"` with its reason rather than
193
- * dropping out silently or being narrated as a success.
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.
194
263
  */
195
- export async function runMudTurn(character, { world, memoryDir, env, graph, cache, k = null } = {}) {
264
+ export async function runMudTurn(character, {
265
+ world, memoryDir, env, graph, cache, k = null, mudConfig = DEFAULT_GAME_CONFIG.mud,
266
+ } = {}) {
196
267
  const opened = await readWorld(memoryDir);
197
268
  const room = opened.state.placements.get(character)?.object ?? null;
198
269
  const turn = k ?? opened.state.turnCount + 1;
@@ -207,6 +278,15 @@ export async function runMudTurn(character, { world, memoryDir, env, graph, cach
207
278
  note: `MUD — ${character} has no placement fact; the turn is declined rather than guessed`,
208
279
  };
209
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
+ }
210
290
 
211
291
  const commandContext = { world, memoryDir, env, graph, cache, actingSubject: character };
212
292
  const runCommand = async (step, cmd, detail) => {
@@ -232,21 +312,39 @@ export async function runMudTurn(character, { world, memoryDir, env, graph, cach
232
312
  });
233
313
  moved = !res.miss;
234
314
  } else {
235
- recordSkip(
236
- "walk",
237
- knownFood(walked.rows, walked.state, character).length
238
- ? "no room it knows holds food is reachable from here"
239
- : "it knows of no food to walk toward",
240
- `the ${character} has nowhere it knows to walk to.`,
241
- );
315
+ recordSkip("walk", walkSkipReason(walked.rows, walked.state, character), `the ${character} has nowhere it knows to walk to.`);
242
316
  }
243
317
 
244
318
  if (!moved) {
245
- await rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip });
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" : ""}`);
246
337
  }
247
338
 
248
339
  const closed = await readWorld(memoryDir);
249
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);
250
348
  return {
251
349
  character,
252
350
  k: turn,
@@ -254,35 +352,86 @@ export async function runMudTurn(character, { world, memoryDir, env, graph, cach
254
352
  roomAfter: closed.state.placements.get(character)?.object ?? room,
255
353
  actions,
256
354
  learned,
257
- text: actions.map((a) => a.text).filter(Boolean).join(" "),
355
+ mass: drained.mass,
356
+ ...(fate ? { outOfPlay: true, outOfPlayReason: fate } : {}),
357
+ text: texts.join(" "),
258
358
  note: notes.join("; "),
259
359
  };
260
360
  }
261
361
 
262
- /** Step one: ask a room-mate about food, examine something unexamined, then
263
- * make one seeded attempt at take/put/eat. The ask and the examine write
264
- * testimony, which never folds into the playable state; only the
265
- * manipulation touches the world. */
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. */
266
400
  async function investigateRoom({ character, turn, room, memoryDir, cache, recordSkip, runCommand, actions, notes }) {
267
401
  const { rows, state } = await readWorld(memoryDir);
268
402
  const roomMates = castIn(state, room, character);
269
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);
270
406
 
271
- const teller = pickSeeded(roomMates, character, turn, room, "ask-who");
407
+ const teller = pickSeeded(worthSpeakingTo, character, turn, room, "ask-who");
272
408
  if (!teller) {
273
- recordSkip("investigate", "no other character stands here to ask", "");
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
+ );
274
416
  } else {
275
- const tellerFood = knownFood(rows, state, teller);
276
- const offers = tellerFood.filter((thing) => !alreadyKnown.has(thing));
277
- const told = pickSeeded(offers, character, turn, room, `ask-${teller}`);
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
+ }
278
426
  if (!told) {
279
- recordSkip(
280
- "investigate",
281
- tellerFood.length
282
- ? `the ${character} already knows every food the ${teller} could name`
283
- : `the ${teller} knows of no food to share`,
284
- "",
285
- );
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`);
286
435
  } else {
287
436
  await recordTold(memoryDir, { asker: character, teller, thing: told, k: turn, cache });
288
437
  alreadyKnown.add(told);
@@ -323,9 +472,15 @@ async function manipulateSomething({ character, turn, room, memoryDir, runComman
323
472
  const { rows, state } = await readWorld(memoryDir);
324
473
  const chosen = MANIPULATIONS[Math.floor(rollFor(character, turn, room, "manipulate") * MANIPULATIONS.length)];
325
474
 
326
- const looseHere = [...state.placements]
327
- .filter(([, place]) => place.predicate === "mgx:located-in" && place.object === room)
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")
328
482
  .map(([thing]) => thing)
483
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
329
484
  .sort();
330
485
  const carried = [...state.placements]
331
486
  .filter(([, place]) => place.predicate === "mgx:located-in" && place.object === character)
@@ -333,8 +488,8 @@ async function manipulateSomething({ character, turn, room, memoryDir, runComman
333
488
  .sort();
334
489
 
335
490
  if (chosen === "take") {
336
- const target = pickSeeded(looseHere, character, turn, room, "take-what");
337
- if (!target) return recordSkip("investigate", "nothing loose here to take", "");
491
+ const target = pickSeeded(withinReach, character, turn, room, "take-what");
492
+ if (!target) return recordSkip("investigate", "nothing here it could pick up", "");
338
493
  return runCommand("investigate", { pattern: "imperative", verb: "take", object: target }, { object: target });
339
494
  }
340
495
 
@@ -356,7 +511,7 @@ async function manipulateSomething({ character, turn, room, memoryDir, runComman
356
511
  );
357
512
  }
358
513
 
359
- const edible = [...new Set([...looseHere, ...carried])].filter((thing) => isFood(rows, thing)).sort();
514
+ const edible = [...new Set([...withinReach, ...carried])].filter((thing) => isFood(rows, thing)).sort();
360
515
  const target = pickSeeded(edible, character, turn, room, "eat-what");
361
516
  if (!target) return recordSkip("investigate", "no food is within reach here", "");
362
517
  return runCommand("investigate", { pattern: "imperative", verb: "eat", object: target }, { object: target });
@@ -364,32 +519,54 @@ async function manipulateSomething({ character, turn, room, memoryDir, runComman
364
519
 
365
520
  /** Step three: the room's unexplored sides, one independent roll per reason
366
521
  * per direction. Only reached on a turn whose walk took no step, so the move
367
- * budget is still unspent. */
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. */
368
524
  async function rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip }) {
369
525
  const { rows, state } = await readWorld(memoryDir);
370
526
  const room = state.placements.get(character)?.object ?? null;
371
- if (!room) return recordSkip("edge", "it has no position to roll from", "");
372
- const edges = edgeDirectionsOf(state, room);
373
- if (!edges.length) return recordSkip("edge", `the ${room} is mapped on every side`, "");
527
+ if (!room) {
528
+ recordSkip("edge", "it has no position to roll from", "");
529
+ return { acted: false };
530
+ }
374
531
 
375
- const foodItKnows = knownFood(rows, state, character);
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);
376
538
  const foodStandsHere = foodItKnows.some((thing) => visibleRoomOf(rows, state, thing) === room);
377
539
  if (foodItKnows.length && !foodStandsHere) {
378
540
  for (const direction of [...(state.exits.get(room)?.keys() ?? [])].sort()) {
379
541
  if (rollFor(character, turn, room, `exit-${direction}`) >= EXIT_TOWARD_FOOD_CHANCE) continue;
380
- return runCommand("edge", { pattern: "imperative", verb: "go", direction }, { direction, reason: "exit-toward-food" });
542
+ await runCommand("edge", { pattern: "imperative", verb: "go", direction }, { direction, reason: "exit-toward-food" });
543
+ return { acted: true };
381
544
  }
382
545
  }
383
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
+
384
558
  for (const direction of edges.filter((d) => LATERAL_DIRECTIONS.includes(d))) {
385
559
  if (rollFor(character, turn, room, `edge-follow-${direction}`) >= EDGE_FOLLOW_DIG_CHANCE) continue;
386
- return runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "edge-follow" });
560
+ await runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "edge-follow" });
561
+ return { acted: true };
387
562
  }
388
563
 
389
564
  for (const direction of edges) {
390
565
  if (rollFor(character, turn, room, `dig-${direction}`) >= EXPLORATORY_DIG_CHANCE) continue;
391
- return runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "explore" });
566
+ await runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "explore" });
567
+ return { acted: true };
392
568
  }
393
569
 
394
- return recordSkip("edge", `no roll came up at the ${room}'s edge`, "");
570
+ recordSkip("edge", `no roll came up at the ${room}'s edge`, "");
571
+ return { acted: false };
395
572
  }