@polycode-projects/the-mechanical-code-talker 3.2.0 → 3.3.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.
@@ -134,6 +134,26 @@ export async function createSession({
134
134
  // tmct-context.mjs) render hints under the host's own names. Omitted (the
135
135
  // default) preserves today's "tmct_" behavior exactly.
136
136
  toolNamePrefix,
137
+ // Which individual the adventure lane's world-mutating verbs act as —
138
+ // adventure.mjs's own actingSubject parameter, threaded up to session level
139
+ // so one session can play a single mud character rather than always
140
+ // "player". Omitted (the default) preserves today's single-player behavior
141
+ // exactly; a session speaks for exactly one character for its whole
142
+ // lifetime (four mud.html characters means four sessions, not one session
143
+ // switching identity turn to turn).
144
+ actingSubject,
145
+ // Marks a world already LIVE for this session, bypassing openAdventure's
146
+ // "play <world>" opener — that opener requires a shipped "player"
147
+ // individual (its own protection against mis-firing on a non-adventure
148
+ // world like spider-fly's board), which a multi-character world such as
149
+ // mud-garden deliberately never ships. The caller is responsible for
150
+ // seeding that world's facts/rules into this repo's store BEFORE opening
151
+ // any session against it (see test/services/adventure*.test.mjs's
152
+ // loadShippedWorldInto for the pattern) — one shared world, several
153
+ // sessions (one per actingSubject) pointed at the same repoPath. Omitted
154
+ // (the default) preserves today's behavior: a world only goes live via its
155
+ // own "play <world>" opener line.
156
+ adventureWorld,
137
157
  } = {}) {
138
158
  // EPHEMERAL mode (--ephemeral, or TMCT_EPHEMERAL=1): read the target graph but
139
159
  // write NOTHING back into it. The shipped examples run this way so a demo never
@@ -360,7 +380,11 @@ export async function createSession({
360
380
  let turns = 0;
361
381
  let focus = null; // the current focus entity ({id,label}) — threaded turn to turn
362
382
  let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
363
- let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
383
+ // The in-progress plan (goals/moves/cursor) — cleared by completion or a
384
+ // fresh goal, never by an aside. `adventureWorld` seeds it as already
385
+ // playing that world (see the option's own doc comment above) instead of
386
+ // starting null and waiting for a "play <world>" opener line.
387
+ let planState = adventureWorld ? { adventure: { world: adventureWorld } } : null;
364
388
  let researchState = null; // the in-progress research queue — advanced by "research next", cleared by completion or "research stop"
365
389
  // The typed discourse record ([discourse] max_referents caps it) — session-scoped
366
390
  // like the focus, threaded turn to turn, never persisted.
@@ -394,7 +418,7 @@ export async function createSession({
394
418
  async turn(line) {
395
419
  let result;
396
420
  try {
397
- result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord });
421
+ result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord, actingSubject });
398
422
  } catch (e) {
399
423
  const ts = new Date().toISOString();
400
424
  const message = e instanceof Error ? e.message : String(e);
@@ -476,11 +500,12 @@ export async function runChat({
476
500
  liveReference = false,
477
501
  memoryBackend = null,
478
502
  toolNamePrefix,
503
+ actingSubject,
479
504
  } = {}) {
480
505
  // createSession's first-run seed (~2-3s) produces ZERO output until it fully
481
506
  // resolves, which otherwise reads as `npm run chat` hanging with total silence.
482
507
  output.write("tmct — starting…\n");
483
- const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, liveReference, memoryBackend, toolNamePrefix });
508
+ const session = await createSession({ repoPath, graphPaths, configPath, source, env, cwd, gitRoot, ephemeral, narrate, liveReference, memoryBackend, toolNamePrefix, actingSubject });
484
509
 
485
510
  const dim = (s) => (env.NO_COLOR || !output.isTTY ? s : `\x1b[2m${s}\x1b[0m`);
486
511
  for (const line of session.bannerLines) output.write(dim(line) + "\n");
@@ -14957,7 +14957,7 @@ export async function runTurn(input, options = {}) {
14957
14957
  return { ...result, factsTouched: await factsTouchedSince(memoryDir, before) };
14958
14958
  }
14959
14959
 
14960
- async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false } = {}) {
14960
+ async function dispatchTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, liveReference = false, onLiveLookup = null, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null, planState = null, gameConfig = null, uiContext = "cli", synthesisBudget = AUTO_SYNTHESIS_BUDGET, researchState = null, researchConfig = null, discourse = null, _noSplit = false, actingSubject = "player" } = {}) {
14961
14961
  // Every game's tuning knobs (spider-fly's mass economy, guess-the-number's
14962
14962
  // bounds, the shared plan lane's search-depth cap) — a caller's own
14963
14963
  // gameConfig (chat-session.mjs resolves one per session from tmct.toml)
@@ -15118,7 +15118,7 @@ async function dispatchTurn(input, { config, source = defaultSource, graph = nul
15118
15118
  // otherwise read as a declarative or an orientation ask.
15119
15119
  {
15120
15120
  const advTurn = await adventureTurn(workingLine, {
15121
- planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder,
15121
+ planHolder, memoryDir, sessionId, env, lexicon, graph, cache: factRowsCache, isPlanFrameLine, discourseHolder, actingSubject,
15122
15122
  });
15123
15123
  if (advTurn) {
15124
15124
  note(trace, `lane: ${advTurn.note}`);
@@ -0,0 +1,395 @@
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.
8
+ //
9
+ // Every roll is seeded from (character, turn, room, decision name) through
10
+ // fnv1a32/mulberry32, so a run reproduces exactly from its inputs. The world
11
+ // layer writes no bare Math.random anywhere, and this file keeps that.
12
+ //
13
+ // Three things the design leaves open, settled here:
14
+ //
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.
19
+ //
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
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
+ import { mulberry32 } from "../domain/seeded-random.mjs";
36
+ import { fnv1a32 } from "../domain/hash.mjs";
37
+ import { bfsLevels } from "../domain/planning.mjs";
38
+ import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
39
+ import {
40
+ foldWorldState, worldActionRows, runWorldCommand, recordTold, recordExamined,
41
+ personKnowledgeLines, objectClassChain,
42
+ } from "./adventure.mjs";
43
+
44
+ const FOOD_CLASS = "food";
45
+ 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.
49
+ const WALK_SEARCH_DEPTH = 8;
50
+
51
+ const EXIT_TOWARD_FOOD_CHANCE = 0.5;
52
+ const EDGE_FOLLOW_DIG_CHANCE = 0.25;
53
+ const EXPLORATORY_DIG_CHANCE = 0.1;
54
+
55
+ const MANIPULATIONS = ["take", "put", "eat"];
56
+
57
+ // ---- seeded decisions --------------------------------------------------------
58
+
59
+ /** One roll in [0,1) for one decision, reproducible from the four things that
60
+ * identify it. Two characters deciding the same thing in the same room on the
61
+ * same turn get different numbers; the same character re-run gets the same
62
+ * one. */
63
+ const rollFor = (character, k, room, decision) =>
64
+ mulberry32(fnv1a32(`${character}:${k}:${room}:${decision}`))();
65
+
66
+ /** The item a seeded roll selects out of `items`, or null when there is
67
+ * nothing to select. Callers sort their candidates first, so the choice does
68
+ * not ride on fact-row order. */
69
+ function pickSeeded(items, character, k, room, decision) {
70
+ if (!items.length) return null;
71
+ return items[Math.floor(rollFor(character, k, room, decision) * items.length)];
72
+ }
73
+
74
+ // ---- world reads -------------------------------------------------------------
75
+
76
+ async function readWorld(memoryDir) {
77
+ const rows = readFactRows(await loadMemory(memoryDir));
78
+ return { rows, state: foldWorldState(worldActionRows(rows)) };
79
+ }
80
+
81
+ const isTyped = (rows, subject, type) =>
82
+ (rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
83
+
84
+ const isContainer = (rows, subject) =>
85
+ (rows || []).some((r) => r.subject === subject && r.predicate === "mgx:is-container" && r.object === "true");
86
+
87
+ /** The room a thing is on show in, mirroring the presence check every
88
+ * adventure verb already applies. adventure.mjs keeps its own copy private,
89
+ * and this file may not reach into it, so the rule is restated rather than
90
+ * approximated: a decision made on a looser notion of "present" would pick
91
+ * actions the verbs then refuse. */
92
+ function visibleRoomOf(rows, state, thing) {
93
+ const place = state.placements.get(thing);
94
+ if (!place || place.predicate === "mgx:hidden-in") return null;
95
+ if (place.predicate === "mgx:currently-in" || isTyped(rows, place.object, "room")) return place.object;
96
+ const holder = place.object;
97
+ if (!isContainer(rows, holder)) return null; // a character is carrying it
98
+ if (!state.openness.get(holder)?.open) return null;
99
+ const holderPlace = state.placements.get(holder);
100
+ return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
101
+ }
102
+
103
+ const carriedBy = (state, thing, holder) => {
104
+ const place = state.placements.get(thing);
105
+ return !!place && place.predicate === "mgx:located-in" && place.object === holder;
106
+ };
107
+
108
+ const isFood = (rows, thing) => objectClassChain(rows, thing).includes(FOOD_CLASS);
109
+
110
+ /** What `character` currently knows about, food only — the durable
111
+ * knows-about facts recordTold/recordExamined leave behind, read over the
112
+ * FULL rows because testimony is deliberately filtered out of the state
113
+ * fold. */
114
+ function knownFood(rows, state, character) {
115
+ const { aboutTopics } = personKnowledgeLines(rows, state, character);
116
+ return [...new Set(aboutTopics)].filter((thing) => isFood(rows, thing)).sort();
117
+ }
118
+
119
+ const knownTopics = (rows, state, character) =>
120
+ new Set(personKnowledgeLines(rows, state, character).aboutTopics);
121
+
122
+ /** The cast standing in `room`: the world places its characters with
123
+ * currently-in and its props every other way, so that predicate is the whole
124
+ * test. Discovered from the fold rather than handed in, so a caller can never
125
+ * hand this turn a room-mate that has already walked off. */
126
+ const castIn = (state, room, exclude) => [...state.placements]
127
+ .filter(([subject, place]) => subject !== exclude && place.predicate === "mgx:currently-in" && place.object === room)
128
+ .map(([subject]) => subject)
129
+ .sort();
130
+
131
+ // ---- the directed walk -------------------------------------------------------
132
+
133
+ /**
134
+ * 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.
139
+ */
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
+
148
+ const successorsOf = (room) => [...(state.exits.get(room)?.entries() ?? [])]
149
+ .map(([direction, target]) => ({ room: target, direction, from: room }));
150
+
151
+ const cameFrom = new Map(); // room -> { via, from }
152
+ let hops = 0;
153
+ for (const level of bfsLevels(here, successorsOf, { maxDepth: WALK_SEARCH_DEPTH, keyOf: (item) => item.room })) {
154
+ hops += 1;
155
+ 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];
157
+ if (!goal) continue;
158
+ let room = goal;
159
+ let firstStep = null;
160
+ while (room !== here) {
161
+ const trail = cameFrom.get(room);
162
+ if (!trail) return null;
163
+ firstStep = trail.via;
164
+ room = trail.from;
165
+ }
166
+ return firstStep ? { direction: firstStep, room: goal, hops } : null;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // ---- the turn ----------------------------------------------------------------
172
+
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
+ };
180
+
181
+ /**
182
+ * Run one acting character's whole turn against a live mud world.
183
+ *
184
+ * `k` is the turn ordinal the caller drives (it tags the testimony this turn
185
+ * writes and seeds this turn's rolls); it defaults to the world's own next
186
+ * turn. Room-mates are discovered from the fold, so the caller passes no cast
187
+ * list.
188
+ *
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.
194
+ */
195
+ export async function runMudTurn(character, { world, memoryDir, env, graph, cache, k = null } = {}) {
196
+ const opened = await readWorld(memoryDir);
197
+ const room = opened.state.placements.get(character)?.object ?? null;
198
+ const turn = k ?? opened.state.turnCount + 1;
199
+ const actions = [];
200
+ const notes = [];
201
+ const learnedBefore = knownTopics(opened.rows, opened.state, character);
202
+
203
+ if (!room) {
204
+ return {
205
+ character, k: turn, room: null, roomAfter: null, actions, learned: [],
206
+ text: `the ${character} has no written position in this world.`,
207
+ note: `MUD — ${character} has no placement fact; the turn is declined rather than guessed`,
208
+ };
209
+ }
210
+
211
+ const commandContext = { world, memoryDir, env, graph, cache, actingSubject: character };
212
+ const runCommand = async (step, cmd, detail) => {
213
+ const res = await runWorldCommand(cmd, commandContext);
214
+ actions.push({ step, kind: cmd.verb, ...detail, text: res.text, miss: !!res.miss });
215
+ notes.push(res.note);
216
+ return res;
217
+ };
218
+ const recordSkip = (step, reason, text) => {
219
+ actions.push({ step, kind: "none", reason, text, miss: false });
220
+ notes.push(`MUD — ${step}: ${reason}`);
221
+ };
222
+
223
+ await investigateRoom({ character, turn, room, memoryDir, cache, actions, notes, runCommand, recordSkip });
224
+
225
+ const walked = await readWorld(memoryDir);
226
+ const walkedRoom = walked.state.placements.get(character)?.object ?? room;
227
+ const step = stepTowardKnownFood(walked.rows, walked.state, character, walkedRoom);
228
+ let moved = false;
229
+ if (step) {
230
+ const res = await runCommand("walk", { pattern: "imperative", verb: "go", direction: step.direction }, {
231
+ direction: step.direction, toward: step.room, hops: step.hops,
232
+ });
233
+ moved = !res.miss;
234
+ } 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
+ );
242
+ }
243
+
244
+ if (!moved) {
245
+ await rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip });
246
+ }
247
+
248
+ const closed = await readWorld(memoryDir);
249
+ const learned = [...knownTopics(closed.rows, closed.state, character)].filter((t) => !learnedBefore.has(t)).sort();
250
+ return {
251
+ character,
252
+ k: turn,
253
+ room,
254
+ roomAfter: closed.state.placements.get(character)?.object ?? room,
255
+ actions,
256
+ learned,
257
+ text: actions.map((a) => a.text).filter(Boolean).join(" "),
258
+ note: notes.join("; "),
259
+ };
260
+ }
261
+
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. */
266
+ async function investigateRoom({ character, turn, room, memoryDir, cache, recordSkip, runCommand, actions, notes }) {
267
+ const { rows, state } = await readWorld(memoryDir);
268
+ const roomMates = castIn(state, room, character);
269
+ const alreadyKnown = knownTopics(rows, state, character);
270
+
271
+ const teller = pickSeeded(roomMates, character, turn, room, "ask-who");
272
+ if (!teller) {
273
+ recordSkip("investigate", "no other character stands here to ask", "");
274
+ } 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}`);
278
+ 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
+ );
286
+ } else {
287
+ await recordTold(memoryDir, { asker: character, teller, thing: told, k: turn, cache });
288
+ alreadyKnown.add(told);
289
+ actions.push({
290
+ step: "investigate", kind: "ask", teller, thing: told, miss: false,
291
+ text: `the ${character} asks the ${teller} about food, and hears about the ${told}.`,
292
+ });
293
+ notes.push(`MUD — ask: ${teller} told ${character} about ${told}; written as mud:${teller}:turn${turn}`);
294
+ }
295
+ }
296
+
297
+ const unexamined = [...state.placements.keys()]
298
+ .filter((thing) => thing !== character && !alreadyKnown.has(thing))
299
+ .filter((thing) => state.placements.get(thing).predicate !== "mgx:currently-in")
300
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
301
+ .sort();
302
+ const examined = pickSeeded(unexamined, character, turn, room, "examine");
303
+ if (!examined) {
304
+ recordSkip("investigate", "nothing unexamined stands here", "");
305
+ } else {
306
+ await recordExamined(memoryDir, { observer: character, thing: examined, k: turn, cache });
307
+ alreadyKnown.add(examined);
308
+ actions.push({
309
+ step: "investigate", kind: "examine", thing: examined, miss: false,
310
+ text: `the ${character} examines the ${examined}.`,
311
+ });
312
+ notes.push(`MUD — examine: ${character} looked at ${examined}; written as mud:${character}:turn${turn}`);
313
+ }
314
+
315
+ await manipulateSomething({ character, turn, room, memoryDir, runCommand, recordSkip });
316
+ }
317
+
318
+ /** Step one's last move: one of take, put or eat, chosen by a seeded roll. A
319
+ * roll that lands on something the room cannot offer this turn is a plain
320
+ * no-op — the alternative would be narrating an action that never happened.
321
+ * The hunger gate itself stays with the eat verb, which owns it. */
322
+ async function manipulateSomething({ character, turn, room, memoryDir, runCommand, recordSkip }) {
323
+ const { rows, state } = await readWorld(memoryDir);
324
+ const chosen = MANIPULATIONS[Math.floor(rollFor(character, turn, room, "manipulate") * MANIPULATIONS.length)];
325
+
326
+ const looseHere = [...state.placements]
327
+ .filter(([, place]) => place.predicate === "mgx:located-in" && place.object === room)
328
+ .map(([thing]) => thing)
329
+ .sort();
330
+ const carried = [...state.placements]
331
+ .filter(([, place]) => place.predicate === "mgx:located-in" && place.object === character)
332
+ .map(([thing]) => thing)
333
+ .sort();
334
+
335
+ if (chosen === "take") {
336
+ const target = pickSeeded(looseHere, character, turn, room, "take-what");
337
+ if (!target) return recordSkip("investigate", "nothing loose here to take", "");
338
+ return runCommand("investigate", { pattern: "imperative", verb: "take", object: target }, { object: target });
339
+ }
340
+
341
+ if (chosen === "put") {
342
+ const containers = [...state.placements.keys()]
343
+ .filter((thing) => isContainer(rows, thing))
344
+ .filter((thing) => state.openness.get(thing)?.open)
345
+ .filter((thing) => visibleRoomOf(rows, state, thing) === room)
346
+ .sort();
347
+ const target = pickSeeded(carried, character, turn, room, "put-what");
348
+ const container = pickSeeded(containers, character, turn, room, "put-where");
349
+ if (!target || !container) {
350
+ return recordSkip("investigate", carried.length ? "no open container stands here" : "it carries nothing to put down", "");
351
+ }
352
+ return runCommand(
353
+ "investigate",
354
+ { pattern: "imperative", verb: "put", object: target, indirectObject: container },
355
+ { object: target, indirectObject: container },
356
+ );
357
+ }
358
+
359
+ const edible = [...new Set([...looseHere, ...carried])].filter((thing) => isFood(rows, thing)).sort();
360
+ const target = pickSeeded(edible, character, turn, room, "eat-what");
361
+ if (!target) return recordSkip("investigate", "no food is within reach here", "");
362
+ return runCommand("investigate", { pattern: "imperative", verb: "eat", object: target }, { object: target });
363
+ }
364
+
365
+ /** Step three: the room's unexplored sides, one independent roll per reason
366
+ * per direction. Only reached on a turn whose walk took no step, so the move
367
+ * budget is still unspent. */
368
+ async function rollAtEdge({ character, turn, memoryDir, runCommand, recordSkip }) {
369
+ const { rows, state } = await readWorld(memoryDir);
370
+ 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`, "");
374
+
375
+ const foodItKnows = knownFood(rows, state, character);
376
+ const foodStandsHere = foodItKnows.some((thing) => visibleRoomOf(rows, state, thing) === room);
377
+ if (foodItKnows.length && !foodStandsHere) {
378
+ for (const direction of [...(state.exits.get(room)?.keys() ?? [])].sort()) {
379
+ 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" });
381
+ }
382
+ }
383
+
384
+ for (const direction of edges.filter((d) => LATERAL_DIRECTIONS.includes(d))) {
385
+ 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" });
387
+ }
388
+
389
+ for (const direction of edges) {
390
+ if (rollFor(character, turn, room, `dig-${direction}`) >= EXPLORATORY_DIG_CHANCE) continue;
391
+ return runCommand("edge", { pattern: "imperative", verb: "dig", direction }, { direction, reason: "explore" });
392
+ }
393
+
394
+ return recordSkip("edge", `no roll came up at the ${room}'s edge`, "");
395
+ }