@polycode-projects/the-mechanical-code-talker 2.7.11 → 2.7.13
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/worlds/manifest.json +6 -6
- package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
- package/corpus/worlds/src/ashcombe-hall.jsonl +6 -0
- package/package.json +1 -1
- package/src/adapters/memory/core.mjs +42 -6
- package/src/adapters/memory/shacl.mjs +8 -6
- package/src/domain/domain.mjs +51 -6
- package/src/domain/spider-fly-world.mjs +13 -0
- package/src/domain/sprite-map.mjs +49 -1
- package/src/services/adventure-autoplay.mjs +192 -0
- package/src/services/adventure-viz.mjs +262 -0
- package/src/services/adventure.mjs +103 -35
- package/src/services/chat.mjs +19 -1
- package/src/services/spider-fly-viz.mjs +39 -9
- package/src/services/spider-fly.mjs +236 -68
- package/src/surfaces/web/adventure-browser-entry.mjs +88 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +41 -11
- package/src/surfaces/web/spider-fly-browser-entry.mjs +10 -8
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"generated": "by scripts/build-worlds-pack.mjs from corpus/worlds/src/",
|
|
4
4
|
"counts": {
|
|
5
5
|
"worlds": 2,
|
|
6
|
-
"facts":
|
|
7
|
-
"rules":
|
|
6
|
+
"facts": 528,
|
|
7
|
+
"rules": 19
|
|
8
8
|
},
|
|
9
9
|
"budgets": {
|
|
10
10
|
"sourceBytesPerWorld": 131072,
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"sources": [
|
|
15
15
|
{
|
|
16
16
|
"file": "src/ashcombe-hall.jsonl",
|
|
17
|
-
"bytes":
|
|
18
|
-
"sha256": "
|
|
17
|
+
"bytes": 8458,
|
|
18
|
+
"sha256": "79332b0700a8c8289ca4d8b5a9cf3dcff4c869fb1b45a72dd291617f61bdd184"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"file": "src/spider-fly.jsonl",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"file": "shards/ashcombe-hall.jsonl.gz",
|
|
34
|
-
"bytes":
|
|
35
|
-
"sha256": "
|
|
34
|
+
"bytes": 963,
|
|
35
|
+
"sha256": "d44e229d8f30dc43e8801443d426c315b43a6758adb11ad332c84e7108268746"
|
|
36
36
|
},
|
|
37
37
|
{
|
|
38
38
|
"file": "shards/spider-fly.jsonl.gz",
|
|
Binary file
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
{"world":"ashcombe-hall","kind":"fact","subject":"portrait","predicate":"mgx:fixed-in","object":"drawing-room"}
|
|
34
34
|
{"world":"ashcombe-hall","kind":"fact","subject":"key","predicate":"mgx:hidden-in","object":"portrait"}
|
|
35
35
|
{"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:hidden-in","object":"cabinet"}
|
|
36
|
+
{"world":"ashcombe-hall","kind":"fact","subject":"letter","predicate":"mgx:is-objective","object":"true"}
|
|
36
37
|
{"world":"ashcombe-hall","kind":"fact","subject":"housekeeper","predicate":"mgx:currently-in","object":"kitchen"}
|
|
37
38
|
{"world":"ashcombe-hall","kind":"fact","subject":"cook","predicate":"mgx:currently-in","object":"kitchen"}
|
|
38
39
|
{"world":"ashcombe-hall","kind":"fact","subject":"butler","predicate":"mgx:currently-in","object":"drawing-room"}
|
|
@@ -60,5 +61,10 @@
|
|
|
60
61
|
{"world":"ashcombe-hall","kind":"rule","name":"give","ruleKind":"action-signature","slots":{"subjectClass":"portable","targetClass":"person"}}
|
|
61
62
|
{"world":"ashcombe-hall","kind":"rule","name":"give","ruleKind":"action-effect","slots":{"predicate":"located-in","subjectRole":"subject","objectRole":"target"}}
|
|
62
63
|
{"world":"ashcombe-hall","kind":"rule","name":"open","ruleKind":"action-signature","slots":{"subjectClass":"adventurer","targetClass":"furniture"}}
|
|
64
|
+
{"world":"ashcombe-hall","kind":"rule","name":"open","ruleKind":"action-precond","slots":{"shape":"fact-value","predicate":"stands-locked-in","role":"target","scope":"any","negate":"true"}}
|
|
65
|
+
{"world":"ashcombe-hall","kind":"rule","name":"open","ruleKind":"action-precond","slots":{"shape":"fact-value","predicate":"is-open","role":"target","scope":"any","value":"true","negate":"true"}}
|
|
66
|
+
{"world":"ashcombe-hall","kind":"rule","name":"open","ruleKind":"action-effect","slots":{"predicate":"is-open","subjectRole":"target","value":"true"}}
|
|
63
67
|
{"world":"ashcombe-hall","kind":"rule","name":"unlock","ruleKind":"action-signature","slots":{"subjectClass":"adventurer","targetClass":"furniture"}}
|
|
64
68
|
{"world":"ashcombe-hall","kind":"rule","name":"close","ruleKind":"action-signature","slots":{"subjectClass":"adventurer","targetClass":"furniture"}}
|
|
69
|
+
{"world":"ashcombe-hall","kind":"rule","name":"close","ruleKind":"action-precond","slots":{"shape":"fact-value","predicate":"is-open","role":"target","scope":"any","value":"true"}}
|
|
70
|
+
{"world":"ashcombe-hall","kind":"rule","name":"close","ruleKind":"action-effect","slots":{"predicate":"is-open","subjectRole":"target","value":"false"}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.13",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
@@ -1287,10 +1287,18 @@ const RULE_SLOT_SPEC = {
|
|
|
1287
1287
|
[RULE_KIND_ACTION_PRECOND]: [
|
|
1288
1288
|
["shape", "mgx:ruleActionPrecondShape"], ["predicate", "mgx:ruleActionPrecondPredicate"],
|
|
1289
1289
|
["role", "mgx:ruleActionPrecondRole"], ["scope", "mgx:ruleActionPrecondScope"],
|
|
1290
|
+
// value/negate: the "fact-value" shape's literal-match and negation
|
|
1291
|
+
// slots. Optional (RULE_SLOT_OPTIONAL below) — no-incoming/comparator
|
|
1292
|
+
// preconds never set them.
|
|
1293
|
+
["value", "mgx:ruleActionPrecondValue"], ["negate", "mgx:ruleActionPrecondNegate"],
|
|
1290
1294
|
],
|
|
1291
1295
|
[RULE_KIND_ACTION_EFFECT]: [
|
|
1292
1296
|
["predicate", "mgx:ruleActionEffectPredicate"], ["subjectRole", "mgx:ruleActionEffectSubject"],
|
|
1293
|
-
|
|
1297
|
+
// objectRole/value: exactly one of these two must be set (enforced
|
|
1298
|
+
// below, not by this per-slot spec) — a role-bound effect (Hanoi's
|
|
1299
|
+
// "rest-on") supplies objectRole, a literal datatype effect (Ashcombe's
|
|
1300
|
+
// is-open = "true") supplies value instead.
|
|
1301
|
+
["objectRole", "mgx:ruleActionEffectObject"], ["value", "mgx:ruleActionEffectValue"],
|
|
1294
1302
|
],
|
|
1295
1303
|
// "the <left> may not be with the <right> without the <guard>" — each slot
|
|
1296
1304
|
// names a class whose sole member src/domain/domain.mjs resolves at compile time.
|
|
@@ -1300,25 +1308,47 @@ const RULE_SLOT_SPEC = {
|
|
|
1300
1308
|
],
|
|
1301
1309
|
};
|
|
1302
1310
|
|
|
1311
|
+
// Slots a kind's RULE_SLOT_SPEC lists but does NOT require non-empty — the
|
|
1312
|
+
// literal-effect/fact-value extension's slots, added after the original
|
|
1313
|
+
// four-kind design. A pre-existing rule never sets them, so it reads back
|
|
1314
|
+
// with these keys simply absent (readRuleRows defaults an absent slot to
|
|
1315
|
+
// ""), the same "not supplied" signal domain.mjs's compileDomain gives an
|
|
1316
|
+
// explicit "" — see optionalTerm there.
|
|
1317
|
+
const RULE_SLOT_OPTIONAL = {
|
|
1318
|
+
[RULE_KIND_ACTION_PRECOND]: new Set(["value", "negate"]),
|
|
1319
|
+
[RULE_KIND_ACTION_EFFECT]: new Set(["objectRole", "value"]),
|
|
1320
|
+
};
|
|
1321
|
+
|
|
1303
1322
|
// Content-addressed over (kind, name, ...slots in RULE_SLOT_SPEC order),
|
|
1304
1323
|
// mirroring factIdFor's NUL-delimited discipline: identical rules upsert,
|
|
1305
1324
|
// different ones coexist. For 2-slot kinds the joined string is byte-identical
|
|
1306
1325
|
// to the historical (kind, name, slot1, slot2) template, so pre-existing rule
|
|
1307
|
-
// ids never change (pinned by test/adapters/memory-rules-action.test.mjs)
|
|
1326
|
+
// ids never change (pinned by test/adapters/memory-rules-action.test.mjs);
|
|
1327
|
+
// action-precond/action-effect ids shifted when their optional slots joined
|
|
1328
|
+
// the spec above (their own round-trip test asserts dedup behavior, never a
|
|
1329
|
+
// specific hash, so this was never a promise for those two kinds).
|
|
1308
1330
|
const ruleIdFor = (kind, name, slotValues) => `rule:${fnv1aHex([kind, name, ...slotValues].join("\0"))}`;
|
|
1309
1331
|
|
|
1310
1332
|
/** Append one taught RULE — a sibling of appendFact storing a `Rule`
|
|
1311
1333
|
* individual, same upsert/provenance/trust/SHACL discipline (neither
|
|
1312
1334
|
* pipeline ever checks `individual.class`). `slots` is the matching
|
|
1313
|
-
* per-kind object (RULE_SLOT_SPEC above)
|
|
1335
|
+
* per-kind object (RULE_SLOT_SPEC above); a slot named in RULE_SLOT_OPTIONAL
|
|
1336
|
+
* may be omitted. Returns { id }. */
|
|
1314
1337
|
export async function appendRule(dir, { name, kind, slots, provenance = "", createdAt = "" } = {}) {
|
|
1315
1338
|
const spec = RULE_SLOT_SPEC[kind];
|
|
1316
1339
|
if (!spec) throw new Error(`a rule kind must be one of ${RULE_KINDS.join(", ")}, got ${JSON.stringify(kind)}`);
|
|
1317
1340
|
const n = normFactTerm(name);
|
|
1318
1341
|
if (!n) throw new Error("a rule needs a name");
|
|
1342
|
+
const optional = RULE_SLOT_OPTIONAL[kind] || new Set();
|
|
1319
1343
|
const slotValues = spec.map(([slotKey]) => normFactTerm(slots?.[slotKey]));
|
|
1320
|
-
|
|
1321
|
-
|
|
1344
|
+
const missing = spec.filter(([slotKey], i) => !optional.has(slotKey) && !slotValues[i]);
|
|
1345
|
+
if (missing.length) {
|
|
1346
|
+
throw new Error(`a ${kind} rule needs ${missing.map(([slotKey]) => slotKey).join(" + ")}`);
|
|
1347
|
+
}
|
|
1348
|
+
if (kind === RULE_KIND_ACTION_EFFECT) {
|
|
1349
|
+
const objectRole = slotValues[spec.findIndex(([k]) => k === "objectRole")];
|
|
1350
|
+
const value = slotValues[spec.findIndex(([k]) => k === "value")];
|
|
1351
|
+
if (!objectRole && !value) throw new Error(`a ${kind} rule needs an objectRole or a value`);
|
|
1322
1352
|
}
|
|
1323
1353
|
const id = ruleIdFor(kind, n, slotValues);
|
|
1324
1354
|
const label = labelOf(`${n} = ${kind}(${slotValues.join(", ")})`);
|
|
@@ -1336,7 +1366,13 @@ export async function appendRule(dir, { name, kind, slots, provenance = "", crea
|
|
|
1336
1366
|
{ prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
|
|
1337
1367
|
{ prop: RULE_NAME_PROP, key: "ruleName", value: n },
|
|
1338
1368
|
{ prop: RULE_KIND_PROP, key: "ruleKind", value: kind },
|
|
1339
|
-
|
|
1369
|
+
// An optional slot left empty stores no attribute at all — the same
|
|
1370
|
+
// "never supplied" shape a pre-extension Rule individual already has,
|
|
1371
|
+
// rather than a wasted always-"" one.
|
|
1372
|
+
...spec
|
|
1373
|
+
.map(([slotKey, prop], i) => ({ prop, key: slotKey, value: slotValues[i], skip: !slotValues[i] && optional.has(slotKey) }))
|
|
1374
|
+
.filter((attr) => !attr.skip)
|
|
1375
|
+
.map(({ skip, ...attr }) => attr),
|
|
1340
1376
|
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
1341
1377
|
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
1342
1378
|
],
|
|
@@ -11,9 +11,13 @@ const RULE_KINDS = new Set([
|
|
|
11
11
|
"action-signature", "action-precond", "action-effect", "action-constraint",
|
|
12
12
|
]);
|
|
13
13
|
|
|
14
|
-
// Mirrors core.mjs's own (unexported) RULE_SLOT_SPEC
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
14
|
+
// Mirrors the REQUIRED subset of core.mjs's own (unexported) RULE_SLOT_SPEC —
|
|
15
|
+
// kept in sync by hand. Two kinds there also carry optional slots
|
|
16
|
+
// (RULE_SLOT_OPTIONAL: action-precond's value/negate, action-effect's
|
|
17
|
+
// objectRole/value) that deliberately do NOT appear here: this gate only
|
|
18
|
+
// checks genuine malformation (the comment above), and "at least one of
|
|
19
|
+
// objectRole/value" is a joint constraint core.mjs's own appendRule already
|
|
20
|
+
// enforces at write time, not a per-slot presence check this shape can express.
|
|
17
21
|
const RULE_SLOT_PROPS = {
|
|
18
22
|
compose2: ["mgx:ruleBase1", "mgx:ruleBase2"],
|
|
19
23
|
filter: ["mgx:ruleBase1", "mgx:ruleFilterProperty"],
|
|
@@ -23,9 +27,7 @@ const RULE_SLOT_PROPS = {
|
|
|
23
27
|
"mgx:ruleActionPrecondShape", "mgx:ruleActionPrecondPredicate",
|
|
24
28
|
"mgx:ruleActionPrecondRole", "mgx:ruleActionPrecondScope",
|
|
25
29
|
],
|
|
26
|
-
"action-effect": [
|
|
27
|
-
"mgx:ruleActionEffectPredicate", "mgx:ruleActionEffectSubject", "mgx:ruleActionEffectObject",
|
|
28
|
-
],
|
|
30
|
+
"action-effect": ["mgx:ruleActionEffectPredicate", "mgx:ruleActionEffectSubject"],
|
|
29
31
|
"action-constraint": [
|
|
30
32
|
"mgx:ruleActionConstraintLeft", "mgx:ruleActionConstraintRight", "mgx:ruleActionConstraintGuard",
|
|
31
33
|
],
|
package/src/domain/domain.mjs
CHANGED
|
@@ -19,6 +19,15 @@ const attachPrefix = (predicate) => {
|
|
|
19
19
|
return p.includes(":") ? p : `mgx:${p}`;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/** A genuinely-supplied optional slot (a literal precond/effect value): `""`,
|
|
23
|
+
* `null` and `undefined` all read as "not supplied", the same signal a
|
|
24
|
+
* never-taught slot and a readRuleRows-defaulted `""` both give. */
|
|
25
|
+
const optionalTerm = (value) => {
|
|
26
|
+
if (value == null) return undefined;
|
|
27
|
+
const t = normTerm(value);
|
|
28
|
+
return t === "" ? undefined : t;
|
|
29
|
+
};
|
|
30
|
+
|
|
22
31
|
const rowSort = (a, b) =>
|
|
23
32
|
a.subject.localeCompare(b.subject) ||
|
|
24
33
|
a.predicate.localeCompare(b.predicate) ||
|
|
@@ -55,17 +64,32 @@ export function compileDomain(factRows, ruleRows) {
|
|
|
55
64
|
targetClass: normTerm(slots.targetClass),
|
|
56
65
|
});
|
|
57
66
|
} else if (rule.kind === "action-precond") {
|
|
67
|
+
// value/negate are optional: absent in every no-incoming/comparator row
|
|
68
|
+
// taught so far, so their key is omitted rather than written as "" —
|
|
69
|
+
// an omitted key keeps a pre-existing precond's JSON.stringify sort
|
|
70
|
+
// form (and its precondHolds behavior) byte-identical to before this
|
|
71
|
+
// shape existed. normFactTerm/readRuleRows read a genuinely absent
|
|
72
|
+
// slot back as "", the same signal an explicit "" would give.
|
|
73
|
+
const value = optionalTerm(slots.value);
|
|
74
|
+
const negate = String(slots.negate) === "true" ? true : undefined;
|
|
58
75
|
family.preconds.push({
|
|
59
76
|
shape: normTerm(slots.shape),
|
|
60
77
|
predicate: attachPrefix(slots.predicate),
|
|
61
78
|
role: normTerm(slots.role),
|
|
62
79
|
scope: normTerm(slots.scope),
|
|
80
|
+
...(value !== undefined ? { value } : {}),
|
|
81
|
+
...(negate !== undefined ? { negate } : {}),
|
|
63
82
|
});
|
|
64
83
|
} else if (rule.kind === "action-effect") {
|
|
84
|
+
// value is the literal-effect alternative to objectRole (a datatype
|
|
85
|
+
// write, e.g. mgx:is-open = "true", rather than a role binding) — same
|
|
86
|
+
// omit-when-absent discipline as the precond fields above.
|
|
87
|
+
const value = optionalTerm(slots.value);
|
|
65
88
|
family.effects.push({
|
|
66
89
|
predicate: attachPrefix(slots.predicate),
|
|
67
90
|
subjectRole: normTerm(slots.subjectRole),
|
|
68
91
|
objectRole: normTerm(slots.objectRole),
|
|
92
|
+
...(value !== undefined ? { value } : {}),
|
|
69
93
|
});
|
|
70
94
|
} else if (rule.kind === "action-constraint") {
|
|
71
95
|
family.constraints.push({
|
|
@@ -111,7 +135,11 @@ export function compileDomain(factRows, ruleRows) {
|
|
|
111
135
|
};
|
|
112
136
|
for (const action of actions) {
|
|
113
137
|
for (const effect of action.effects) {
|
|
114
|
-
|
|
138
|
+
// A literal-valued effect (effect.value set) has no objectRole to bind
|
|
139
|
+
// — the literal IS the object, so objectRole (unused, "" when the
|
|
140
|
+
// teaching row never set it) never enters the class-membership check.
|
|
141
|
+
const roles = effect.value !== undefined ? [effect.subjectRole] : [effect.subjectRole, effect.objectRole];
|
|
142
|
+
for (const role of roles) {
|
|
115
143
|
if (role !== "subject" && role !== "target") requireSoleMember(role, `an effect role of "${action.name}"`);
|
|
116
144
|
}
|
|
117
145
|
}
|
|
@@ -187,7 +215,13 @@ export function stateKeyFor(state) {
|
|
|
187
215
|
const precondApplies = (precond, target, domain) =>
|
|
188
216
|
precond.scope === "any" || (domain.classMembers[precond.scope] || []).includes(target);
|
|
189
217
|
|
|
190
|
-
|
|
218
|
+
/** A precondition on a fact ABOUT the role term itself (roleTerm as subject),
|
|
219
|
+
* as opposed to "no-incoming"'s fact pointing AT the role term (roleTerm as
|
|
220
|
+
* object). Datatype/state checks — a lock, an open/closed flag — read this
|
|
221
|
+
* way: "does (roleTerm, predicate, value) exist", with `value` an existence
|
|
222
|
+
* wildcard when omitted and `negate` flipping the sense ("must NOT exist"
|
|
223
|
+
* covers "must not be locked", "must not already be open"). */
|
|
224
|
+
export function precondHolds(precond, subject, target, state, domain) {
|
|
191
225
|
const roleTerm = precond.role === "target" ? target : subject;
|
|
192
226
|
if (precond.shape === "no-incoming") {
|
|
193
227
|
return !state.some((r) => r.predicate === precond.predicate && r.object === roleTerm);
|
|
@@ -198,13 +232,21 @@ function precondHolds(precond, subject, target, state, domain) {
|
|
|
198
232
|
return domain.ordering.some((r) =>
|
|
199
233
|
r.subject === left && r.predicate === precond.predicate && r.object === right);
|
|
200
234
|
}
|
|
235
|
+
if (precond.shape === "fact-value") {
|
|
236
|
+
const exists = state.some((r) => r.subject === roleTerm && r.predicate === precond.predicate
|
|
237
|
+
&& (precond.value == null || r.object === precond.value));
|
|
238
|
+
return precond.negate ? !exists : exists;
|
|
239
|
+
}
|
|
201
240
|
return false;
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
/** Ground an effect/constraint role word: "subject"/"target" bind the
|
|
205
244
|
* grounding pair; any other word is class-bound and binds the class's sole
|
|
206
|
-
* member — its companion semantics (compileDomain guarantees exactly one).
|
|
207
|
-
|
|
245
|
+
* member — its companion semantics (compileDomain guarantees exactly one).
|
|
246
|
+
* Exported so a caller consulting one grounded precond/effect directly,
|
|
247
|
+
* rather than running a full movesFromRules search, can still resolve an
|
|
248
|
+
* effect's subject/object the same way the planner does. */
|
|
249
|
+
export const roleBinding = (role, subject, target, domain) => {
|
|
208
250
|
if (role === "subject") return subject;
|
|
209
251
|
if (role === "target") return target;
|
|
210
252
|
return (domain.classMembers[role] || [])[0];
|
|
@@ -213,12 +255,15 @@ const roleBinding = (role, subject, target, domain) => {
|
|
|
213
255
|
const positionIn = (rows, term, predicate) =>
|
|
214
256
|
rows.find((r) => r.subject === term && r.predicate === predicate)?.object;
|
|
215
257
|
|
|
216
|
-
function applyEffects(effects, subject, target, state, domain) {
|
|
258
|
+
export function applyEffects(effects, subject, target, state, domain) {
|
|
217
259
|
let rows = state;
|
|
218
260
|
let changed = false;
|
|
219
261
|
for (const effect of effects) {
|
|
220
262
|
const effSubject = roleBinding(effect.subjectRole, subject, target, domain);
|
|
221
|
-
|
|
263
|
+
// A literal-valued effect (a taught datatype flag) writes its value
|
|
264
|
+
// directly; a role-valued effect resolves objectRole through the
|
|
265
|
+
// grounding pair / class-bound member instead.
|
|
266
|
+
const effObject = effect.value !== undefined ? effect.value : roleBinding(effect.objectRole, subject, target, domain);
|
|
222
267
|
const already = rows.some((r) =>
|
|
223
268
|
r.subject === effSubject && r.predicate === effect.predicate && r.object === effObject);
|
|
224
269
|
if (already) continue;
|
|
@@ -16,6 +16,19 @@ export const GRID_SIZE = 10;
|
|
|
16
16
|
export const WEB_HOME = Object.freeze({ x: 2, y: 2 });
|
|
17
17
|
export const WEB_RADIUS = 1;
|
|
18
18
|
|
|
19
|
+
// A spider-built dynamic web (src/services/spider-fly.mjs's hasActiveWebAt)
|
|
20
|
+
// stays active for this many turns past the turn it was built, mirroring the
|
|
21
|
+
// static home zone's own always-on web without needing separate code paths.
|
|
22
|
+
export const WEB_DURATION_TURNS = 10;
|
|
23
|
+
|
|
24
|
+
// Spider mass mirrors a fly's own (src/services/spider-fly.mjs's
|
|
25
|
+
// FLY_INITIAL_MASS/FLY_MASS_DECREMENT_PER_TURN): a spider starves like a fly
|
|
26
|
+
// does, and gains exactly a fly's remaining mass on an eat. Heavier starting
|
|
27
|
+
// mass than a single fly's worth on purpose — a spider that eats nothing for
|
|
28
|
+
// a while has some runway before starving.
|
|
29
|
+
export const SPIDER_INITIAL_MASS = 15;
|
|
30
|
+
export const SPIDER_MASS_DECREMENT_PER_TURN = 1;
|
|
31
|
+
|
|
19
32
|
export const cellId = (x, y) => `cell-${x}-${y}`;
|
|
20
33
|
|
|
21
34
|
const CELL_ID_RE = /^cell-(\d+)-(\d+)$/;
|
|
@@ -79,11 +79,53 @@ const ANIMAL_SVG =
|
|
|
79
79
|
+ '<g stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="7" y1="17" x2="7" y2="21"/><line x1="17" y1="17" x2="17" y2="21"/></g>'
|
|
80
80
|
+ "</svg>";
|
|
81
81
|
|
|
82
|
+
// ---- the adventure world's own class family (PLAN_GAMES_UPLIFT_V2.md Part B)
|
|
83
|
+
// — a room's floor plan, a piece of furniture, a portable, a person, the
|
|
84
|
+
// player's own adventurer sprite, and a container. Ashcombe Hall's own
|
|
85
|
+
// classes resolve to these directly (no ancestor walk needed, since the
|
|
86
|
+
// shipped world has no rdfs:subClassOf chain at all yet); the walk stays
|
|
87
|
+
// ready for a future world whose taxonomy goes deeper, the same "poodle IsA
|
|
88
|
+
// dog" mechanism spider-fly already exercises.
|
|
89
|
+
|
|
90
|
+
const ROOM_SVG =
|
|
91
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="1" fill="none" stroke="currentColor" stroke-width="1.6"/>'
|
|
92
|
+
+ '<path d="M9 21 L9 13 A3 3 0 0 1 15 13 L15 21" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
93
|
+
|
|
94
|
+
const FURNITURE_SVG =
|
|
95
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="3" rx="1" fill="currentColor"/>'
|
|
96
|
+
+ '<rect x="4" y="8" width="3" height="12" fill="currentColor"/>'
|
|
97
|
+
+ '<rect x="17" y="8" width="3" height="12" fill="currentColor"/></svg>';
|
|
98
|
+
|
|
99
|
+
const PORTABLE_SVG =
|
|
100
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="1.5" fill="currentColor"/>'
|
|
101
|
+
+ '<path d="M9 9 V6.5 A3 3 0 0 1 15 6.5 V9" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
102
|
+
|
|
103
|
+
const PERSON_SVG =
|
|
104
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
|
|
105
|
+
+ '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/></svg>';
|
|
106
|
+
|
|
107
|
+
// The adventurer is the player's own sprite — the same silhouette as `person`
|
|
108
|
+
// plus a small satchel, so the one individual the player controls always
|
|
109
|
+
// reads as visually distinct from the cast of NPCs sharing the room.
|
|
110
|
+
const ADVENTURER_SVG =
|
|
111
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="6.5" r="3.2" fill="currentColor"/>'
|
|
112
|
+
+ '<path d="M6 21 C6 15.5 8.7 13 12 13 C15.3 13 18 15.5 18 21 Z" fill="currentColor"/>'
|
|
113
|
+
+ '<rect x="15.5" y="13.5" width="4.5" height="5" rx="1" fill="currentColor" opacity="0.6"/></svg>';
|
|
114
|
+
|
|
115
|
+
const CONTAINER_SVG =
|
|
116
|
+
'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="10" width="18" height="10" rx="1.2" fill="currentColor"/>'
|
|
117
|
+
+ '<path d="M3 10 L5 5 H19 L21 10 Z" fill="currentColor" opacity="0.7"/>'
|
|
118
|
+
+ '<rect x="10.6" y="9.4" width="2.8" height="2.2" rx="0.4" fill="currentColor" opacity="0.35"/></svg>';
|
|
119
|
+
|
|
82
120
|
/** The sprite registry: class name (normFactTerm-normalized) -> inline SVG
|
|
83
121
|
* markup. `animal` is this world's declared root fallback (PLAN_SPIDER_FLY.md
|
|
84
122
|
* §7 names "animal"/"object"/"plant" as the family of possible roots — a
|
|
85
123
|
* spider-and-fly board only ever needs "animal") — resolveSpriteForClass
|
|
86
|
-
* falls back to it once the ancestor walk exhausts with no closer hit.
|
|
124
|
+
* falls back to it once the ancestor walk exhausts with no closer hit. The
|
|
125
|
+
* adventure world's classes (room/furniture/portable/person/adventurer/
|
|
126
|
+
* container) are a second, unrelated root family sharing the one flat table —
|
|
127
|
+
* a board only ever resolves classes from its own world, so the two never
|
|
128
|
+
* collide in practice. */
|
|
87
129
|
export const SPRITE_REGISTRY = Object.freeze({
|
|
88
130
|
spider: SPIDER_SVG,
|
|
89
131
|
fly: FLY_SVG,
|
|
@@ -91,6 +133,12 @@ export const SPRITE_REGISTRY = Object.freeze({
|
|
|
91
133
|
poodle: POODLE_SVG,
|
|
92
134
|
dog: DOG_SVG,
|
|
93
135
|
animal: ANIMAL_SVG,
|
|
136
|
+
room: ROOM_SVG,
|
|
137
|
+
furniture: FURNITURE_SVG,
|
|
138
|
+
portable: PORTABLE_SVG,
|
|
139
|
+
person: PERSON_SVG,
|
|
140
|
+
adventurer: ADVENTURER_SVG,
|
|
141
|
+
container: CONTAINER_SVG,
|
|
94
142
|
});
|
|
95
143
|
|
|
96
144
|
/** Every direct rdfs:subClassOf superclass of `term`, from a flat fact-row
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// adventure-autoplay.mjs — a sibling layer over adventure.mjs (never edited
|
|
2
|
+
// for this: this session's own in-flight rule-shape work owns that file)
|
|
3
|
+
// that plays a loaded world by itself: infer a goal from one generic marker
|
|
4
|
+
// fact, explore toward it, fetch it, and report an honest stall the moment no
|
|
5
|
+
// further move is justified. It is a CALLER of the existing command
|
|
6
|
+
// interpreter (adventureTurn), never a second one — every move it makes is
|
|
7
|
+
// the same plain command string a human types ("go north", "take letter"),
|
|
8
|
+
// executed through the identical turn a real chat session runs.
|
|
9
|
+
//
|
|
10
|
+
// The one hard constraint: auto-play only ever reasons over what it has
|
|
11
|
+
// actually seen. `exposedRoomIds` is the set of rooms this auto-play run has
|
|
12
|
+
// itself moved into (the opening room included from turn 0) — it is the sole
|
|
13
|
+
// caller making every move, so it always knows this, and threads the set
|
|
14
|
+
// forward turn to turn exactly like spider-fly.mjs threads its own agents
|
|
15
|
+
// shape. `exposedFacts` turns that set into the actual filtered view: a fact
|
|
16
|
+
// is exposed when its subject's CURRENT placement resolves into an exposed
|
|
17
|
+
// room, when the subject is the player, or when the fact IS the world's
|
|
18
|
+
// objective marker (told to the player unconditionally at turn 0, the same
|
|
19
|
+
// way the opening line's prose already is). A hidden object's reveal falls
|
|
20
|
+
// out of this automatically — no `mgx:hidden-in` special case is needed, the
|
|
21
|
+
// placement-resolution walk already returns null for anything still hidden.
|
|
22
|
+
//
|
|
23
|
+
// The objective marker is one new, generic world-pack fact,
|
|
24
|
+
// `{"subject":"letter","predicate":"mgx:is-objective","object":"true"}` —
|
|
25
|
+
// deliberately not hard-coded to any one world. A world that ships no such
|
|
26
|
+
// fact simply has nothing for this module to infer a goal toward.
|
|
27
|
+
import { findActionPath } from "../domain/planning.mjs";
|
|
28
|
+
import { loadMemory, readFactRows } from "../adapters/memory/core.mjs";
|
|
29
|
+
import { foldWorldState, adventureTurn } from "./adventure.mjs";
|
|
30
|
+
|
|
31
|
+
const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
|
|
32
|
+
const baseSubjectOf = (subject) => SNAPSHOT_RE.exec(subject)?.[1] ?? subject;
|
|
33
|
+
|
|
34
|
+
const isTypedRow = (rows, subject, type) =>
|
|
35
|
+
(rows || []).some((r) => r.subject === subject && r.predicate === "rdf:type" && r.object === type);
|
|
36
|
+
|
|
37
|
+
/** The room a subject's CURRENT placement resolves into — a room resolves to
|
|
38
|
+
* itself; an object placed directly in a room resolves to that room; an
|
|
39
|
+
* object one containment hop inside an OPEN container resolves to the
|
|
40
|
+
* container's own room; anything hidden, carried, or inside a closed
|
|
41
|
+
* container resolves to nothing (null). This mirrors adventure.mjs's own
|
|
42
|
+
* private `visibleRoomOf` exactly (that helper isn't exported, so the walk
|
|
43
|
+
* is re-derived here against the same `foldWorldState` placements map,
|
|
44
|
+
* never a second notion of visibility). */
|
|
45
|
+
function roomOfSubject(subject, rows, state) {
|
|
46
|
+
if (isTypedRow(rows, subject, "room")) return subject;
|
|
47
|
+
const place = state.placements.get(subject);
|
|
48
|
+
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
49
|
+
if (place.predicate === "mgx:currently-in" || isTypedRow(rows, place.object, "room")) return place.object;
|
|
50
|
+
const holder = place.object;
|
|
51
|
+
if (holder === "player") return null; // carried, not resolvable to a room
|
|
52
|
+
if (!state.openness.get(holder)?.open) return null;
|
|
53
|
+
const holderPlace = state.placements.get(holder);
|
|
54
|
+
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The subset of `allRows` this auto-play run may reason over, given the
|
|
59
|
+
* rooms it has actually moved into so far (`exposedRoomIds`). Pure — folds
|
|
60
|
+
* `allRows` itself rather than taking a precomputed state, so a caller never
|
|
61
|
+
* has to keep a fold in step with the exposure set by hand.
|
|
62
|
+
*/
|
|
63
|
+
export function exposedFacts(allRows, exposedRoomIds) {
|
|
64
|
+
const rows = allRows || [];
|
|
65
|
+
const state = foldWorldState(rows);
|
|
66
|
+
const exposed = new Set(exposedRoomIds || []);
|
|
67
|
+
return rows.filter((row) => {
|
|
68
|
+
if (baseSubjectOf(row.subject) === "player") return true;
|
|
69
|
+
if (row.predicate === "mgx:is-objective") return true;
|
|
70
|
+
const room = roomOfSubject(baseSubjectOf(row.subject), rows, state);
|
|
71
|
+
return room != null && exposed.has(room);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One hop per exposed room's own has-exit-* facts, sorted by direction name
|
|
76
|
+
* for deterministic tie-breaking — the applyActions closure findActionPath
|
|
77
|
+
* needs, built from the EXPOSED fold only, so a search over this graph can
|
|
78
|
+
* never plan through an edge auto-play hasn't itself walked into. */
|
|
79
|
+
function exposedExitApplyActions(exposedState) {
|
|
80
|
+
return (room) => {
|
|
81
|
+
const dirs = exposedState.exits.get(room);
|
|
82
|
+
if (!dirs) return [];
|
|
83
|
+
return [...dirs.keys()].sort().map((direction) => ({ action: direction, nextState: dirs.get(direction) }));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const unexposedExitsOf = (room, exposedState, exposed) =>
|
|
88
|
+
[...(exposedState.exits.get(room)?.entries() ?? [])].filter(([, target]) => !exposed.has(target));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One auto-play tick over a live, loaded adventure: fold the world, infer a
|
|
92
|
+
* goal from the one generic objective marker under the exposure constraint,
|
|
93
|
+
* and execute exactly the one move that goal implies through `adventureTurn`
|
|
94
|
+
* — the same public entry point a real chat turn calls. Returns
|
|
95
|
+
* `{ turn, goal, plan, done, stalled, exposedRoomIds }`, mirroring
|
|
96
|
+
* spider-fly.mjs's own tick shape: `plan` is the remaining multi-step route a
|
|
97
|
+
* `findActionPath` search found this tick (or null when the move was a
|
|
98
|
+
* single, immediate step — an adjacent unexposed exit, or a take), `goal` is
|
|
99
|
+
* a short line describing what this tick did, `done` means the objective is
|
|
100
|
+
* now carried, `stalled` means no move could be justified without guessing.
|
|
101
|
+
*/
|
|
102
|
+
export async function runAdventureAutoplayTick(memoryDir, opts = {}) {
|
|
103
|
+
const { exposedRoomIds, planHolder, sessionId = "", env = {}, graph = null, cache = null } = opts;
|
|
104
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
105
|
+
const state = foldWorldState(rows);
|
|
106
|
+
const here = state.placements.get("player")?.object ?? null;
|
|
107
|
+
const exposed = new Set(exposedRoomIds || []);
|
|
108
|
+
|
|
109
|
+
if (!here) {
|
|
110
|
+
return {
|
|
111
|
+
turn: state.turnCount, goal: "stalled — no player position is written for this world.",
|
|
112
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
exposed.add(here);
|
|
116
|
+
|
|
117
|
+
const runCommand = (line) => adventureTurn(line, { planHolder, memoryDir, sessionId, env, graph, cache });
|
|
118
|
+
|
|
119
|
+
const exposedRows = exposedFacts(rows, exposed);
|
|
120
|
+
const exposedState = foldWorldState(exposedRows);
|
|
121
|
+
const objectiveId = exposedRows.find((r) => r.predicate === "mgx:is-objective" && r.object === "true")?.subject ?? null;
|
|
122
|
+
|
|
123
|
+
// Win: the objective is already carried — checked against the FULL fold,
|
|
124
|
+
// since what the player itself carries is always self-evidently known,
|
|
125
|
+
// the same unconditional "OR the subject is player" exposure the marker
|
|
126
|
+
// fact itself gets (worldDigestRows shows carried items regardless of
|
|
127
|
+
// room visibility too — carrying was never gated on being seen).
|
|
128
|
+
const carried = objectiveId
|
|
129
|
+
&& state.placements.get(objectiveId)?.predicate === "mgx:located-in"
|
|
130
|
+
&& state.placements.get(objectiveId)?.object === "player";
|
|
131
|
+
if (objectiveId && carried) {
|
|
132
|
+
return {
|
|
133
|
+
turn: state.turnCount, goal: `carrying the ${objectiveId} — the adventure is won.`,
|
|
134
|
+
plan: [], done: true, stalled: false, exposedRoomIds: exposed,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const objectiveRoom = objectiveId ? roomOfSubject(objectiveId, exposedRows, exposedState) : null;
|
|
139
|
+
|
|
140
|
+
if (objectiveId && objectiveRoom) {
|
|
141
|
+
if (objectiveRoom === here) {
|
|
142
|
+
await runCommand(`take ${objectiveId}`);
|
|
143
|
+
return {
|
|
144
|
+
turn: state.turnCount + 1, goal: `in the ${here} — taking the ${objectiveId}.`,
|
|
145
|
+
plan: null, done: false, stalled: false, exposedRoomIds: exposed,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const path = findActionPath(here, (room) => room === objectiveRoom, exposedExitApplyActions(exposedState));
|
|
149
|
+
if (!path || !path.actions.length) {
|
|
150
|
+
return {
|
|
151
|
+
turn: state.turnCount, goal: `stalled — no seen path from the ${here} to the ${objectiveRoom}.`,
|
|
152
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
await runCommand(`go ${path.actions[0]}`);
|
|
156
|
+
exposed.add(path.states[1]);
|
|
157
|
+
return {
|
|
158
|
+
turn: state.turnCount + 1, goal: `heading toward the ${objectiveRoom} for the ${objectiveId}.`,
|
|
159
|
+
plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Explore: the objective either doesn't exist in this world, or its room
|
|
164
|
+
// isn't known yet. Prefer an immediate unexposed exit from here (the
|
|
165
|
+
// lowest-sorted direction); otherwise path toward the nearest exposed room
|
|
166
|
+
// that still has one.
|
|
167
|
+
const unexposedHere = unexposedExitsOf(here, exposedState, exposed).sort(([a], [b]) => a.localeCompare(b));
|
|
168
|
+
if (unexposedHere.length) {
|
|
169
|
+
const [direction, target] = unexposedHere[0];
|
|
170
|
+
await runCommand(`go ${direction}`);
|
|
171
|
+
exposed.add(target);
|
|
172
|
+
return {
|
|
173
|
+
turn: state.turnCount + 1, goal: `exploring — heading ${direction} into unseen ground.`,
|
|
174
|
+
plan: null, done: false, stalled: false, exposedRoomIds: exposed,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const hasUnexposedExit = (room) => unexposedExitsOf(room, exposedState, exposed).length > 0;
|
|
179
|
+
const path = findActionPath(here, hasUnexposedExit, exposedExitApplyActions(exposedState));
|
|
180
|
+
if (!path || !path.actions.length) {
|
|
181
|
+
return {
|
|
182
|
+
turn: state.turnCount, goal: "stalled — every reachable room is already explored, and no goal was ever found.",
|
|
183
|
+
plan: null, done: false, stalled: true, exposedRoomIds: exposed,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
await runCommand(`go ${path.actions[0]}`);
|
|
187
|
+
exposed.add(path.states[1]);
|
|
188
|
+
return {
|
|
189
|
+
turn: state.turnCount + 1, goal: "exploring — backtracking toward unseen ground.",
|
|
190
|
+
plan: path.actions, done: false, stalled: false, exposedRoomIds: exposed,
|
|
191
|
+
};
|
|
192
|
+
}
|