@polycode-projects/the-mechanical-code-talker 2.6.1 → 2.7.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/LICENSES.json +14 -0
- package/corpus/child/LICENSE-NOTICE +43 -0
- package/corpus/child/README.md +79 -0
- package/corpus/child/index.json.gz +0 -0
- package/corpus/child/manifest.json +313 -0
- package/corpus/child/shards/child-00.jsonl.gz +0 -0
- package/corpus/child/shards/child-01.jsonl.gz +0 -0
- package/corpus/child/shards/child-02.jsonl.gz +0 -0
- package/corpus/child/shards/child-03.jsonl.gz +0 -0
- package/corpus/child/shards/child-04.jsonl.gz +0 -0
- package/corpus/child/shards/child-05.jsonl.gz +0 -0
- package/corpus/child/shards/child-06.jsonl.gz +0 -0
- package/corpus/child/shards/child-07.jsonl.gz +0 -0
- package/corpus/child/shards/child-08.jsonl.gz +0 -0
- package/corpus/child/shards/child-09.jsonl.gz +0 -0
- package/corpus/child/shards/child-0a.jsonl.gz +0 -0
- package/corpus/child/shards/child-0b.jsonl.gz +0 -0
- package/corpus/child/shards/child-0c.jsonl.gz +0 -0
- package/corpus/child/shards/child-0d.jsonl.gz +0 -0
- package/corpus/child/shards/child-0e.jsonl.gz +0 -0
- package/corpus/child/shards/child-0f.jsonl.gz +0 -0
- package/corpus/child/shards/child-10.jsonl.gz +0 -0
- package/corpus/child/shards/child-11.jsonl.gz +0 -0
- package/corpus/child/shards/child-12.jsonl.gz +0 -0
- package/corpus/child/shards/child-13.jsonl.gz +0 -0
- package/corpus/child/shards/child-14.jsonl.gz +0 -0
- package/corpus/child/shards/child-15.jsonl.gz +0 -0
- package/corpus/child/shards/child-16.jsonl.gz +0 -0
- package/corpus/child/shards/child-17.jsonl.gz +0 -0
- package/corpus/child/shards/child-18.jsonl.gz +0 -0
- package/corpus/child/shards/child-19.jsonl.gz +0 -0
- package/corpus/child/shards/child-1a.jsonl.gz +0 -0
- package/corpus/child/shards/child-1b.jsonl.gz +0 -0
- package/corpus/child/shards/child-1c.jsonl.gz +0 -0
- package/corpus/child/shards/child-1d.jsonl.gz +0 -0
- package/corpus/child/shards/child-1e.jsonl.gz +0 -0
- package/corpus/child/shards/child-1f.jsonl.gz +0 -0
- package/corpus/conceptnet/child-seed.mjs +169 -0
- package/corpus/conceptnet/filter-dump.mjs +69 -48
- package/corpus/worlds/README.md +26 -0
- package/corpus/worlds/index.json.gz +0 -0
- package/corpus/worlds/manifest.json +33 -0
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +64 -0
- package/package.json +4 -1
- package/src/adapters/corpus/child-pack.mjs +115 -0
- package/src/adapters/corpus/conceptnet-map.toml +7 -0
- package/src/adapters/corpus/worlds-pack.mjs +122 -0
- package/src/domain/child-pack.mjs +79 -0
- package/src/domain/grammar/ace.mjs +77 -0
- package/src/domain/grammar/lexicon-core.json +6 -0
- package/src/domain/memory/trust.mjs +8 -0
- package/src/domain/worlds-pack.mjs +71 -0
- package/src/services/adventure.mjs +669 -0
- package/src/services/chat.mjs +26 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +521 -39
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// adventure.mjs — the chat adventure lane: loading a shipped game world into
|
|
2
|
+
// the session's memory store and (once one is live) playing it. The world is
|
|
3
|
+
// data all the way down: its rooms, exits, objects and cast arrive as fact
|
|
4
|
+
// rows from the worlds pack (src/adapters/corpus/worlds-pack.mjs), its verbs
|
|
5
|
+
// as pre-built action-Rule families, and this module never hardcodes a
|
|
6
|
+
// particular world. The lane's state on the shared session plan slot is one
|
|
7
|
+
// tagged sub-object ({ adventure: { world } }), mirroring the guess-number
|
|
8
|
+
// game's { game } payload, so a plan, a number game and an adventure never
|
|
9
|
+
// share the slot.
|
|
10
|
+
|
|
11
|
+
import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
|
|
12
|
+
import { parseImperative } from "../domain/grammar/ace.mjs";
|
|
13
|
+
import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
|
|
14
|
+
import { actionFamilies } from "../domain/router/taught.mjs";
|
|
15
|
+
import { getWorldsPackProvider } from "../adapters/corpus/worlds-pack.mjs";
|
|
16
|
+
import { appendFacts, appendRule, loadMemory, readFactRows, readRuleRows } from "../adapters/memory/core.mjs";
|
|
17
|
+
import { COMPLETIONS_STORE, generateCompletion } from "./completions.mjs";
|
|
18
|
+
|
|
19
|
+
// ---- recognizers: the closed opening/stop set --------------------------------
|
|
20
|
+
|
|
21
|
+
// The generic opener names the adventure without naming a world ("start the
|
|
22
|
+
// adventure", "play the adventure game") — it loads the pack's only world,
|
|
23
|
+
// or asks which when there are several.
|
|
24
|
+
const ADVENTURE_GENERIC_OPEN_RE =
|
|
25
|
+
/^(?:let'?s\s+)?(?:play|start|begin)\s+(?:the\s+|an?\s+)?(?:text\s+)?adventure(?:\s+game)?[.!?\s]*$/i;
|
|
26
|
+
// The named opener ("play ashcombe hall"). "play" only — "start <anything>"
|
|
27
|
+
// would hijack ordinary requests. The captured name is checked against the
|
|
28
|
+
// pack's own index downstream; an unknown name gets an honest decline that
|
|
29
|
+
// lists the worlds the pack actually has.
|
|
30
|
+
const ADVENTURE_NAMED_OPEN_RE = /^(?:let'?s\s+play|play)\s+(?:the\s+)?([a-z][a-z' -]*[a-z])\s*[.!?]*$/i;
|
|
31
|
+
const ADVENTURE_STOP_RE =
|
|
32
|
+
/^(?:stop\s+playing|quit\s+(?:the\s+)?(?:game|adventure)|stop\s+(?:the\s+)?(?:game|adventure)|end\s+the\s+adventure|leave\s+the\s+game)[.!?\s]*$/i;
|
|
33
|
+
|
|
34
|
+
const worldNameOf = (spoken) => String(spoken || "").trim().toLowerCase().replace(/['.!?]/g, "").replace(/[\s-]+/g, "-");
|
|
35
|
+
const spokenNameOf = (worldName) => String(worldName || "").replace(/-/g, " ");
|
|
36
|
+
|
|
37
|
+
/** An adventure opening move — { world } (null world = the generic opener) —
|
|
38
|
+
* or null when the line is not one. Shape-only; the pack is consulted by
|
|
39
|
+
* the lane, not here. */
|
|
40
|
+
export function matchAdventureOpening(line) {
|
|
41
|
+
const l = String(line).trim();
|
|
42
|
+
if (ADVENTURE_GENERIC_OPEN_RE.test(l)) return { world: null };
|
|
43
|
+
const named = l.match(ADVENTURE_NAMED_OPEN_RE);
|
|
44
|
+
if (named) return { world: worldNameOf(named[1]) };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- the opening turn: pull the world through the provider -------------------
|
|
49
|
+
|
|
50
|
+
const missingPackAnswer = () => ({
|
|
51
|
+
text: 'no worlds pack here — game worlds ship in corpus/worlds/ (or the directory TMCT_WORLDS_PACK_DIR names), and that pack is missing or unreadable, so there is no world to load.',
|
|
52
|
+
lane: "game-inform",
|
|
53
|
+
note: "ADVENTURE — opening declined: the worlds pack is absent/unreadable",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
async function openAdventure(opening, { planHolder, memoryDir, sessionId, env, cache }) {
|
|
57
|
+
if (!memoryDir) {
|
|
58
|
+
return {
|
|
59
|
+
text: "an adventure needs a session with a memory store to hold the world — start tmct inside a repo first.",
|
|
60
|
+
lane: "game-inform",
|
|
61
|
+
note: "ADVENTURE — opening declined: no memory store to load the world into",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const provider = getWorldsPackProvider(env);
|
|
65
|
+
let names = null;
|
|
66
|
+
try { names = await provider.list(); } catch { names = null; }
|
|
67
|
+
if (!names || !names.length) return missingPackAnswer();
|
|
68
|
+
|
|
69
|
+
let world = opening.world;
|
|
70
|
+
if (!world) {
|
|
71
|
+
if (names.length > 1) {
|
|
72
|
+
return {
|
|
73
|
+
text: `which world? The pack has: ${names.map(spokenNameOf).join(", ")}. Say "play ${spokenNameOf(names[0])}".`,
|
|
74
|
+
lane: "game-inform",
|
|
75
|
+
note: "ADVENTURE — the generic opener with several worlds shipped; asked which, never guessed",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
world = names[0];
|
|
79
|
+
} else if (!names.includes(world)) {
|
|
80
|
+
// An unknown "play X" is not necessarily an adventure ask at all ("play
|
|
81
|
+
// chess") — fall through to the ordinary lanes rather than claim it.
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let payload = null;
|
|
86
|
+
try { payload = await provider.load(world); } catch { payload = null; }
|
|
87
|
+
if (!payload) {
|
|
88
|
+
return {
|
|
89
|
+
text: `the worlds pack lists "${spokenNameOf(world)}" but its shard is missing or unreadable — rebuild the pack with \`npm run gen:worlds-pack\`.`,
|
|
90
|
+
lane: "game-inform",
|
|
91
|
+
note: "ADVENTURE — opening declined: the world's shard failed to load",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tag = worldProvenanceTag(world);
|
|
96
|
+
await appendFacts(memoryDir, payload.facts.map((f) => ({
|
|
97
|
+
subject: f.subject, predicate: f.predicate, object: f.object, provenance: tag,
|
|
98
|
+
})));
|
|
99
|
+
for (const rule of payload.rules) {
|
|
100
|
+
try {
|
|
101
|
+
await appendRule(memoryDir, { name: rule.name, kind: rule.ruleKind, slots: rule.slots, provenance: tag });
|
|
102
|
+
} catch { /* one malformed rule row loses that rule, not the world */ }
|
|
103
|
+
}
|
|
104
|
+
if (cache) cache.rows = null; // the fact-rows cache predates these writes
|
|
105
|
+
|
|
106
|
+
planHolder.state = { adventure: { world } };
|
|
107
|
+
const resumed = await resumedPosition(memoryDir);
|
|
108
|
+
const opener = resumed
|
|
109
|
+
? `back in the adventure — you are in the ${resumed}. Say "look" to look around.`
|
|
110
|
+
: payload.meta?.opening || "the adventure begins.";
|
|
111
|
+
return {
|
|
112
|
+
text: opener,
|
|
113
|
+
goal: `play the ${spokenNameOf(world)} adventure`,
|
|
114
|
+
lane: "game-inform",
|
|
115
|
+
note: `ADVENTURE — loaded the "${world}" world from the pack into this session's memory (facts + action families, provenance ${tag}) and announced the ${resumed ? "resumed" : "opening"} room`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A resumed game's current room: non-null only when earlier @turnN
|
|
120
|
+
* snapshots exist (the world was already played in this store), folded the
|
|
121
|
+
* same way every other reader folds them. A fresh world returns null and
|
|
122
|
+
* the meta opening speaks. */
|
|
123
|
+
async function resumedPosition(memoryDir) {
|
|
124
|
+
try {
|
|
125
|
+
const state = foldWorldState(readFactRows(await loadMemory(memoryDir)));
|
|
126
|
+
if (!state.turnCount) return null;
|
|
127
|
+
return state.placements.get("player")?.object ?? null;
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- the world-state fold ----------------------------------------------------
|
|
134
|
+
|
|
135
|
+
const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
|
|
136
|
+
const PLACEMENT_PREDICATES = new Set([
|
|
137
|
+
"mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
|
|
138
|
+
]);
|
|
139
|
+
const OPEN_PREDICATE = "mgx:is-open";
|
|
140
|
+
const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
|
|
141
|
+
|
|
142
|
+
/** Fold fact rows into the CURRENT world state: per subject, the newest
|
|
143
|
+
* placement (base row = turn 0, @turnN snapshots override), the newest
|
|
144
|
+
* open/closed state, the exit map, and the turn counter (the largest @turnN
|
|
145
|
+
* suffix written so far — derived, never stored). Pure. */
|
|
146
|
+
export function foldWorldState(factRows) {
|
|
147
|
+
const placements = new Map(); // subject -> { predicate, object, turn }
|
|
148
|
+
const openness = new Map(); // subject -> { open, turn }
|
|
149
|
+
const exits = new Map(); // room -> Map(direction -> room)
|
|
150
|
+
let turnCount = 0;
|
|
151
|
+
for (const row of factRows || []) {
|
|
152
|
+
const m = SNAPSHOT_RE.exec(row.subject);
|
|
153
|
+
const base = m ? m[1] : row.subject;
|
|
154
|
+
const turn = m ? Number(m[2]) : 0;
|
|
155
|
+
if (m) turnCount = Math.max(turnCount, turn);
|
|
156
|
+
if (PLACEMENT_PREDICATES.has(row.predicate)) {
|
|
157
|
+
const prior = placements.get(base);
|
|
158
|
+
if (!prior || turn >= prior.turn) placements.set(base, { predicate: row.predicate, object: row.object, turn });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (row.predicate === OPEN_PREDICATE) {
|
|
162
|
+
const prior = openness.get(base);
|
|
163
|
+
if (!prior || turn >= prior.turn) openness.set(base, { open: row.object === "true", turn });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const exit = EXIT_PREDICATE_RE.exec(row.predicate);
|
|
167
|
+
if (exit && !m) {
|
|
168
|
+
if (!exits.has(row.subject)) exits.set(row.subject, new Map());
|
|
169
|
+
exits.get(row.subject).set(exit[1], row.object);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { placements, openness, exits, turnCount };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- the world interpreter ---------------------------------------------------
|
|
176
|
+
//
|
|
177
|
+
// Generic over any loaded world: which verb exists is the taught action
|
|
178
|
+
// families' business (a verb with no family declines by name), what each
|
|
179
|
+
// verb writes is its family's effect rows, and every precondition is a
|
|
180
|
+
// closed-vocabulary world FACT (exits, lock state, hidden contents, the
|
|
181
|
+
// unlock instrument) checked per predicate — never per object, never per
|
|
182
|
+
// world. Effects append as @turnN snapshots through the ordinary appendFacts
|
|
183
|
+
// path; nothing is ever mutated in place.
|
|
184
|
+
|
|
185
|
+
const INVENTORY_RE =
|
|
186
|
+
/^(?:inventory|inv|what\s+am\s+i\s+carrying|what\s+do\s+i\s+have|what(?:'s|\s+is)\s+in\s+my\s+(?:bag|pockets?|hands?))[?.!\s]*$/i;
|
|
187
|
+
|
|
188
|
+
const factObjects = (rows, subject, predicate) =>
|
|
189
|
+
(rows || []).filter((r) => r.subject === subject && r.predicate === predicate).map((r) => r.object);
|
|
190
|
+
|
|
191
|
+
const isTyped = (rows, subject, type) =>
|
|
192
|
+
(rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
|
|
193
|
+
|
|
194
|
+
const isContainer = (rows, x) => factObjects(rows, x, "mgx:is-container").includes("true");
|
|
195
|
+
|
|
196
|
+
/** The room an object's current placement puts it in for visibility: its own
|
|
197
|
+
* room, or (one containment hop) the room of the OPEN container holding it. */
|
|
198
|
+
function visibleRoomOf(thing, { rows, state }) {
|
|
199
|
+
const place = state.placements.get(thing);
|
|
200
|
+
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
201
|
+
if (place.predicate === "mgx:currently-in" || isTyped(rows, place.object, "room")) return place.object;
|
|
202
|
+
const holder = place.object;
|
|
203
|
+
if (holder === "player") return null; // carried, not on show in a room
|
|
204
|
+
if (!state.openness.get(holder)?.open) return null;
|
|
205
|
+
const holderPlace = state.placements.get(holder);
|
|
206
|
+
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const carriedByPlayer = (state, thing) => {
|
|
210
|
+
const place = state.placements.get(thing);
|
|
211
|
+
return !!place && place.predicate === "mgx:located-in" && place.object === "player";
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** The effect predicate a family writes (its action-effect row's slot, with
|
|
215
|
+
* the mgx: prefix rule readers re-attach). Null when the family carries no
|
|
216
|
+
* effect row — the open/unlock/close families, whose datatype state writes
|
|
217
|
+
* have no shipped effect shape yet and ride the container logic below. */
|
|
218
|
+
function familyEffectPredicate(family) {
|
|
219
|
+
const effect = (family || []).find((r) => r.kind === "action-effect");
|
|
220
|
+
if (!effect?.slots?.predicate) return null;
|
|
221
|
+
const p = effect.slots.predicate;
|
|
222
|
+
return p.includes(":") ? p : `mgx:${p}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- the NPC scheduler -------------------------------------------------------
|
|
226
|
+
//
|
|
227
|
+
// One bounded pass per state-changing player command, run synchronously in
|
|
228
|
+
// the same turn, immediately after the player's own effect: every individual
|
|
229
|
+
// tagged mgx:is-npc is walked in fixed sorted order, and an NPC whose
|
|
230
|
+
// mgx:acts-on-turn fact matches the new turn number fires its taught "go"
|
|
231
|
+
// family toward its mgx:acts-toward target — capped at one fired action per
|
|
232
|
+
// NPC per turn, never a cascade, no randomness anywhere. The precondition is
|
|
233
|
+
// the same one the player's own go obeys (an exit fact linking the rooms)
|
|
234
|
+
// plus the family's signature covering the NPC, and the effect writes as the
|
|
235
|
+
// same @turnN snapshot whether or not the player is there to see it. This is
|
|
236
|
+
// scripted-by-data autonomy: the schedule, the target, the cast and the
|
|
237
|
+
// family all arrive as world rows, never code.
|
|
238
|
+
|
|
239
|
+
export function runNpcPass({ rows, state, k, families, playerRoomAfter }) {
|
|
240
|
+
const writes = [];
|
|
241
|
+
const lines = [];
|
|
242
|
+
const goFamily = families.get("go") || [];
|
|
243
|
+
const signatures = goFamily.filter((r) => r.kind === "action-signature");
|
|
244
|
+
const effectPredicate = familyEffectPredicate(goFamily) ?? "mgx:currently-in";
|
|
245
|
+
const npcs = [...new Set((rows || [])
|
|
246
|
+
.filter((r) => r.predicate === "mgx:is-npc" && r.object === "true")
|
|
247
|
+
.map((r) => r.subject))].sort();
|
|
248
|
+
for (const npc of npcs) {
|
|
249
|
+
if (!factObjects(rows, npc, "mgx:acts-on-turn").includes(String(k))) continue;
|
|
250
|
+
const target = factObjects(rows, npc, "mgx:acts-toward")[0];
|
|
251
|
+
if (!target) continue;
|
|
252
|
+
const from = state.placements.get(npc)?.object;
|
|
253
|
+
if (!from || from === target) continue;
|
|
254
|
+
const covered = signatures.some((s) =>
|
|
255
|
+
isTyped(rows, npc, s.slots.subjectClass) && isTyped(rows, target, s.slots.targetClass));
|
|
256
|
+
if (!covered) continue;
|
|
257
|
+
const linked = [...(state.exits.get(from)?.values() ?? [])].includes(target);
|
|
258
|
+
if (!linked) continue;
|
|
259
|
+
writes.push({ subject: `${npc}@turn${k}`, predicate: effectPredicate, object: target });
|
|
260
|
+
if (playerRoomAfter === target) lines.push(`the ${npc} walks in.`);
|
|
261
|
+
else if (playerRoomAfter === from) lines.push(`the ${npc} leaves.`);
|
|
262
|
+
}
|
|
263
|
+
return { writes, lines };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function writeWorldTurn(memoryDir, world, k, facts, cache) {
|
|
267
|
+
await appendFacts(memoryDir, facts.map((f) => ({
|
|
268
|
+
...f, provenance: `${worldProvenanceTag(world)}:turn${k}`,
|
|
269
|
+
})));
|
|
270
|
+
if (cache) cache.rows = null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---- the look/inventory digest ----------------------------------------------
|
|
274
|
+
//
|
|
275
|
+
// "look" and "what am I carrying" are generateCompletion calls (the shipped
|
|
276
|
+
// extractive pipeline), pointed at the current room / the player — never a
|
|
277
|
+
// hand-written room template. Two pieces of query shaping make the pipeline
|
|
278
|
+
// read CURRENT state honestly: the fact view handed to the pipeline's graph
|
|
279
|
+
// adapter is the same @turnN fold every other reader here uses (so a
|
|
280
|
+
// superseded placement never contradicts the newest one), and its predicates
|
|
281
|
+
// render as their phrase forms (the same rendering discipline as chat's own
|
|
282
|
+
// predicatePhrase) so the extracted sentences are plain prose. Hidden
|
|
283
|
+
// contents and the puzzle wiring (mgx:hidden-in, mgx:unlocks-with, the NPC
|
|
284
|
+
// schedule) stay out of the view: hidden means hidden.
|
|
285
|
+
|
|
286
|
+
const VIEW_EXCLUDED_PREDICATES = new Set([
|
|
287
|
+
"mgx:hidden-in", "mgx:is-open", "mgx:is-npc", "mgx:is-container",
|
|
288
|
+
"mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward",
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
const sentenceCase = (term) => String(term).charAt(0).toUpperCase() + String(term).slice(1);
|
|
292
|
+
const typePhrase = (object) => (/^[aeiou]/.test(object) ? "is an" : "is a");
|
|
293
|
+
|
|
294
|
+
/** The digest's fact view: current placements (folded), exits, typing and
|
|
295
|
+
* every other surviving fact, with phrase predicates and sentence-cased
|
|
296
|
+
* subjects so the pipeline's sentence splitter sees real sentences. Pure. */
|
|
297
|
+
export function worldDigestRows(rows, state) {
|
|
298
|
+
const out = [];
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
const push = (subject, phrase, object) => {
|
|
301
|
+
const key = `${subject}\0${phrase}\0${object}`;
|
|
302
|
+
if (seen.has(key)) return;
|
|
303
|
+
seen.add(key);
|
|
304
|
+
out.push({ subject: sentenceCase(subject), predicate: phrase, object });
|
|
305
|
+
};
|
|
306
|
+
for (const [subject, place] of state.placements) {
|
|
307
|
+
if (place.predicate === "mgx:hidden-in") continue;
|
|
308
|
+
if (place.predicate === "mgx:located-in" && place.object === "player") {
|
|
309
|
+
push("player", "carries the", subject);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (place.predicate === "mgx:located-in" && isTyped(rows, place.object, "person")) {
|
|
313
|
+
push(place.object, "carries the", subject);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const phrase = {
|
|
317
|
+
"mgx:currently-in": "is in the",
|
|
318
|
+
"mgx:located-in": "is in the",
|
|
319
|
+
"mgx:fixed-in": "is fixed in the",
|
|
320
|
+
"mgx:stands-locked-in": "stands locked in the",
|
|
321
|
+
}[place.predicate];
|
|
322
|
+
if (phrase) push(subject, phrase, place.object);
|
|
323
|
+
}
|
|
324
|
+
for (const row of rows || []) {
|
|
325
|
+
if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
|
|
326
|
+
if (PLACEMENT_PREDICATES.has(row.predicate)) continue; // folded above
|
|
327
|
+
if (VIEW_EXCLUDED_PREDICATES.has(row.predicate)) continue;
|
|
328
|
+
const exit = EXIT_PREDICATE_RE.exec(row.predicate);
|
|
329
|
+
if (exit) { push(row.subject, `has an exit ${exit[1]} to the`, row.object); continue; }
|
|
330
|
+
if (row.predicate === "rdf:type") { push(row.subject, typePhrase(row.object), row.object); continue; }
|
|
331
|
+
if (row.predicate === "mgx:works-in") { push(row.subject, "works in the", row.object); continue; }
|
|
332
|
+
push(row.subject, row.predicate, row.object);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function worldDigest(prompt, { memoryDir, memory, rows, state, graph }) {
|
|
338
|
+
const view = worldDigestRows(rows, state);
|
|
339
|
+
const store = {
|
|
340
|
+
...COMPLETIONS_STORE,
|
|
341
|
+
readFactRows: () => view,
|
|
342
|
+
// World state lives in facts. Folded session blocks quote earlier
|
|
343
|
+
// TRANSCRIPTS of this same world, so with block retrieval on, a second
|
|
344
|
+
// session's look would echo stale room descriptions beside the current
|
|
345
|
+
// one. Retrieval is shaped to the graph source alone.
|
|
346
|
+
retrieveBlocks: async () => [],
|
|
347
|
+
};
|
|
348
|
+
const graphService = createCompletionsGraphAdapter(graph ?? null, memory, { store });
|
|
349
|
+
try {
|
|
350
|
+
const res = await generateCompletion(memoryDir, prompt, {
|
|
351
|
+
query: prompt, memory, graph: graph ?? undefined, graphService, store,
|
|
352
|
+
// Ashcombe-scale rooms carry more than the default 3 facts; the wider
|
|
353
|
+
// per-group cutoff keeps a correctness-bearing fact (the locked
|
|
354
|
+
// cabinet) from losing its digest slot to scenery.
|
|
355
|
+
maxSentencesPerGroup: 12,
|
|
356
|
+
});
|
|
357
|
+
if (res?.text && !res.declined) return res.text;
|
|
358
|
+
} catch { /* a digest failure falls to the honest floor below */ }
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---- command execution -------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
const answer = (text, note, { goal, miss = false } = {}) => ({
|
|
365
|
+
text, note, lane: "game-answer", miss, ...(goal ? { goal } : {}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
369
|
+
const memory = await loadMemory(memoryDir);
|
|
370
|
+
const rows = readFactRows(memory);
|
|
371
|
+
const state = foldWorldState(rows);
|
|
372
|
+
const here = state.placements.get("player")?.object ?? null;
|
|
373
|
+
const noteFor = (detail) => `ADVENTURE — ${detail}`;
|
|
374
|
+
|
|
375
|
+
if (cmd.residue?.length) {
|
|
376
|
+
return answer(
|
|
377
|
+
`I don't know the word "${cmd.residue[0]}" — it isn't in my vocabulary.`,
|
|
378
|
+
noteFor(`the imperative parsed but "${cmd.residue[0]}" is undeclared; honest decline, never a guess`),
|
|
379
|
+
{ miss: true },
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (!here) {
|
|
383
|
+
return answer(
|
|
384
|
+
"the world has no written player position — reload it with its opening line.",
|
|
385
|
+
noteFor("no player placement fact; declined"),
|
|
386
|
+
{ miss: true },
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (cmd.verb === "look") {
|
|
391
|
+
const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph });
|
|
392
|
+
return answer(
|
|
393
|
+
digest ?? `you are in the ${here}. Nothing more about it is written down yet.`,
|
|
394
|
+
noteFor(`look — an extractive completions digest over the current world facts mentioning "${here}"`),
|
|
395
|
+
{ goal: `look around the ${here}` },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const families = actionFamilies(readRuleRows(memory));
|
|
400
|
+
const family = families.get(cmd.verb);
|
|
401
|
+
if (!family) {
|
|
402
|
+
return answer(
|
|
403
|
+
`this world doesn't teach the verb "${cmd.verb}" — its action family isn't loaded.`,
|
|
404
|
+
noteFor(`no "${cmd.verb}" action family in the store; honest decline`),
|
|
405
|
+
{ miss: true },
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const k = state.turnCount + 1;
|
|
409
|
+
const commit = async (facts, text, detail, goal, playerRoomAfter = here) => {
|
|
410
|
+
// The NPC pass rides every SUCCESSFUL state-changing command, in the same
|
|
411
|
+
// turn, after the player's own effect. Its schedule preconditions read
|
|
412
|
+
// the pre-command placements (an NPC's turn-gated move never depends on
|
|
413
|
+
// what the player just wrote); the observability lines read the player's
|
|
414
|
+
// post-command room, so walking into a room as an NPC arrives is seen.
|
|
415
|
+
const npcPass = runNpcPass({ rows, state, k, families, playerRoomAfter });
|
|
416
|
+
await writeWorldTurn(memoryDir, world, k, [...facts, ...npcPass.writes], cache);
|
|
417
|
+
const text2 = npcPass.lines.length ? `${text} ${npcPass.lines.join(" ")}` : text;
|
|
418
|
+
return answer(text2, noteFor(`${detail}; turn ${k} snapshots written through appendFacts${npcPass.writes.length ? `; NPC pass fired ${npcPass.writes.length} scheduled move(s)` : ""}`), { goal });
|
|
419
|
+
};
|
|
420
|
+
const object = cmd.object;
|
|
421
|
+
const place = object ? state.placements.get(object) ?? null : null;
|
|
422
|
+
|
|
423
|
+
if (cmd.verb === "go") {
|
|
424
|
+
const target = state.exits.get(here)?.get(cmd.direction);
|
|
425
|
+
if (!target) {
|
|
426
|
+
return answer(
|
|
427
|
+
`there's no exit ${cmd.direction} from the ${here}.`,
|
|
428
|
+
noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
|
|
429
|
+
{ miss: true },
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return commit(
|
|
433
|
+
[{ subject: `player@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
|
|
434
|
+
`you go ${cmd.direction}. Now in the ${target}.`,
|
|
435
|
+
`go — the taught "go" family fired; player moves ${here} -> ${target}`,
|
|
436
|
+
`move through the world (now in the ${target})`,
|
|
437
|
+
target,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (cmd.verb === "take") {
|
|
442
|
+
if (isTyped(rows, object, "room")) {
|
|
443
|
+
return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
|
|
444
|
+
}
|
|
445
|
+
if (carriedByPlayer(state, object)) {
|
|
446
|
+
return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
|
|
447
|
+
}
|
|
448
|
+
if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
|
|
449
|
+
return answer(
|
|
450
|
+
`the ${object} is fixed in place — it can't be taken.`,
|
|
451
|
+
noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
|
|
452
|
+
{ miss: true },
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
if (place && place.predicate === "mgx:currently-in" && place.object === here) {
|
|
456
|
+
return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
|
|
457
|
+
}
|
|
458
|
+
if (visibleRoomOf(object, { rows, state }) !== here) {
|
|
459
|
+
return answer(`I don't see a ${object} here.`, noteFor(`take — ${object} isn't visible in the ${here}; declined, hidden things stay hidden`), { miss: true });
|
|
460
|
+
}
|
|
461
|
+
return commit(
|
|
462
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: "player" }],
|
|
463
|
+
`you take the ${object}.`,
|
|
464
|
+
`take — the taught "take" family fired; ${object} is now carried`,
|
|
465
|
+
`carry the ${object}`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (cmd.verb === "drop" || cmd.verb === "give") {
|
|
470
|
+
if (!carriedByPlayer(state, object)) {
|
|
471
|
+
return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
|
|
472
|
+
}
|
|
473
|
+
if (cmd.verb === "drop") {
|
|
474
|
+
return commit(
|
|
475
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
|
|
476
|
+
`you drop the ${object} in the ${here}.`,
|
|
477
|
+
`drop — the taught "drop" family fired; ${object} rests in the ${here}`,
|
|
478
|
+
`set the ${object} down`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
const receiver = cmd.indirectObject;
|
|
482
|
+
if (!isTyped(rows, receiver, "person") || state.placements.get(receiver)?.object !== here) {
|
|
483
|
+
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't a person in the ${here}; precondition declined by name`), { miss: true });
|
|
484
|
+
}
|
|
485
|
+
return commit(
|
|
486
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
|
|
487
|
+
`you give the ${object} to the ${receiver}.`,
|
|
488
|
+
`give — the taught "give" family fired; the ${receiver} holds the ${object}`,
|
|
489
|
+
`hand the ${object} over`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// open / unlock / close — the container verbs. Their families are
|
|
494
|
+
// signature-only (no shipped rule shape for a datatype effect yet), so the
|
|
495
|
+
// state writes are the closed container vocabulary below.
|
|
496
|
+
const presentHere = place && place.predicate !== "mgx:hidden-in" && place.predicate !== "mgx:currently-in" && place.object === here;
|
|
497
|
+
if (!presentHere) {
|
|
498
|
+
return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
|
|
499
|
+
}
|
|
500
|
+
if (!isContainer(rows, object)) {
|
|
501
|
+
return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
|
|
502
|
+
}
|
|
503
|
+
const open = !!state.openness.get(object)?.open;
|
|
504
|
+
|
|
505
|
+
if (cmd.verb === "open") {
|
|
506
|
+
if (place.predicate === "mgx:stands-locked-in") {
|
|
507
|
+
return answer(`the ${object} is locked.`, noteFor(`open — ${object} stands locked; precondition declined by name`), { miss: true });
|
|
508
|
+
}
|
|
509
|
+
if (open) {
|
|
510
|
+
return answer(`the ${object} is already open.`, noteFor("open — already open; declined"), { miss: true });
|
|
511
|
+
}
|
|
512
|
+
const revealed = [...state.placements]
|
|
513
|
+
.filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
|
|
514
|
+
.map(([thing]) => thing)
|
|
515
|
+
.sort();
|
|
516
|
+
return commit(
|
|
517
|
+
[
|
|
518
|
+
{ subject: `${object}@turn${k}`, predicate: "mgx:is-open", object: "true" },
|
|
519
|
+
...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
|
|
520
|
+
],
|
|
521
|
+
revealed.length
|
|
522
|
+
? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
|
|
523
|
+
: `you open the ${object}. It's empty.`,
|
|
524
|
+
`open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""}`,
|
|
525
|
+
`open the ${object}`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (cmd.verb === "close") {
|
|
530
|
+
if (!open) {
|
|
531
|
+
return answer(`the ${object} isn't open.`, noteFor("close — not open; declined"), { miss: true });
|
|
532
|
+
}
|
|
533
|
+
return commit(
|
|
534
|
+
[{ subject: `${object}@turn${k}`, predicate: "mgx:is-open", object: "false" }],
|
|
535
|
+
`you close the ${object}.`,
|
|
536
|
+
`close — ${object} closes`,
|
|
537
|
+
`close the ${object}`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// unlock
|
|
542
|
+
if (place.predicate !== "mgx:stands-locked-in") {
|
|
543
|
+
return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
|
|
544
|
+
}
|
|
545
|
+
if (!cmd.instrument) {
|
|
546
|
+
return answer(
|
|
547
|
+
`unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
|
|
548
|
+
noteFor("unlock — no instrument named; asked, never guessed"),
|
|
549
|
+
{ miss: true },
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
|
|
553
|
+
if (!required) {
|
|
554
|
+
return answer(
|
|
555
|
+
`nothing in this world says what unlocks the ${object}.`,
|
|
556
|
+
noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
|
|
557
|
+
{ miss: true },
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (!carriedByPlayer(state, cmd.instrument)) {
|
|
561
|
+
return answer(
|
|
562
|
+
`you're not carrying the ${cmd.instrument}.`,
|
|
563
|
+
noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
|
|
564
|
+
{ miss: true },
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (cmd.instrument !== required) {
|
|
568
|
+
return answer(
|
|
569
|
+
`the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
|
|
570
|
+
noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
|
|
571
|
+
{ miss: true },
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
return commit(
|
|
575
|
+
[{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
|
|
576
|
+
`you unlock the ${object} with the ${required}.`,
|
|
577
|
+
`unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
|
|
578
|
+
`unlock the ${object}`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function inventoryAnswer({ memoryDir, graph }) {
|
|
583
|
+
const memory = await loadMemory(memoryDir);
|
|
584
|
+
const rows = readFactRows(memory);
|
|
585
|
+
const state = foldWorldState(rows);
|
|
586
|
+
const carried = [...state.placements]
|
|
587
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === "player")
|
|
588
|
+
.map(([thing]) => thing)
|
|
589
|
+
.sort();
|
|
590
|
+
if (!carried.length) {
|
|
591
|
+
return answer(
|
|
592
|
+
"you aren't carrying anything.",
|
|
593
|
+
"ADVENTURE — inventory: no fact places anything with the player; the honest empty answer",
|
|
594
|
+
{ goal: "check what you carry" },
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const digest = await worldDigest("player", { memoryDir, memory, rows, state, graph });
|
|
598
|
+
return answer(
|
|
599
|
+
digest ?? `you are carrying the ${carried.join(", the ")}.`,
|
|
600
|
+
"ADVENTURE — inventory: an extractive completions digest over the facts mentioning the player",
|
|
601
|
+
{ goal: "check what you carry" },
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---- the lane ----------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* The whole adventure lane for one turn: the opening moves, the stop command,
|
|
609
|
+
* and (with a world live) the one-at-a-time declines across the shared plan
|
|
610
|
+
* slot. Returns { text, lane, note, goal? } or null when the turn is not the
|
|
611
|
+
* adventure's to answer — a mid-game aside falls through to the ordinary
|
|
612
|
+
* lanes, world untouched. `isPlanFrameLine` is chat's own plan-frame
|
|
613
|
+
* recognizer, injected so the two lanes can never disagree about what a plan
|
|
614
|
+
* frame is.
|
|
615
|
+
*/
|
|
616
|
+
export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false }) {
|
|
617
|
+
const slot = planHolder?.state ?? null;
|
|
618
|
+
const adventure = slot?.adventure ?? null;
|
|
619
|
+
const opening = matchAdventureOpening(line);
|
|
620
|
+
|
|
621
|
+
if (!adventure) {
|
|
622
|
+
if (!opening) return null;
|
|
623
|
+
if (slot?.game) {
|
|
624
|
+
return {
|
|
625
|
+
text: 'a guess-the-number game is active — say "I give up" to end it, then start the adventure.',
|
|
626
|
+
lane: "game-inform",
|
|
627
|
+
note: "ADVENTURE — an opening arrived mid-number-game; the slot holds one thing at a time",
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
const planActive = slot && !slot.done
|
|
631
|
+
&& ((Array.isArray(slot.goals) && slot.goals.length) || (Array.isArray(slot.actions) && slot.actions.length));
|
|
632
|
+
if (planActive) {
|
|
633
|
+
return {
|
|
634
|
+
text: 'a plan is in progress — finish it or say "forget the goal" before we start the adventure.',
|
|
635
|
+
lane: "game-inform",
|
|
636
|
+
note: "ADVENTURE — an opening arrived while a plan frame is active; the slot holds one thing at a time",
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return openAdventure(opening, { planHolder, memoryDir, sessionId, env, cache });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// A world is live.
|
|
643
|
+
if (opening) {
|
|
644
|
+
return {
|
|
645
|
+
text: `we're already playing ${spokenNameOf(adventure.world)} — say "stop playing" to end it first.`,
|
|
646
|
+
lane: "game-inform",
|
|
647
|
+
note: "ADVENTURE — an opening arrived mid-adventure; declined, the running world stands",
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
if (ADVENTURE_STOP_RE.test(line)) {
|
|
651
|
+
planHolder.state = null;
|
|
652
|
+
return {
|
|
653
|
+
text: `OK — the adventure ends here. Everything the world wrote stays remembered; say "play ${spokenNameOf(adventure.world)}" to pick it back up.`,
|
|
654
|
+
lane: "game-inform",
|
|
655
|
+
note: "ADVENTURE — the adventure ended on request; the world's facts stay in the store",
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
if (isPlanFrameLine(line)) {
|
|
659
|
+
return {
|
|
660
|
+
text: 'an adventure is running — say "stop playing" to end it, then set your goal.',
|
|
661
|
+
lane: "game-inform",
|
|
662
|
+
note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph });
|
|
666
|
+
const cmd = parseImperative(line, lexicon ?? undefined);
|
|
667
|
+
if (cmd) return runWorldCommand(cmd, { world: adventure.world, memoryDir, env, graph, cache });
|
|
668
|
+
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
669
|
+
}
|