@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.
- package/corpus/sprites/src/sprite-facts.jsonl +10 -0
- package/corpus/tier2/generate.mjs +10 -1
- package/corpus/tier2/human.jsonl +23 -0
- package/corpus/tier2/manifest.json +3 -3
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +15 -5
- package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
- package/corpus/worlds/src/mud-garden.jsonl +68 -0
- package/package.json +2 -1
- package/src/domain/game-config.mjs +46 -0
- package/src/domain/grammar/ace.mjs +11 -3
- package/src/domain/grammar/lexicon-core.json +3 -0
- package/src/domain/memory/trust.mjs +13 -0
- package/src/services/adventure.mjs +351 -62
- package/src/services/chat-session.mjs +28 -3
- package/src/services/chat.mjs +2 -2
- package/src/services/mud-turn.mjs +395 -0
- package/src/services/mud-viz.mjs +752 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +60 -60
- package/src/surfaces/web/mud-browser-entry.mjs +164 -0
|
@@ -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
|
-
|
|
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");
|
package/src/services/chat.mjs
CHANGED
|
@@ -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
|
+
}
|