@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.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 +28 -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 +101 -0
- package/package.json +2 -1
- package/src/adapters/p2p/webrtc-transport.mjs +146 -0
- package/src/domain/game-config.mjs +67 -0
- package/src/domain/grammar/ace.mjs +11 -3
- package/src/domain/grammar/lexicon-core.json +3 -0
- package/src/domain/grammar/lexicon.mjs +13 -0
- package/src/domain/memory/trust.mjs +15 -0
- package/src/domain/p2p/facts.mjs +81 -0
- package/src/domain/p2p/peer-id.mjs +32 -0
- package/src/domain/p2p/provenance-relabel.mjs +26 -0
- package/src/domain/p2p/sync-filter.mjs +31 -0
- package/src/domain/p2p/wire.mjs +123 -0
- package/src/domain/sprite-map.mjs +10 -2
- package/src/services/adventure-editor.mjs +10 -2
- package/src/services/adventure-viz.mjs +192 -39
- package/src/services/adventure.mjs +989 -69
- package/src/services/chat-page-viz.mjs +1060 -7
- package/src/services/chat-session.mjs +28 -3
- package/src/services/chat.mjs +2 -2
- package/src/services/mud-editor.mjs +313 -0
- package/src/services/mud-turn.mjs +572 -0
- package/src/services/mud-viz.mjs +2055 -0
- package/src/services/p2p-room.mjs +559 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
- package/src/surfaces/web/mud-browser-entry.mjs +330 -0
- package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { worldProvenanceTag } from "../domain/worlds-pack.mjs";
|
|
12
12
|
import { parseImperative, OBJECT_PRONOUNS } from "../domain/grammar/ace.mjs";
|
|
13
|
+
import { loadLexicon, withProperNames, classify } from "../domain/grammar/lexicon.mjs";
|
|
13
14
|
import { register as registerReferent, bind as bindDiscourseForm } from "../domain/discourse.mjs";
|
|
14
15
|
import { createCompletionsGraphAdapter } from "../domain/completions/graph-adapter.mjs";
|
|
15
16
|
import { actionFamilies } from "../domain/router/taught.mjs";
|
|
@@ -183,7 +184,34 @@ const PLACEMENT_PREDICATES = new Set([
|
|
|
183
184
|
// which surface a thing rests against.
|
|
184
185
|
const POSITION_PREDICATES = new Set(["mgx:on-top-of", "mgx:on-plane", "mgx:under"]);
|
|
185
186
|
const OPEN_PREDICATE = "mgx:is-open";
|
|
187
|
+
const MASS_PREDICATE = "mgx:hasMass";
|
|
188
|
+
const KNOWS_ABOUT_PREDICATE = "mgx:knows-about";
|
|
189
|
+
// What a thing is called on screen, when that differs from its id. A dug
|
|
190
|
+
// object needs a distinct id per instance and a plain name to read by, and
|
|
191
|
+
// this predicate is the only place the two are allowed to differ.
|
|
192
|
+
const DISPLAY_NAME_PREDICATE = "mgx:display-name";
|
|
186
193
|
const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
|
|
194
|
+
// The dig mechanic's own wiring: which room a world measures distance from, the
|
|
195
|
+
// kinds a dug room turns up, the richer set a den holds, and who lives in one.
|
|
196
|
+
// All four are the world's answers to the dig verb's questions, never scenery.
|
|
197
|
+
const ORIGIN_PREDICATE = "mgx:is-origin";
|
|
198
|
+
const DIG_SPAWN_PREDICATE = "mgx:dig-spawns";
|
|
199
|
+
const DEN_SPAWN_PREDICATE = "mgx:den-spawns";
|
|
200
|
+
const DEN_RESIDENT_PREDICATE = "mgx:den-resident";
|
|
201
|
+
const DIG_REACH_PREDICATE = "mgx:dig-reach";
|
|
202
|
+
const DIG_SPAWN_MAX_PREDICATE = "mgx:dig-spawn-max";
|
|
203
|
+
const DEN_CHANCE_PREDICATE = "mgx:den-chance-in";
|
|
204
|
+
const DEN_RESIDENT_CHANCE_PREDICATE = "mgx:den-resident-chance-in";
|
|
205
|
+
const MASS_DRAIN_PREDICATE = "mgx:mass-drain-per-turn";
|
|
206
|
+
// Where a thing that has left the world is placed. The world has no other way
|
|
207
|
+
// to say "out of play", and no room can be called either of these, so the
|
|
208
|
+
// sentinel is the whole convention: the readers below skip it exactly as they
|
|
209
|
+
// skip a hiding place. Which sentinel a character sits at IS the reason it is
|
|
210
|
+
// out — eaten by a predator, or starved once its mass ran out — so a caller
|
|
211
|
+
// can say which without a second fact to read.
|
|
212
|
+
const CONSUMED_PLACE = "eaten";
|
|
213
|
+
const STARVED_PLACE = "starved";
|
|
214
|
+
const OUT_OF_PLAY_PLACES = new Set([CONSUMED_PLACE, STARVED_PLACE]);
|
|
187
215
|
|
|
188
216
|
/** The rows a live world's STATE fold may see: those the world itself wrote
|
|
189
217
|
* (provenance empty, or `world:*` — the loaded shard and its @turn
|
|
@@ -199,14 +227,29 @@ export function worldActionRows(rows) {
|
|
|
199
227
|
});
|
|
200
228
|
}
|
|
201
229
|
|
|
230
|
+
/** Every individual the world names — its rooms, its cast, its props, and
|
|
231
|
+
* anything dug up since — as the plain id strings a parser has to have
|
|
232
|
+
* DECLARED before it can resolve them. @turnN snapshots are skipped: a
|
|
233
|
+
* snapshot only ever repeats a subject its base row already named. Pure. */
|
|
234
|
+
export function worldIndividualNames(rows) {
|
|
235
|
+
const names = new Set();
|
|
236
|
+
for (const row of rows || []) {
|
|
237
|
+
if (SNAPSHOT_RE.test(row.subject)) continue;
|
|
238
|
+
if (row.predicate === "rdf:type" || PLACEMENT_PREDICATES.has(row.predicate)) names.add(row.subject);
|
|
239
|
+
if (EXIT_PREDICATE_RE.test(row.predicate)) { names.add(row.subject); names.add(row.object); }
|
|
240
|
+
}
|
|
241
|
+
return [...names].sort();
|
|
242
|
+
}
|
|
243
|
+
|
|
202
244
|
/** Fold fact rows into the CURRENT world state: per subject, the newest
|
|
203
245
|
* placement (base row = turn 0, @turnN snapshots override), the newest
|
|
204
|
-
* open/closed state, the exit map, and the turn counter (the
|
|
205
|
-
* suffix written so far — derived, never stored). Pure. */
|
|
246
|
+
* open/closed state, the newest mass, the exit map, and the turn counter (the
|
|
247
|
+
* largest @turnN suffix written so far — derived, never stored). Pure. */
|
|
206
248
|
export function foldWorldState(factRows) {
|
|
207
249
|
const placements = new Map(); // subject -> { predicate, object, turn }
|
|
208
250
|
const positions = new Map(); // subject -> { predicate, object, turn }
|
|
209
251
|
const openness = new Map(); // subject -> { open, turn }
|
|
252
|
+
const masses = new Map(); // subject -> { value, turn }
|
|
210
253
|
const exits = new Map(); // room -> Map(direction -> room)
|
|
211
254
|
let turnCount = 0;
|
|
212
255
|
for (const row of factRows || []) {
|
|
@@ -229,13 +272,20 @@ export function foldWorldState(factRows) {
|
|
|
229
272
|
if (!prior || turn >= prior.turn) openness.set(base, { open: row.object === "true", turn });
|
|
230
273
|
continue;
|
|
231
274
|
}
|
|
275
|
+
if (row.predicate === MASS_PREDICATE) {
|
|
276
|
+
const value = Number(row.object);
|
|
277
|
+
if (!Number.isFinite(value)) continue; // masses hold numbers; an unparsable one is no mass at all
|
|
278
|
+
const prior = masses.get(base);
|
|
279
|
+
if (!prior || turn >= prior.turn) masses.set(base, { value, turn });
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
232
282
|
const exit = EXIT_PREDICATE_RE.exec(row.predicate);
|
|
233
283
|
if (exit && !m) {
|
|
234
284
|
if (!exits.has(row.subject)) exits.set(row.subject, new Map());
|
|
235
285
|
exits.get(row.subject).set(exit[1], row.object);
|
|
236
286
|
}
|
|
237
287
|
}
|
|
238
|
-
return { placements, positions, openness, exits, turnCount };
|
|
288
|
+
return { placements, positions, openness, masses, exits, turnCount };
|
|
239
289
|
}
|
|
240
290
|
|
|
241
291
|
/** A subject's CURRENT within-room position, or null. A position goes stale
|
|
@@ -305,15 +355,17 @@ function visibleRoomOf(thing, { rows, state }) {
|
|
|
305
355
|
if (!place || place.predicate === "mgx:hidden-in") return null;
|
|
306
356
|
if (place.predicate === "mgx:currently-in" || isTyped(rows, place.object, "room")) return place.object;
|
|
307
357
|
const holder = place.object;
|
|
308
|
-
|
|
358
|
+
// A non-container holder is a character carrying the thing, whoever they
|
|
359
|
+
// are — carried, so not on show in the room they stand in.
|
|
360
|
+
if (!isContainer(rows, holder)) return null;
|
|
309
361
|
if (!state.openness.get(holder)?.open) return null;
|
|
310
362
|
const holderPlace = state.placements.get(holder);
|
|
311
363
|
return holderPlace && holderPlace.predicate !== "mgx:hidden-in" ? holderPlace.object : null;
|
|
312
364
|
}
|
|
313
365
|
|
|
314
|
-
const
|
|
366
|
+
const carriedBy = (state, thing, holder) => {
|
|
315
367
|
const place = state.placements.get(thing);
|
|
316
|
-
return !!place && place.predicate === "mgx:located-in" && place.object ===
|
|
368
|
+
return !!place && place.predicate === "mgx:located-in" && place.object === holder;
|
|
317
369
|
};
|
|
318
370
|
|
|
319
371
|
/** True when `object` is never a real placed game entity (no entry in
|
|
@@ -330,6 +382,59 @@ function backgroundOnlyMention(rows, state, object) {
|
|
|
330
382
|
return (rows || []).some((r) => r.subject === object || r.object === object);
|
|
331
383
|
}
|
|
332
384
|
|
|
385
|
+
/** True when `subject` is one of the world's cast rather than a prop: a
|
|
386
|
+
* declared person, or anything the world places with mgx:currently-in — the
|
|
387
|
+
* predicate every world reserves for a character standing in a room, props
|
|
388
|
+
* riding located-in/fixed-in/stands-locked-in instead. Both halves matter:
|
|
389
|
+
* ashcombe-hall types its staff `person`, while mud-garden types its animals
|
|
390
|
+
* `adventurer` and places them the same way, so a person-only test leaves a
|
|
391
|
+
* whole cast with nobody able to speak to it. */
|
|
392
|
+
function isCastMember(rows, state, subject) {
|
|
393
|
+
return isTyped(rows, subject, "person") || state.placements.get(subject)?.predicate === "mgx:currently-in";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Who else is standing in `room` right now, sorted — the same currently-in
|
|
397
|
+
* placement the talk verb and the room affordances read, exposed so a caller
|
|
398
|
+
* rendering a room can name its cast without re-deriving the test. Pure. */
|
|
399
|
+
export function castInRoom(rows, state, room, exclude = null) {
|
|
400
|
+
return [...state.placements.keys()]
|
|
401
|
+
.filter((subject) => subject !== exclude && subject !== room)
|
|
402
|
+
.filter((subject) => state.placements.get(subject).object === room)
|
|
403
|
+
.filter((subject) => isCastMember(rows, state, subject))
|
|
404
|
+
.sort();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// A predator eats whatever walks into its room. The marker is a world fact,
|
|
408
|
+
// so which individual is dangerous is the world's business, never this
|
|
409
|
+
// module's.
|
|
410
|
+
const PREDATOR_PREDICATE = "mgx:is-predator";
|
|
411
|
+
|
|
412
|
+
/** The predator standing in `room`, or null — read from the same placements
|
|
413
|
+
* fold every other presence check uses. Pure. */
|
|
414
|
+
function predatorIn(rows, state, room) {
|
|
415
|
+
return castInRoom(rows, state, room)
|
|
416
|
+
.find((subject) => factObjects(rows, subject, PREDATOR_PREDICATE).includes("true")) ?? null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** True when `subject` has left the world: placed at an out-of-play sentinel
|
|
420
|
+
* no room can be called. Its part in the world is finished — every command it
|
|
421
|
+
* gives declines, and its scripted turns stop. Pure. */
|
|
422
|
+
export const isOutOfPlay = (state, subject) => OUT_OF_PLAY_PLACES.has(state.placements.get(subject)?.object);
|
|
423
|
+
|
|
424
|
+
/** WHY `subject` is out of play — "eaten" or "starved" — or null while it is
|
|
425
|
+
* still playing. The two fates end a run the same way and read nothing alike,
|
|
426
|
+
* so anything narrating one needs to tell them apart. Pure. */
|
|
427
|
+
export const outOfPlayReasonOf = (state, subject) => {
|
|
428
|
+
const place = state.placements.get(subject)?.object;
|
|
429
|
+
return OUT_OF_PLAY_PLACES.has(place) ? place : null;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/** How a fate reads in a sentence: "the mole-1 has been eaten", "the mole-1 has
|
|
433
|
+
* starved". One phrase per sentinel, so nothing anywhere else has to spell the
|
|
434
|
+
* difference out. Pure. */
|
|
435
|
+
export const outOfPlayPhrase = (subject, reason) =>
|
|
436
|
+
(reason === STARVED_PLACE ? `the ${subject} has starved` : `the ${subject} has been eaten`);
|
|
437
|
+
|
|
333
438
|
/** The room's real affordances — every exit, and every visible object's
|
|
334
439
|
* applicable verb — read from the EXACT SAME data take/open/talk/examine
|
|
335
440
|
* already check (visibleRoomOf, isContainer, isTyped, the placement
|
|
@@ -337,13 +442,13 @@ function backgroundOnlyMention(rows, state, object) {
|
|
|
337
442
|
* would then refuse. A locked container offers "unlock", never "open" (that
|
|
338
443
|
* would only decline); an already-open one offers neither, since there is
|
|
339
444
|
* nothing left for either verb to do. Pure. */
|
|
340
|
-
export function roomAffordances(rows, state, here) {
|
|
445
|
+
export function roomAffordances(rows, state, here, actingSubject = "player") {
|
|
341
446
|
const actions = [];
|
|
342
447
|
for (const direction of state.exits.get(here)?.keys() ?? []) {
|
|
343
448
|
actions.push(`go ${direction}`);
|
|
344
449
|
}
|
|
345
450
|
for (const subject of [...state.placements.keys()].sort()) {
|
|
346
|
-
if (subject ===
|
|
451
|
+
if (subject === actingSubject) continue;
|
|
347
452
|
if (visibleRoomOf(subject, { rows, state }) !== here) continue;
|
|
348
453
|
const place = state.placements.get(subject);
|
|
349
454
|
const container = isContainer(rows, subject);
|
|
@@ -359,7 +464,7 @@ export function roomAffordances(rows, state, here) {
|
|
|
359
464
|
actions.push(`examine ${subject}`);
|
|
360
465
|
continue;
|
|
361
466
|
}
|
|
362
|
-
if (
|
|
467
|
+
if (isCastMember(rows, state, subject)) {
|
|
363
468
|
actions.push(`talk to ${subject}`);
|
|
364
469
|
continue;
|
|
365
470
|
}
|
|
@@ -433,6 +538,151 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
|
|
|
433
538
|
if (cache) cache.rows = null;
|
|
434
539
|
}
|
|
435
540
|
|
|
541
|
+
// ---- what a character knows, and who it heard it from -----------------------
|
|
542
|
+
//
|
|
543
|
+
// A character telling another character about something, or looking at
|
|
544
|
+
// something itself, leaves a REAL fact behind: an mgx:knows-about edge the
|
|
545
|
+
// hearer carries from that turn on, readable by personKnowledgeLines exactly
|
|
546
|
+
// like a world-authored one. Nothing here is per-tick or in-memory — one
|
|
547
|
+
// animal can walk off, come back ten turns later, and still know what it was
|
|
548
|
+
// told.
|
|
549
|
+
//
|
|
550
|
+
// These deliberately bypass writeWorldTurn. That tags everything
|
|
551
|
+
// `world:<name>:turnN`, which credits the WORLD for the claim; a character's
|
|
552
|
+
// testimony belongs to the character, so it carries its own
|
|
553
|
+
// `mud:<character>:turnN` tag and lands on that character's own Source and
|
|
554
|
+
// trust track record. The side effect is that worldActionRows filters these
|
|
555
|
+
// out of the playable state fold, which is what you want — being told about a
|
|
556
|
+
// stone must never move the stone.
|
|
557
|
+
//
|
|
558
|
+
// A claim can also go out of date, and none of them is ever retracted. Eating
|
|
559
|
+
// the last carrot appends a SECOND claim to the same edge, tagged `:gone`, and
|
|
560
|
+
// appendFacts unions the two tags onto the one fact exactly as it unions any
|
|
561
|
+
// repeat assertion. "The carrot was here on turn 2" and "the carrot is gone on
|
|
562
|
+
// turn 5" are both true; the reader's job is to say which one rules, the same
|
|
563
|
+
// recency question the p2p layer asks of its own tags. So reading knowledge
|
|
564
|
+
// back means reading the newest claim per edge, never the union of every claim
|
|
565
|
+
// ever made.
|
|
566
|
+
|
|
567
|
+
const VOIDED_TESTIMONY_SUFFIX = ":gone";
|
|
568
|
+
|
|
569
|
+
const characterTestimonyTag = (character, k, voided = false) =>
|
|
570
|
+
`mud:${character}:turn${k}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
|
|
571
|
+
|
|
572
|
+
async function appendTestimony(memoryDir, { knower, source, thing, k, voided = false, cache }) {
|
|
573
|
+
await appendFacts(memoryDir, [{
|
|
574
|
+
subject: knower, predicate: KNOWS_ABOUT_PREDICATE, object: thing,
|
|
575
|
+
provenance: characterTestimonyTag(source, k, voided),
|
|
576
|
+
}]);
|
|
577
|
+
if (cache) cache.rows = null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Record that `teller` told `asker` about `thing` on turn `k`. The asker is
|
|
581
|
+
* the subject — it is the one who now knows — and the teller is named in the
|
|
582
|
+
* provenance, so the claim corroborates the teller's Source, not the asker's. */
|
|
583
|
+
export async function recordTold(memoryDir, { asker, teller, thing, k, cache = null }) {
|
|
584
|
+
return appendTestimony(memoryDir, { knower: asker, source: teller, thing, k, cache });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Record that `observer` examined `thing` on turn `k`. The observer is both
|
|
588
|
+
* the subject and the provenance's character: it learned this by looking, so
|
|
589
|
+
* it is its own source for it. */
|
|
590
|
+
export async function recordExamined(memoryDir, { observer, thing, k, cache = null }) {
|
|
591
|
+
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, cache });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Record that `observer` saw `thing` leave the world on turn `k` — it ate the
|
|
595
|
+
* last of it. Written as a fresh claim on the SAME edge an older one already
|
|
596
|
+
* sits on, so the older claim stands untouched and stops being the one that
|
|
597
|
+
* rules. The observer is its own source, the way examining is. */
|
|
598
|
+
export async function recordGone(memoryDir, { observer, thing, k, cache = null }) {
|
|
599
|
+
return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, voided: true, cache });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const TESTIMONY_TAG_RE = /^mud:([^:\s]+):turn(\d+)(:gone)?$/;
|
|
603
|
+
const TURN_STAMP_RE = /:turn(\d+)\b/;
|
|
604
|
+
|
|
605
|
+
/** How one provenance segment on a knows-about edge stands as a claim about
|
|
606
|
+
* what `knower` knows: whether the knower vouches for it itself, the turn it
|
|
607
|
+
* was asserted on, and whether it says the thing is gone. A tag that is no
|
|
608
|
+
* character's testimony — a world's own seed fact, a dig spawn — reads as
|
|
609
|
+
* hearsay stamped with whatever turn it carries. */
|
|
610
|
+
function testimonyClaim(segment, knower) {
|
|
611
|
+
const mine = TESTIMONY_TAG_RE.exec(segment);
|
|
612
|
+
const stamp = Number(mine ? mine[2] : (TURN_STAMP_RE.exec(segment)?.[1] ?? 0));
|
|
613
|
+
return {
|
|
614
|
+
firsthand: !!mine && mine[1] === knower,
|
|
615
|
+
turn: Number.isFinite(stamp) ? stamp : 0,
|
|
616
|
+
voided: !!(mine && mine[3]),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Firsthand beats hearsay outright, then the later turn wins, then "gone"
|
|
621
|
+
* takes the tie — an animal that examined a carrot and ate it on one turn ate
|
|
622
|
+
* it second. Tier ABOVE recency is what stops an animal being talked back into
|
|
623
|
+
* a meal it ate itself: a room-mate can tell it about that carrot the turn
|
|
624
|
+
* after, and its own eyes still hold. */
|
|
625
|
+
const outranksClaim = (claim, best) => (
|
|
626
|
+
claim.firsthand !== best.firsthand ? claim.firsthand
|
|
627
|
+
: claim.turn !== best.turn ? claim.turn > best.turn
|
|
628
|
+
: claim.voided && !best.voided
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
/** The claim that rules on one knows-about edge, across every segment its
|
|
632
|
+
* provenance carries. */
|
|
633
|
+
function rulingTestimonyClaim(provenance, knower) {
|
|
634
|
+
let best = null;
|
|
635
|
+
for (const segment of String(provenance || "").split(" | ")) {
|
|
636
|
+
const tag = segment.trim();
|
|
637
|
+
if (!tag) continue;
|
|
638
|
+
const claim = testimonyClaim(tag, knower);
|
|
639
|
+
if (!best || outranksClaim(claim, best)) best = claim;
|
|
640
|
+
}
|
|
641
|
+
return best;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** What `person` knows about NOW: the object of every knows-about edge whose
|
|
645
|
+
* ruling claim still stands, in the order the edges were first written.
|
|
646
|
+
* Nothing is deleted — an edge whose newest claim says the thing is gone just
|
|
647
|
+
* stops reading back. */
|
|
648
|
+
function currentKnowsAboutTopics(rows, person) {
|
|
649
|
+
const topics = [];
|
|
650
|
+
for (const row of rows || []) {
|
|
651
|
+
if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
|
|
652
|
+
if (rulingTestimonyClaim(row.provenance, person)?.voided) continue;
|
|
653
|
+
topics.push(row.object);
|
|
654
|
+
}
|
|
655
|
+
return topics;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Charge `subject` the mass a turn costs it, and place it out of play at the
|
|
660
|
+
* starved sentinel once nothing is left. Returns `{ mass, starved }` — the mass
|
|
661
|
+
* it is left with, and whether that ended its run. Writes nothing and charges
|
|
662
|
+
* nothing when the drain is zero, when the subject is already out of play, or
|
|
663
|
+
* when the world gives it no mass at all (`mass` is then null: a thing with no
|
|
664
|
+
* mass cannot run out of it).
|
|
665
|
+
*
|
|
666
|
+
* The write lands on the world's OWN next turn, read fresh here rather than
|
|
667
|
+
* taken from the caller. A scripted turn runs several world commands, each
|
|
668
|
+
* stamping a turn of its own, so a caller's tick number can trail the world's
|
|
669
|
+
* count — and a mass snapshot stamped behind the newest placement would fold
|
|
670
|
+
* away as stale the moment it was written.
|
|
671
|
+
*/
|
|
672
|
+
export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn, cache = null }) {
|
|
673
|
+
const rows = readFactRows(await loadMemory(memoryDir));
|
|
674
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
675
|
+
const mass = state.masses.get(subject)?.value ?? null;
|
|
676
|
+
if (mass === null || !(drainPerTurn > 0) || isOutOfPlay(state, subject)) return { mass, starved: false };
|
|
677
|
+
const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
|
|
678
|
+
const k = state.turnCount + 1;
|
|
679
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
680
|
+
{ subject: `${subject}@turn${k}`, predicate: MASS_PREDICATE, object: String(left) },
|
|
681
|
+
...(left > 0 ? [] : [{ subject: `${subject}@turn${k}`, predicate: "mgx:currently-in", object: STARVED_PLACE }]),
|
|
682
|
+
], cache);
|
|
683
|
+
return { mass: left, starved: left <= 0 };
|
|
684
|
+
}
|
|
685
|
+
|
|
436
686
|
// ---- the look/inventory digest ----------------------------------------------
|
|
437
687
|
//
|
|
438
688
|
// "look" and "what am I carrying" are generateCompletion calls (the shipped
|
|
@@ -448,6 +698,9 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
|
|
|
448
698
|
|
|
449
699
|
const VIEW_EXCLUDED_PREDICATES = new Set([
|
|
450
700
|
"mgx:hidden-in", "mgx:is-open", "mgx:is-npc", "mgx:is-container",
|
|
701
|
+
// A bare number reads as an untranslated triple in room prose ("Mole-1
|
|
702
|
+
// mgx:hasMass 8"). Mass reaches a player through the verbs that change it.
|
|
703
|
+
MASS_PREDICATE,
|
|
451
704
|
"mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward",
|
|
452
705
|
// is-objective is an internal marker for auto-play's goal inference — the
|
|
453
706
|
// same information the opening narration already tells a human player in
|
|
@@ -457,12 +710,27 @@ const VIEW_EXCLUDED_PREDICATES = new Set([
|
|
|
457
710
|
// Staff knowledge is the whole puzzle: a room look must never leak
|
|
458
711
|
// "Gardener knows-where letter" or the game is spoiled. It reaches the
|
|
459
712
|
// player only through the talk lane, which resolves each pointer live.
|
|
460
|
-
"mgx:knows-where", "mgx:knows-objective",
|
|
713
|
+
"mgx:knows-where", "mgx:knows-objective", KNOWS_ABOUT_PREDICATE,
|
|
461
714
|
// Class-schema facts describe the ontology, not the scene. default-contains
|
|
462
715
|
// is already materialized into real instances at load; default-plane and
|
|
463
716
|
// subClassOf drive positional rendering by their own readers, and read as
|
|
464
717
|
// raw triples if they land in room prose.
|
|
465
718
|
"mgx:default-contains", "mgx:default-plane", "rdfs:subClassOf",
|
|
719
|
+
// A screen name is presentation, not scenery — it reads as a raw triple in
|
|
720
|
+
// room prose ("Carrot-1 mgx:display-name carrot") and says nothing the
|
|
721
|
+
// room's own sentences don't already say.
|
|
722
|
+
DISPLAY_NAME_PREDICATE,
|
|
723
|
+
// Which individual is dangerous is the predator mechanic's own wiring; a
|
|
724
|
+
// room look that announced it would give the trap away as a bare triple.
|
|
725
|
+
"mgx:is-predator",
|
|
726
|
+
// The dig mechanic's wiring is the same kind of thing: it tells the verb what
|
|
727
|
+
// a dug room may hold and how far the world reaches, and says nothing about
|
|
728
|
+
// the room anyone is standing in.
|
|
729
|
+
ORIGIN_PREDICATE, DIG_SPAWN_PREDICATE, DEN_SPAWN_PREDICATE, DEN_RESIDENT_PREDICATE,
|
|
730
|
+
DIG_REACH_PREDICATE, DIG_SPAWN_MAX_PREDICATE, DEN_CHANCE_PREDICATE, DEN_RESIDENT_CHANCE_PREDICATE,
|
|
731
|
+
// How fast a turn wears a species down is the mass economy's own wiring, and
|
|
732
|
+
// reads as a bare number in room prose the same way hasMass does.
|
|
733
|
+
MASS_DRAIN_PREDICATE,
|
|
466
734
|
]);
|
|
467
735
|
|
|
468
736
|
const sentenceCase = (term) => String(term).charAt(0).toUpperCase() + String(term).slice(1);
|
|
@@ -497,7 +765,7 @@ function isNonWorldSourced(row) {
|
|
|
497
765
|
* subjects so the pipeline's sentence splitter sees real sentences. Room text
|
|
498
766
|
* is world-sourced only — a merged corpus's overlap on a room's own vocabulary
|
|
499
767
|
* never leaks into the description. Pure. */
|
|
500
|
-
export function worldDigestRows(rows, state) {
|
|
768
|
+
export function worldDigestRows(rows, state, actingSubject = "player") {
|
|
501
769
|
const out = [];
|
|
502
770
|
const seen = new Set();
|
|
503
771
|
const push = (subject, phrase, object) => {
|
|
@@ -506,13 +774,19 @@ export function worldDigestRows(rows, state) {
|
|
|
506
774
|
seen.add(key);
|
|
507
775
|
out.push({ subject: sentenceCase(subject), predicate: phrase, object });
|
|
508
776
|
};
|
|
777
|
+
// Whoever holds a located-in thing is carrying it rather than housing it,
|
|
778
|
+
// and the cast are exactly the individuals the world places with
|
|
779
|
+
// currently-in — props ride located-in/fixed-in/stands-locked-in, rooms are
|
|
780
|
+
// never placed at all.
|
|
781
|
+
const isCarryingCharacter = (holder) =>
|
|
782
|
+
isTyped(rows, holder, "person") || state.placements.get(holder)?.predicate === "mgx:currently-in";
|
|
509
783
|
for (const [subject, place] of state.placements) {
|
|
510
|
-
if (place.predicate === "mgx:hidden-in") continue;
|
|
511
|
-
if (place.predicate === "mgx:located-in" && place.object ===
|
|
512
|
-
push(
|
|
784
|
+
if (place.predicate === "mgx:hidden-in" || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
785
|
+
if (place.predicate === "mgx:located-in" && place.object === actingSubject) {
|
|
786
|
+
push(actingSubject, "carries the", subject);
|
|
513
787
|
continue;
|
|
514
788
|
}
|
|
515
|
-
if (place.predicate === "mgx:located-in" &&
|
|
789
|
+
if (place.predicate === "mgx:located-in" && isCarryingCharacter(place.object)) {
|
|
516
790
|
push(place.object, "carries the", subject);
|
|
517
791
|
continue;
|
|
518
792
|
}
|
|
@@ -530,14 +804,18 @@ export function worldDigestRows(rows, state) {
|
|
|
530
804
|
// already assumes, so it is left unsaid.
|
|
531
805
|
const POSITION_PHRASE = { "mgx:on-top-of": "is on the", "mgx:on-plane": "is on the", "mgx:under": "is under the" };
|
|
532
806
|
for (const [subject, place] of state.placements) {
|
|
533
|
-
if (place.predicate === "mgx:hidden-in" || place.object ===
|
|
807
|
+
if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || OUT_OF_PLAY_PLACES.has(place.object)) continue;
|
|
534
808
|
const pos = currentPosition(state, subject);
|
|
535
809
|
if (pos && POSITION_PHRASE[pos.predicate]) { push(subject, POSITION_PHRASE[pos.predicate], pos.object); continue; }
|
|
536
810
|
const plane = classDefaultPlane(rows, subject);
|
|
537
811
|
if (plane && plane !== "floor") push(subject, "is usually on the", plane);
|
|
538
812
|
}
|
|
813
|
+
const gone = new Set([...state.placements]
|
|
814
|
+
.filter(([, place]) => OUT_OF_PLAY_PLACES.has(place.object))
|
|
815
|
+
.map(([subject]) => subject));
|
|
539
816
|
for (const row of rows || []) {
|
|
540
817
|
if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
|
|
818
|
+
if (gone.has(row.subject)) continue; // out of the world entirely
|
|
541
819
|
// Room text comes from the world source only. A merged corpus overlaps a
|
|
542
820
|
// room's own vocabulary ("library rdfs:subClassOf literary study"), and
|
|
543
821
|
// without this those rows leak into the room description as stray sentences.
|
|
@@ -564,9 +842,9 @@ export function worldDigestRows(rows, state) {
|
|
|
564
842
|
* the class hierarchy renders that as its own is-a chain instead. A carried
|
|
565
843
|
* object surfaces through the "carries the" line the digest already produces.
|
|
566
844
|
* Pure. */
|
|
567
|
-
export function objectLookProperties(rows, state, object) {
|
|
845
|
+
export function objectLookProperties(rows, state, object, actingSubject = "player") {
|
|
568
846
|
const subjectCased = sentenceCase(object);
|
|
569
|
-
return worldDigestRows(rows, state)
|
|
847
|
+
return worldDigestRows(rows, state, actingSubject)
|
|
570
848
|
.filter((r) => (r.subject === subjectCased && r.predicate !== "is a" && r.predicate !== "is an")
|
|
571
849
|
|| (r.predicate === "carries the" && r.object === object))
|
|
572
850
|
.map((r) => `${r.subject} ${r.predicate} ${r.object}.`);
|
|
@@ -595,8 +873,8 @@ export function objectClassChain(rows, object) {
|
|
|
595
873
|
return chain;
|
|
596
874
|
}
|
|
597
875
|
|
|
598
|
-
async function worldDigest(prompt, { memoryDir, memory, rows, state, graph }) {
|
|
599
|
-
const view = worldDigestRows(rows, state);
|
|
876
|
+
async function worldDigest(prompt, { memoryDir, memory, rows, state, graph, actingSubject = "player" }) {
|
|
877
|
+
const view = worldDigestRows(rows, state, actingSubject);
|
|
600
878
|
const store = {
|
|
601
879
|
...COMPLETIONS_STORE,
|
|
602
880
|
readFactRows: () => view,
|
|
@@ -626,6 +904,248 @@ const answer = (text, note, { goal, miss = false } = {}) => ({
|
|
|
626
904
|
text, note, lane: "game-answer", miss, ...(goal ? { goal } : {}),
|
|
627
905
|
});
|
|
628
906
|
|
|
907
|
+
// A dug room needs the way back written too, and the exit vocabulary is only
|
|
908
|
+
// ever a direction word in a predicate name, so the pairing lives here.
|
|
909
|
+
const OPPOSITE_DIRECTION = new Map([
|
|
910
|
+
["north", "south"], ["south", "north"],
|
|
911
|
+
["east", "west"], ["west", "east"],
|
|
912
|
+
["up", "down"], ["down", "up"],
|
|
913
|
+
]);
|
|
914
|
+
|
|
915
|
+
// What a freshly dug room holds, and how often a dig opens something better
|
|
916
|
+
// than a bare tunnel. The world names all of it — a room kind declares the
|
|
917
|
+
// kinds a plain dig turns up, how many of them, the richer set a den holds, how
|
|
918
|
+
// often a dig finds one, and which animal lives in it. The numbers below are
|
|
919
|
+
// only the fallback for a world that declares none.
|
|
920
|
+
const DIG_SPAWN_KINDS = ["root", "carrot", "worm"];
|
|
921
|
+
const DIG_SPAWN_MIN = 0;
|
|
922
|
+
const DEFAULT_DIG_SPAWN_MAX = 2;
|
|
923
|
+
const DEFAULT_DEN_CHANCE_IN = 5;
|
|
924
|
+
const DEFAULT_DEN_RESIDENT_CHANCE_IN = 3;
|
|
925
|
+
const DEN_ROOM_CLASS = "den";
|
|
926
|
+
|
|
927
|
+
// How far from the world's origin room a dig may carry it, when the origin
|
|
928
|
+
// writes no reach of its own. Without a cap a burrow sprawls in every direction
|
|
929
|
+
// at once, and an animal twenty hops out has nothing around it, no food it
|
|
930
|
+
// knows of, and no reason to be anywhere — the stranding this bound exists to
|
|
931
|
+
// stop. Six keeps every room inside one pathfinder search of the origin
|
|
932
|
+
// (mud-turn.mjs walks eight hops), so an animal standing at the frontier can
|
|
933
|
+
// always still walk home to the rooms with food in them.
|
|
934
|
+
const DEFAULT_DIG_REACH = 6;
|
|
935
|
+
|
|
936
|
+
/** The NEWEST value `subject` declares under `predicate`, as a number, or null
|
|
937
|
+
* when it declares none. Newest rather than first on purpose: the store is
|
|
938
|
+
* append-only, so a later write is the current truth — the same rule
|
|
939
|
+
* foldWorldState already applies to placements, and what lets an edit to one of
|
|
940
|
+
* these knobs take effect over the world's own seed fact. Pure. */
|
|
941
|
+
function declaredNumber(rows, subject, predicate) {
|
|
942
|
+
const written = factObjects(rows, subject, predicate);
|
|
943
|
+
if (!written.length) return null;
|
|
944
|
+
const value = Number(written[written.length - 1]);
|
|
945
|
+
return Number.isFinite(value) ? value : null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** A positive count `subject` declares under `predicate`, or `fallback` when it
|
|
949
|
+
* declares none (or writes something that is not a usable count). Pure. */
|
|
950
|
+
function declaredCountOr(rows, subject, predicate, fallback) {
|
|
951
|
+
const written = declaredNumber(rows, subject, predicate);
|
|
952
|
+
return written !== null && written > 0 ? written : fallback;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** How many rooms out from its origin this world lets a dig reach — the origin
|
|
956
|
+
* room's own `mgx:dig-reach` fact, or the shipped default when it writes none.
|
|
957
|
+
* Pure. */
|
|
958
|
+
export function digReachOf(rows) {
|
|
959
|
+
const origin = originRoomOf(rows);
|
|
960
|
+
return origin ? declaredCountOr(rows, origin, DIG_REACH_PREDICATE, DEFAULT_DIG_REACH) : DEFAULT_DIG_REACH;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** What one turn costs `subject` in mass, from a `mgx:mass-drain-per-turn` fact
|
|
964
|
+
* on its own class chain (so a whole species is tuned in one line, and one
|
|
965
|
+
* individual can still overrule its species by writing its own). Null when
|
|
966
|
+
* nothing in the chain declares one — a knob nobody set is not a reason to
|
|
967
|
+
* invent a number and starve something with it. Pure. */
|
|
968
|
+
export function massDrainPerTurnOf(rows, subject) {
|
|
969
|
+
for (const kind of objectClassChain(rows, subject)) {
|
|
970
|
+
const written = declaredNumber(rows, kind, MASS_DRAIN_PREDICATE);
|
|
971
|
+
if (written !== null && written >= 0) return written;
|
|
972
|
+
}
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Which way a room of each kind can be dug, and what the room it opens is
|
|
977
|
+
// typed as. Above ground there is nothing to tunnel sideways through, so the
|
|
978
|
+
// only dig is straight down into the soil; below ground the burrow spreads
|
|
979
|
+
// across its own level and can surface again. Digging deeper is left out so
|
|
980
|
+
// the burrow stays the one level the soil cross-section draws.
|
|
981
|
+
const DIGGABLE_BY_ROOM_KIND = new Map([
|
|
982
|
+
["outdoor", new Map([["down", "underground-space"]])],
|
|
983
|
+
["underground", new Map([
|
|
984
|
+
["north", "underground-space"],
|
|
985
|
+
["south", "underground-space"],
|
|
986
|
+
["east", "underground-space"],
|
|
987
|
+
["west", "underground-space"],
|
|
988
|
+
["up", "outdoor-space"],
|
|
989
|
+
])],
|
|
990
|
+
["indoor", new Map()],
|
|
991
|
+
]);
|
|
992
|
+
|
|
993
|
+
const DIG_DECLINE_BY_ROOM_KIND = {
|
|
994
|
+
outdoor: (room, direction) => (direction === "up"
|
|
995
|
+
? `there's nothing but sky above the ${room}.`
|
|
996
|
+
: `you can't tunnel ${direction} out here — the ${room} is open ground, not soil to dig through. Dig down to get under it.`),
|
|
997
|
+
underground: (room) => `the earth below the ${room} is packed solid — this burrow runs one level deep.`,
|
|
998
|
+
indoor: (room, direction) => `you can't dig ${direction} out of the ${room}.`,
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/** A room's own kind, from the rdf:type facts the world writes about it:
|
|
1002
|
+
* "outdoor" (the surface), "underground" (the burrow), or "indoor" for a
|
|
1003
|
+
* walled room that says neither. Pure. */
|
|
1004
|
+
export function roomKindOf(rows, room) {
|
|
1005
|
+
const typedAs = (kind) => (rows || []).some((r) => r.subject === room && r.predicate === "rdf:type" && r.object === kind);
|
|
1006
|
+
if (typedAs("outdoor-space")) return "outdoor";
|
|
1007
|
+
if (typedAs("underground-space")) return "underground";
|
|
1008
|
+
return "indoor";
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** The room a world calls its origin — the one every dig is measured from — or
|
|
1012
|
+
* null when it names none. A world with no origin fact is simply not bounded.
|
|
1013
|
+
* Pure. */
|
|
1014
|
+
export function originRoomOf(rows) {
|
|
1015
|
+
return (rows || []).find((r) => r.predicate === ORIGIN_PREDICATE && r.object === "true")?.subject ?? null;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** How many exits a walk from the world's origin to `room` crosses, or null
|
|
1019
|
+
* when the world declares no origin or no chain of exits joins the two. Pure. */
|
|
1020
|
+
export function roomDistanceFromOrigin(rows, state, room) {
|
|
1021
|
+
const origin = originRoomOf(rows);
|
|
1022
|
+
if (!origin) return null;
|
|
1023
|
+
if (origin === room) return 0;
|
|
1024
|
+
const seen = new Set([origin]);
|
|
1025
|
+
let frontier = [origin];
|
|
1026
|
+
for (let distance = 1; frontier.length; distance += 1) {
|
|
1027
|
+
const next = [];
|
|
1028
|
+
for (const from of frontier) {
|
|
1029
|
+
for (const target of state.exits.get(from)?.values() ?? []) {
|
|
1030
|
+
if (seen.has(target)) continue;
|
|
1031
|
+
seen.add(target);
|
|
1032
|
+
if (target === room) return distance;
|
|
1033
|
+
next.push(target);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
frontier = next;
|
|
1037
|
+
}
|
|
1038
|
+
return null;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/** True when `room` is as far from the origin as this world digs, or off the
|
|
1042
|
+
* origin's map altogether. A freshly dug room's only other exit is the one
|
|
1043
|
+
* back, so its distance is always this room's plus one — which makes the whole
|
|
1044
|
+
* boundary test a property of where the digger stands, never of the direction
|
|
1045
|
+
* it faces.
|
|
1046
|
+
*
|
|
1047
|
+
* A room the origin cannot reach is the strictest case, not the loosest: it
|
|
1048
|
+
* has no measurable distance, so nothing would ever stop it growing, and a
|
|
1049
|
+
* burrow with no way home is precisely what the bound exists to prevent. A
|
|
1050
|
+
* world that declares no origin at all is a different thing and stays
|
|
1051
|
+
* unbounded. Pure. */
|
|
1052
|
+
function atDigBoundary(rows, state, room) {
|
|
1053
|
+
if (!originRoomOf(rows)) return false;
|
|
1054
|
+
const distance = roomDistanceFromOrigin(rows, state, room);
|
|
1055
|
+
return distance === null || distance >= digReachOf(rows);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Every direction a dig could actually open a room in from `room`: allowed
|
|
1059
|
+
* by the room's own kind, with no exit already written that way, and inside
|
|
1060
|
+
* the world's dig boundary. This is the exact set the dig verb accepts, so a
|
|
1061
|
+
* caller offering these as hints can never suggest a dig the verb would then
|
|
1062
|
+
* refuse. Pure. */
|
|
1063
|
+
export function diggableDirections(rows, state, room) {
|
|
1064
|
+
if (atDigBoundary(rows, state, room)) return [];
|
|
1065
|
+
const exits = state.exits.get(room);
|
|
1066
|
+
return [...(DIGGABLE_BY_ROOM_KIND.get(roomKindOf(rows, room)) ?? new Map()).keys()]
|
|
1067
|
+
.filter((direction) => !exits?.has(direction));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const FOOD_CLASS = "food";
|
|
1071
|
+
// A shared reference mass standing in for per-species maxima until the game
|
|
1072
|
+
// config carries them, and what an eaten thing is worth when the world wrote
|
|
1073
|
+
// it no mass of its own.
|
|
1074
|
+
const ASSUMED_FULL_MASS = 20;
|
|
1075
|
+
const HUNGRY_FRACTION = 0.5;
|
|
1076
|
+
const DEFAULT_FOOD_MASS = 1;
|
|
1077
|
+
|
|
1078
|
+
/** A stable small number for a string, so the same dig always opens the same
|
|
1079
|
+
* room: this world writes no randomness anywhere, and a re-run that differed
|
|
1080
|
+
* would make the fold's own history unreproducible. Pure. */
|
|
1081
|
+
function stableIndex(seed, span) {
|
|
1082
|
+
let h = 0;
|
|
1083
|
+
for (const ch of String(seed)) h = (h * 31 + ch.codePointAt(0)) % 100003;
|
|
1084
|
+
return h % span;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** An unused id for a newly dug room, reading as the room it was dug from
|
|
1088
|
+
* plus the direction ("garden-down"). A collision takes a numeric suffix, so
|
|
1089
|
+
* digging never renames or overwrites a room that already stands. Pure. */
|
|
1090
|
+
function freshRoomId(rows, here, direction) {
|
|
1091
|
+
const base = `${here}-${direction}`;
|
|
1092
|
+
const taken = (id) => (rows || []).some((r) => r.subject === id || r.object === id);
|
|
1093
|
+
if (!taken(base)) return base;
|
|
1094
|
+
for (let n = 2; n <= (rows || []).length + 2; n += 1) {
|
|
1095
|
+
if (!taken(`${base}-${n}`)) return `${base}-${n}`;
|
|
1096
|
+
}
|
|
1097
|
+
return `${base}-${(rows || []).length + 3}`;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** An unused id for a freshly dug object, reading as its plain kind and a
|
|
1101
|
+
* small number ("carrot-1"). The short id is what keeps a pouch readable:
|
|
1102
|
+
* naming a spawned object after the room it came out of inherits that room's
|
|
1103
|
+
* whole nested dig path ("carrot-sett-1-north-east-east"), which is an id, not
|
|
1104
|
+
* a name anyone can read. `alsoTaken` holds the ids minted earlier in this
|
|
1105
|
+
* same dig, which are not in `rows` yet. Pure. */
|
|
1106
|
+
function freshObjectId(rows, kind, alsoTaken) {
|
|
1107
|
+
const taken = (id) => alsoTaken.has(id) || (rows || []).some((r) => r.subject === id || r.object === id);
|
|
1108
|
+
for (let n = 1; n <= (rows || []).length + 2; n += 1) {
|
|
1109
|
+
if (!taken(`${kind}-${n}`)) return `${kind}-${n}`;
|
|
1110
|
+
}
|
|
1111
|
+
return `${kind}-${(rows || []).length + 3}`;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** The kinds a room kind declares for one of the spawn pools, in the order the
|
|
1115
|
+
* world wrote them, or `fallback` when it declares none. Pure. */
|
|
1116
|
+
function declaredKindsOr(rows, roomClass, predicate, fallback) {
|
|
1117
|
+
const declared = factObjects(rows, roomClass, predicate);
|
|
1118
|
+
return declared.length ? declared : fallback;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** The mass row a freshly minted instance needs, copied off its own class, or
|
|
1122
|
+
* nothing when the class declares no mass. eat reads the instance's mass, so a
|
|
1123
|
+
* dug carrot with none would be worth the flat default however the world
|
|
1124
|
+
* values a carrot. Pure. */
|
|
1125
|
+
function classMassFacts(rows, instance, kind) {
|
|
1126
|
+
const mass = factObjects(rows, kind, MASS_PREDICATE)[0];
|
|
1127
|
+
return mass ? [{ subject: instance, predicate: MASS_PREDICATE, object: mass }] : [];
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** What a dig reads like: a bare tunnel, a scrap or two in the loose earth, or
|
|
1131
|
+
* a den — and, when somebody lives in it, who looked up. Pure. */
|
|
1132
|
+
function digNarration(direction, { isDen, spawned, resident }) {
|
|
1133
|
+
const opened = isDen
|
|
1134
|
+
? `you dig ${direction} and break into a den somebody hollowed out.`
|
|
1135
|
+
: `you dig ${direction} and open up a new room.`;
|
|
1136
|
+
const held = spawned.length
|
|
1137
|
+
? ` ${isDen ? "Stored in it" : "In the loose earth"}: the ${spawned.join(", the ")}.`
|
|
1138
|
+
: " There's nothing in it but bare earth.";
|
|
1139
|
+
return `${opened}${held}${resident ? ` The ${resident} lives here, and looks up as you come through.` : ""}`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** What a thing should be CALLED on screen: its declared display name, else
|
|
1143
|
+
* its own id. A dug object carries one so a pouch can list "carrot" while the
|
|
1144
|
+
* world keeps the distinct id ("carrot-1") every verb resolves against. Pure. */
|
|
1145
|
+
export function displayNameOf(rows, subject) {
|
|
1146
|
+
return factObjects(rows, subject, DISPLAY_NAME_PREDICATE)[0] ?? subject;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
629
1149
|
/** A container's open/locked status, stated plainly, and (only once already
|
|
630
1150
|
* open) its visible contents — the one thing examine/talk's reused
|
|
631
1151
|
* worldDigest call never states on its own, since mgx:is-open is a
|
|
@@ -668,7 +1188,8 @@ function containerDatatypeState(state, object) {
|
|
|
668
1188
|
* thing's current location, a hidden one included: talking to the staff is
|
|
669
1189
|
* the sanctioned way to learn a hiding place, while the where-is aside keeps
|
|
670
1190
|
* declining. `knows-objective` states the quest. `knows-about` topics come
|
|
671
|
-
* back for the caller to digest
|
|
1191
|
+
* back for the caller to digest, each one read from its newest surviving
|
|
1192
|
+
* claim. Pure. */
|
|
672
1193
|
export function personKnowledgeLines(rows, state, person) {
|
|
673
1194
|
const lines = [];
|
|
674
1195
|
for (const objective of factObjects(rows, person, "mgx:knows-objective")) {
|
|
@@ -681,17 +1202,29 @@ export function personKnowledgeLines(rows, state, person) {
|
|
|
681
1202
|
? `you'll find the ${thing} in the ${place.object}.`
|
|
682
1203
|
: `the ${thing} is in the ${place.object}.`);
|
|
683
1204
|
}
|
|
684
|
-
return { lines, aboutTopics:
|
|
1205
|
+
return { lines, aboutTopics: currentKnowsAboutTopics(rows, person) };
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** The FOOD_CLASS things `person` durably knows about — from being told, or
|
|
1209
|
+
* from having examined them itself (the mgx:knows-about facts
|
|
1210
|
+
* recordTold/recordExamined write, read back exactly like
|
|
1211
|
+
* personKnowledgeLines's own aboutTopics), filtered to whatever's
|
|
1212
|
+
* objectClassChain reaches "food". Unlike personKnowledgeLines's topics, a
|
|
1213
|
+
* food query has no per-topic sub-digest to hand back, so this returns the
|
|
1214
|
+
* plain list of known food things rather than a {lines, topics} pair. Pure. */
|
|
1215
|
+
export function personKnownFoodLines(rows, state, person) {
|
|
1216
|
+
return currentKnowsAboutTopics(rows, person)
|
|
1217
|
+
.filter((thing) => objectClassChain(rows, thing).includes(FOOD_CLASS));
|
|
685
1218
|
}
|
|
686
1219
|
|
|
687
1220
|
/** What a person can report from where they stand this turn — derived each
|
|
688
1221
|
* turn, never stored: who and what shares their room, each container's
|
|
689
1222
|
* open/locked status, and what unlocks a locked one there. Pure. */
|
|
690
|
-
export function personRoomReport(rows, state, person) {
|
|
1223
|
+
export function personRoomReport(rows, state, person, actingSubject = "player") {
|
|
691
1224
|
const room = state.placements.get(person)?.object ?? null;
|
|
692
1225
|
if (!room) return "";
|
|
693
1226
|
const here = [...state.placements.keys()]
|
|
694
|
-
.filter((s) => s !== person && s !==
|
|
1227
|
+
.filter((s) => s !== person && s !== actingSubject && visibleRoomOf(s, { rows, state }) === room)
|
|
695
1228
|
.sort();
|
|
696
1229
|
const parts = [];
|
|
697
1230
|
if (here.length) parts.push(`here in the ${room}: the ${here.join(", the ")}.`);
|
|
@@ -706,11 +1239,11 @@ export function personRoomReport(rows, state, person) {
|
|
|
706
1239
|
return parts.join(" ");
|
|
707
1240
|
}
|
|
708
1241
|
|
|
709
|
-
async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
1242
|
+
export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject = "player" }) {
|
|
710
1243
|
const memory = await loadMemory(memoryDir);
|
|
711
1244
|
const rows = readFactRows(memory);
|
|
712
1245
|
const state = foldWorldState(worldActionRows(rows));
|
|
713
|
-
const here = state.placements.get(
|
|
1246
|
+
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
714
1247
|
const noteFor = (detail) => `ADVENTURE — ${detail}`;
|
|
715
1248
|
|
|
716
1249
|
if (cmd.residue?.length) {
|
|
@@ -727,10 +1260,17 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
727
1260
|
{ miss: true },
|
|
728
1261
|
);
|
|
729
1262
|
}
|
|
1263
|
+
if (OUT_OF_PLAY_PLACES.has(here)) {
|
|
1264
|
+
return answer(
|
|
1265
|
+
`${outOfPlayPhrase(actingSubject, here)} — it takes no more turns in this world.`,
|
|
1266
|
+
noteFor(`${cmd.verb} — ${actingSubject} is placed out of play (${here}); every command it gives declines from here on`),
|
|
1267
|
+
{ miss: true },
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
730
1270
|
|
|
731
1271
|
if (cmd.verb === "look" && !cmd.object) {
|
|
732
|
-
const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph });
|
|
733
|
-
const actions = roomAffordances(rows, state, here);
|
|
1272
|
+
const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
1273
|
+
const actions = roomAffordances(rows, state, here, actingSubject);
|
|
734
1274
|
return answer(
|
|
735
1275
|
`${digest ?? `you are in the ${here}. Nothing more about it is written down yet.`}${affordanceSuffix(actions)}`,
|
|
736
1276
|
noteFor(`look — an extractive completions digest over the current world facts mentioning "${here}"; appended the room's roomAffordances action list`),
|
|
@@ -744,7 +1284,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
744
1284
|
// null for anything held by the player) — examine and look still apply to
|
|
745
1285
|
// it, the same way "what am I carrying" already reads inventory contents.
|
|
746
1286
|
// talk has no carried exception: NPCs are never portable.
|
|
747
|
-
const carried = (cmd.verb === "examine" || cmd.verb === "look") &&
|
|
1287
|
+
const carried = (cmd.verb === "examine" || cmd.verb === "look") && carriedBy(state, object, actingSubject);
|
|
748
1288
|
// The room the player is standing in is never the SUBJECT of a placement
|
|
749
1289
|
// fact (only ever the OBJECT other things are placed in), so
|
|
750
1290
|
// visibleRoomOf(object) can never equal `here` for a room's own name —
|
|
@@ -776,7 +1316,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
776
1316
|
{ miss: true },
|
|
777
1317
|
);
|
|
778
1318
|
}
|
|
779
|
-
const person =
|
|
1319
|
+
const person = isCastMember(rows, state, object);
|
|
780
1320
|
// "look <object>" on a real placed prop is the grounded close look: every
|
|
781
1321
|
// physical fact the world writes about the thing (its placement, its
|
|
782
1322
|
// within-room position, any datatype property — all via the SAME
|
|
@@ -786,7 +1326,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
786
1326
|
// placed facts of its own, so it falls through to the examine digest below
|
|
787
1327
|
// (the same general-knowledge answer "what is a flower" gives).
|
|
788
1328
|
if (cmd.verb === "look" && !backgroundOnlyMention(rows, state, object)) {
|
|
789
|
-
const propLines = objectLookProperties(rows, state, object);
|
|
1329
|
+
const propLines = objectLookProperties(rows, state, object, actingSubject);
|
|
790
1330
|
const chain = objectClassChain(rows, object);
|
|
791
1331
|
const parts = [`you look closely at the ${object}.`];
|
|
792
1332
|
if (propLines.length) parts.push(propLines.join(" "));
|
|
@@ -805,10 +1345,10 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
805
1345
|
const { lines, aboutTopics } = personKnowledgeLines(rows, state, object);
|
|
806
1346
|
const aboutLines = [];
|
|
807
1347
|
for (const topic of aboutTopics) {
|
|
808
|
-
const digested = await worldDigest(topic, { memoryDir, memory, rows, state, graph });
|
|
1348
|
+
const digested = await worldDigest(topic, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
809
1349
|
if (digested) aboutLines.push(digested);
|
|
810
1350
|
}
|
|
811
|
-
const report = personRoomReport(rows, state, object);
|
|
1351
|
+
const report = personRoomReport(rows, state, object, actingSubject);
|
|
812
1352
|
const said = [...lines, ...aboutLines, report].filter(Boolean).join(" ");
|
|
813
1353
|
return answer(
|
|
814
1354
|
said ? `the ${object} says: ${said}` : `the ${object} has nothing to tell you right now.`,
|
|
@@ -816,7 +1356,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
816
1356
|
{ goal: `talk to the ${object}` },
|
|
817
1357
|
);
|
|
818
1358
|
}
|
|
819
|
-
const digest = await worldDigest(object, { memoryDir, memory, rows, state, graph });
|
|
1359
|
+
const digest = await worldDigest(object, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
820
1360
|
const body = digest ?? `nothing more about the ${object} is written down yet.`;
|
|
821
1361
|
const containerNote = !person && isContainer(rows, object) ? ` ${containerStatusPhrase(object, { state })}` : "";
|
|
822
1362
|
// Framing follows the VERB the player typed, not the object's type: talking
|
|
@@ -858,8 +1398,8 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
858
1398
|
const freshMemory = await loadMemory(memoryDir);
|
|
859
1399
|
const freshRows = readFactRows(freshMemory);
|
|
860
1400
|
const freshState = foldWorldState(worldActionRows(freshRows));
|
|
861
|
-
const relookDigest = await worldDigest(playerRoomAfter, { memoryDir, memory: freshMemory, rows: freshRows, state: freshState, graph });
|
|
862
|
-
const actions = roomAffordances(freshRows, freshState, playerRoomAfter);
|
|
1401
|
+
const relookDigest = await worldDigest(playerRoomAfter, { memoryDir, memory: freshMemory, rows: freshRows, state: freshState, graph, actingSubject });
|
|
1402
|
+
const actions = roomAffordances(freshRows, freshState, playerRoomAfter, actingSubject);
|
|
863
1403
|
const relook = `you are in the ${playerRoomAfter}. ${relookDigest ?? "Nothing more about it is written down yet."}${affordanceSuffix(actions)}`;
|
|
864
1404
|
return answer(
|
|
865
1405
|
`${text2} ${relook}`,
|
|
@@ -879,10 +1419,25 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
879
1419
|
{ miss: true },
|
|
880
1420
|
);
|
|
881
1421
|
}
|
|
1422
|
+
// A predator eats whatever walks in, and the room it guards is the one
|
|
1423
|
+
// room a move never comes back from — so this write bypasses commit()
|
|
1424
|
+
// entirely: the auto-relook there would describe a room the mover is no
|
|
1425
|
+
// longer standing in, and the world has nobody left to look with.
|
|
1426
|
+
const predator = predatorIn(rows, state, target);
|
|
1427
|
+
if (predator) {
|
|
1428
|
+
await writeWorldTurn(memoryDir, world, k, [
|
|
1429
|
+
{ subject: `${actingSubject}@turn${k}`, predicate: "mgx:currently-in", object: CONSUMED_PLACE },
|
|
1430
|
+
], cache);
|
|
1431
|
+
return answer(
|
|
1432
|
+
`you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
|
|
1433
|
+
noteFor(`go — the ${target} holds the predator ${predator}; ${actingSubject} is placed out of play at turn ${k} and takes no further turns`),
|
|
1434
|
+
{ goal: `move through the world (eaten by the ${predator} in the ${target})` },
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
882
1437
|
return commit(
|
|
883
|
-
[{ subject:
|
|
1438
|
+
[{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
|
|
884
1439
|
`you go ${cmd.direction}. Now in the ${target}.`,
|
|
885
|
-
`go — the taught "go" family fired;
|
|
1440
|
+
`go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
|
|
886
1441
|
`move through the world (now in the ${target})`,
|
|
887
1442
|
target,
|
|
888
1443
|
);
|
|
@@ -892,7 +1447,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
892
1447
|
if (isTyped(rows, object, "room")) {
|
|
893
1448
|
return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
|
|
894
1449
|
}
|
|
895
|
-
if (
|
|
1450
|
+
if (carriedBy(state, object, actingSubject)) {
|
|
896
1451
|
return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
|
|
897
1452
|
}
|
|
898
1453
|
if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
|
|
@@ -916,7 +1471,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
916
1471
|
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 });
|
|
917
1472
|
}
|
|
918
1473
|
return commit(
|
|
919
|
-
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object:
|
|
1474
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
|
|
920
1475
|
`you take the ${object}.`,
|
|
921
1476
|
`take — the taught "take" family fired; ${object} is now carried`,
|
|
922
1477
|
`carry the ${object}`,
|
|
@@ -924,7 +1479,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
924
1479
|
}
|
|
925
1480
|
|
|
926
1481
|
if (cmd.verb === "drop" || cmd.verb === "give") {
|
|
927
|
-
if (!
|
|
1482
|
+
if (!carriedBy(state, object, actingSubject)) {
|
|
928
1483
|
return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
|
|
929
1484
|
}
|
|
930
1485
|
if (cmd.verb === "drop") {
|
|
@@ -936,8 +1491,8 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
936
1491
|
);
|
|
937
1492
|
}
|
|
938
1493
|
const receiver = cmd.indirectObject;
|
|
939
|
-
if (!
|
|
940
|
-
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't
|
|
1494
|
+
if (!isCastMember(rows, state, receiver) || state.placements.get(receiver)?.object !== here) {
|
|
1495
|
+
return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't one of the cast standing in the ${here}; precondition declined by name`), { miss: true });
|
|
941
1496
|
}
|
|
942
1497
|
return commit(
|
|
943
1498
|
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
|
|
@@ -947,6 +1502,176 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
947
1502
|
);
|
|
948
1503
|
}
|
|
949
1504
|
|
|
1505
|
+
if (cmd.verb === "dig") {
|
|
1506
|
+
const direction = cmd.direction;
|
|
1507
|
+
if (state.exits.get(here)?.get(direction)) {
|
|
1508
|
+
return answer(
|
|
1509
|
+
`there's already an exit ${direction} from the ${here}.`,
|
|
1510
|
+
noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
|
|
1511
|
+
{ miss: true },
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
const back = OPPOSITE_DIRECTION.get(direction);
|
|
1515
|
+
if (!back) {
|
|
1516
|
+
return answer(
|
|
1517
|
+
`I don't know which way back a ${direction} tunnel would run.`,
|
|
1518
|
+
noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
|
|
1519
|
+
{ miss: true },
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
const roomKind = roomKindOf(rows, here);
|
|
1523
|
+
const dugKind = (DIGGABLE_BY_ROOM_KIND.get(roomKind) ?? new Map()).get(direction) ?? null;
|
|
1524
|
+
if (!dugKind) {
|
|
1525
|
+
return answer(
|
|
1526
|
+
DIG_DECLINE_BY_ROOM_KIND[roomKind](here, direction),
|
|
1527
|
+
noteFor(`dig — the ${here} is an ${roomKind} room, which cannot be dug ${direction}; declined by the room's own kind`),
|
|
1528
|
+
{ miss: true },
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
if (atDigBoundary(rows, state, here)) {
|
|
1532
|
+
const reach = roomDistanceFromOrigin(rows, state, here);
|
|
1533
|
+
return answer(
|
|
1534
|
+
`the earth ${direction} of the ${here} is packed hard and endless — you have reached the far edge of the burrow.`,
|
|
1535
|
+
noteFor(reach === null
|
|
1536
|
+
? `dig — no chain of exits joins the ${here} to the ${originRoomOf(rows)}, so there is no distance to measure a dig against; declined`
|
|
1537
|
+
: `dig — the ${here} stands ${reach} rooms from the ${originRoomOf(rows)}, and this world digs ${digReachOf(rows)}; declined by distance from the origin`),
|
|
1538
|
+
{ miss: true },
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
const dug = freshRoomId(rows, here, direction);
|
|
1542
|
+
const denChanceIn = declaredCountOr(rows, dugKind, DEN_CHANCE_PREDICATE, DEFAULT_DEN_CHANCE_IN);
|
|
1543
|
+
const isDen = dugKind === "underground-space" && stableIndex(`den:${dug}`, denChanceIn) === 0;
|
|
1544
|
+
const spawnMax = declaredCountOr(rows, dugKind, DIG_SPAWN_MAX_PREDICATE, DEFAULT_DIG_SPAWN_MAX);
|
|
1545
|
+
const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
|
|
1546
|
+
const spawnedKinds = isDen
|
|
1547
|
+
? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
|
|
1548
|
+
: declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
|
|
1549
|
+
const minted = new Set();
|
|
1550
|
+
const spawned = spawnedKinds.map((kind) => {
|
|
1551
|
+
const id = freshObjectId(rows, kind, minted);
|
|
1552
|
+
minted.add(id);
|
|
1553
|
+
return id;
|
|
1554
|
+
});
|
|
1555
|
+
const residentChanceIn = declaredCountOr(rows, dugKind, DEN_RESIDENT_CHANCE_PREDICATE, DEFAULT_DEN_RESIDENT_CHANCE_IN);
|
|
1556
|
+
const residentKind = isDen && stableIndex(`resident:${dug}`, residentChanceIn) === 0
|
|
1557
|
+
? factObjects(rows, dugKind, DEN_RESIDENT_PREDICATE)[0] ?? null
|
|
1558
|
+
: null;
|
|
1559
|
+
const resident = residentKind ? freshObjectId(rows, residentKind, minted) : null;
|
|
1560
|
+
return commit(
|
|
1561
|
+
[
|
|
1562
|
+
{ subject: dug, predicate: "rdf:type", object: "room" },
|
|
1563
|
+
{ subject: dug, predicate: "rdf:type", object: dugKind },
|
|
1564
|
+
...(isDen ? [{ subject: dug, predicate: "rdf:type", object: DEN_ROOM_CLASS }] : []),
|
|
1565
|
+
{ subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
|
|
1566
|
+
{ subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
|
|
1567
|
+
// Typed to its OWN kind, not a flat "portable" — a spawned kind the
|
|
1568
|
+
// world declares rdfs:subClassOf food needs its real class reachable
|
|
1569
|
+
// here for isFood's own objectClassChain walk, or digging up "carrot-1"
|
|
1570
|
+
// would still read as inedible scenery. The class's own mass copies
|
|
1571
|
+
// onto the instance for the same reason: eat reads the instance.
|
|
1572
|
+
...spawnedKinds.flatMap((kind, i) => ([
|
|
1573
|
+
{ subject: spawned[i], predicate: "rdf:type", object: kind },
|
|
1574
|
+
{ subject: spawned[i], predicate: DISPLAY_NAME_PREDICATE, object: kind },
|
|
1575
|
+
{ subject: spawned[i], predicate: "mgx:located-in", object: dug },
|
|
1576
|
+
...classMassFacts(rows, spawned[i], kind),
|
|
1577
|
+
])),
|
|
1578
|
+
// A resident is placed with currently-in, the predicate that makes an
|
|
1579
|
+
// individual one of the cast, and knows about what its own den holds —
|
|
1580
|
+
// so an animal that digs one out has somebody new to ask about food.
|
|
1581
|
+
...(resident ? [
|
|
1582
|
+
{ subject: resident, predicate: "rdf:type", object: residentKind },
|
|
1583
|
+
{ subject: resident, predicate: DISPLAY_NAME_PREDICATE, object: residentKind },
|
|
1584
|
+
{ subject: resident, predicate: "mgx:currently-in", object: dug },
|
|
1585
|
+
...classMassFacts(rows, resident, residentKind),
|
|
1586
|
+
...spawned.map((thing) => ({ subject: resident, predicate: KNOWS_ABOUT_PREDICATE, object: thing })),
|
|
1587
|
+
] : []),
|
|
1588
|
+
],
|
|
1589
|
+
digNarration(direction, { isDen, spawned, resident }),
|
|
1590
|
+
`dig — minted the ${isDen ? `${DEN_ROOM_CLASS} ` : ""}${dugKind} ${dug} with exits both ways (${direction} out, ${back} back)${spawned.length ? `, and ${spawned.length} object(s) in it` : ""}${resident ? `, lived in by ${resident}` : ""}; digging spends the turn, so the digger stays in the ${here}`,
|
|
1591
|
+
`dig ${direction} out of the ${here}`,
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (cmd.verb === "eat") {
|
|
1596
|
+
const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
|
|
1597
|
+
if (!present) {
|
|
1598
|
+
return answer(
|
|
1599
|
+
`I don't see a ${object} here.`,
|
|
1600
|
+
noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
|
|
1601
|
+
{ miss: true },
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
|
|
1605
|
+
return answer(
|
|
1606
|
+
`the ${object} isn't food.`,
|
|
1607
|
+
noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
|
|
1608
|
+
{ miss: true },
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
const eaterMass = state.masses.get(actingSubject)?.value ?? null;
|
|
1612
|
+
if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
|
|
1613
|
+
return answer(
|
|
1614
|
+
`you're too full to eat the ${object}.`,
|
|
1615
|
+
noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
|
|
1616
|
+
{ miss: true },
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
|
|
1620
|
+
const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
|
|
1621
|
+
// Eating is the one act that ends a thing, so the eater is the one witness
|
|
1622
|
+
// whose knowledge of it goes out of date on the spot. Every route into the
|
|
1623
|
+
// eat verb — a typed command, a scripted mud turn — passes here, so the
|
|
1624
|
+
// claim gets written once for all of them.
|
|
1625
|
+
await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
|
|
1626
|
+
return commit(
|
|
1627
|
+
[
|
|
1628
|
+
{ subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
|
|
1629
|
+
{ subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
|
|
1630
|
+
],
|
|
1631
|
+
`you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
|
|
1632
|
+
`eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
|
|
1633
|
+
`eat the ${object}`,
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
if (cmd.verb === "put") {
|
|
1638
|
+
const container = cmd.indirectObject;
|
|
1639
|
+
if (!carriedBy(state, object, actingSubject)) {
|
|
1640
|
+
return answer(
|
|
1641
|
+
`you're not carrying the ${object}.`,
|
|
1642
|
+
noteFor(`put — ${object} isn't carried; precondition declined by name`),
|
|
1643
|
+
{ miss: true },
|
|
1644
|
+
);
|
|
1645
|
+
}
|
|
1646
|
+
if (visibleRoomOf(container, { rows, state }) !== here) {
|
|
1647
|
+
return answer(
|
|
1648
|
+
`I don't see a ${container} here.`,
|
|
1649
|
+
noteFor(`put — ${container} isn't visible in the ${here}; declined`),
|
|
1650
|
+
{ miss: true },
|
|
1651
|
+
);
|
|
1652
|
+
}
|
|
1653
|
+
if (!isContainer(rows, container)) {
|
|
1654
|
+
return answer(
|
|
1655
|
+
`the ${container} doesn't hold things.`,
|
|
1656
|
+
noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
|
|
1657
|
+
{ miss: true },
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
if (!state.openness.get(container)?.open) {
|
|
1661
|
+
return answer(
|
|
1662
|
+
`the ${container} is closed.`,
|
|
1663
|
+
noteFor(`put — the ${container} isn't open; precondition declined by name`),
|
|
1664
|
+
{ miss: true },
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
return commit(
|
|
1668
|
+
[{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
|
|
1669
|
+
`you put the ${object} in the ${container}.`,
|
|
1670
|
+
`put — the taught "put" family fired; the ${object} now sits in the ${container}`,
|
|
1671
|
+
`put the ${object} in the ${container}`,
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
950
1675
|
// open / unlock / close — the container verbs. presence and container-ness
|
|
951
1676
|
// stay hand-checked here (visibility gating, not a state precondition);
|
|
952
1677
|
// unlock's instrument match stays fully hand-written below it too — it
|
|
@@ -981,14 +1706,14 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
981
1706
|
);
|
|
982
1707
|
}
|
|
983
1708
|
const factState = containerDatatypeState(state, object);
|
|
984
|
-
const failed = taughtAction.preconds.find((p) => !precondHolds(p,
|
|
1709
|
+
const failed = taughtAction.preconds.find((p) => !precondHolds(p, actingSubject, object, factState, domain));
|
|
985
1710
|
if (failed) {
|
|
986
1711
|
const text = failed.predicate === "mgx:stands-locked-in"
|
|
987
1712
|
? `the ${object} is locked.`
|
|
988
1713
|
: cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
|
|
989
1714
|
return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
|
|
990
1715
|
}
|
|
991
|
-
const effSubject = roleBinding(effect.subjectRole,
|
|
1716
|
+
const effSubject = roleBinding(effect.subjectRole, actingSubject, object, domain);
|
|
992
1717
|
const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
|
|
993
1718
|
|
|
994
1719
|
if (cmd.verb === "open") {
|
|
@@ -1035,7 +1760,7 @@ async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache }) {
|
|
|
1035
1760
|
{ miss: true },
|
|
1036
1761
|
);
|
|
1037
1762
|
}
|
|
1038
|
-
if (!
|
|
1763
|
+
if (!carriedBy(state, cmd.instrument, actingSubject)) {
|
|
1039
1764
|
return answer(
|
|
1040
1765
|
`you're not carrying the ${cmd.instrument}.`,
|
|
1041
1766
|
noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
|
|
@@ -1076,7 +1801,7 @@ const WORLD_IS_OPEN_RE = /^is\s+(?:the\s+|a\s+|an\s+)?(.+?)\s+(open|closed|shut)
|
|
|
1076
1801
|
* when the asked thing has no placement in the world, so an ordinary
|
|
1077
1802
|
* locative question (a code symbol, a taught board piece) keeps its lane. A
|
|
1078
1803
|
* hidden thing is declined without naming its hiding place. */
|
|
1079
|
-
async function worldWhereAnswer(line, { memoryDir }) {
|
|
1804
|
+
async function worldWhereAnswer(line, { memoryDir, actingSubject = "player" }) {
|
|
1080
1805
|
const m = String(line).match(WORLD_WHERE_RE);
|
|
1081
1806
|
if (!m) return null;
|
|
1082
1807
|
const thing = normFactTerm(m[1]);
|
|
@@ -1092,14 +1817,21 @@ async function worldWhereAnswer(line, { memoryDir }) {
|
|
|
1092
1817
|
{ miss: true, goal: `locate the ${thing}` },
|
|
1093
1818
|
);
|
|
1094
1819
|
}
|
|
1095
|
-
if (
|
|
1820
|
+
if (OUT_OF_PLAY_PLACES.has(place.object)) {
|
|
1821
|
+
return answer(
|
|
1822
|
+
`${outOfPlayPhrase(thing, place.object)} — it's gone from the world.`,
|
|
1823
|
+
`ADVENTURE — where-aside: ${thing} is out of play (${place.object}), so it has no place left to name`,
|
|
1824
|
+
{ goal: `locate the ${thing}` },
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1827
|
+
if (thing === actingSubject) {
|
|
1096
1828
|
return answer(
|
|
1097
1829
|
`you are in the ${place.object}.`,
|
|
1098
1830
|
"ADVENTURE — where-aside: the player's own room, from the current world fold",
|
|
1099
1831
|
{ goal: "check where you are" },
|
|
1100
1832
|
);
|
|
1101
1833
|
}
|
|
1102
|
-
if (place.object ===
|
|
1834
|
+
if (place.object === actingSubject) {
|
|
1103
1835
|
return answer(
|
|
1104
1836
|
`you are carrying the ${thing}.`,
|
|
1105
1837
|
`ADVENTURE — where-aside: ${thing} is carried, from the current world fold`,
|
|
@@ -1143,20 +1875,54 @@ async function worldOpennessAnswer(line, { memoryDir }) {
|
|
|
1143
1875
|
const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
|
|
1144
1876
|
const WORLD_OPTIONS_RE = /^(?:what\s+can\s+i\s+do(?:\s+(?:here|now))?|what\s+are\s+my\s+options|what\s+(?:should|do)\s+i\s+do(?:\s+(?:here|now))?|what\s+now)[?.!\s]*$/i;
|
|
1145
1877
|
const WORLD_QUEST_RE = /^(?:what(?:'s|\s+is)\s+(?:the\s+|my\s+)?(?:quest|goal|objective|mission|aim)|what\s+am\s+i\s+(?:trying\s+to\s+do|(?:supposed|meant)\s+to\s+do)|what\s+do\s+i\s+do\s+here)[?.!\s]*$/i;
|
|
1878
|
+
// "who is here" — the room's cast, the question a shared world invites the
|
|
1879
|
+
// moment a second animal walks in. Answered from the same currently-in
|
|
1880
|
+
// placements the talk verb resolves against, so who is named is exactly who
|
|
1881
|
+
// can be talked to.
|
|
1882
|
+
const WORLD_WHO_HERE_RE =
|
|
1883
|
+
/^(?:who(?:'s|\s+is|\s+are)\s+(?:else\s+)?(?:here|in\s+(?:the\s+|this\s+)?room|with\s+me)|who\s+else\s+is\s+(?:here|around))[?.!\s]*$/i;
|
|
1884
|
+
// "what food do you know about" and its natural variants — the asking
|
|
1885
|
+
// character's OWN durable food knowledge (personKnownFoodLines), never the
|
|
1886
|
+
// whole world's food. The trailing "about" is optional, "know" swaps for
|
|
1887
|
+
// "found"/"seen"/"heard about", and the "what do you know about food"
|
|
1888
|
+
// inversion and a plain yes/no lead-in both count: every one of these is the
|
|
1889
|
+
// same question, and a phrasing this lane doesn't recognise leaves the world
|
|
1890
|
+
// entirely and comes back answered as vocabulary.
|
|
1891
|
+
const WORLD_KNOWN_FOOD_RE = new RegExp(
|
|
1892
|
+
"^(?:"
|
|
1893
|
+
+ "what\\s+foods?\\s+(?:do\\s+you\\s+know(?:\\s+about)?|have\\s+you\\s+(?:found|seen|heard\\s+(?:about|of))|do\\s+you\\s+know\\s+of)"
|
|
1894
|
+
+ "|what\\s+do\\s+you\\s+know\\s+about\\s+(?:any\\s+)?foods?"
|
|
1895
|
+
+ "|do\\s+you\\s+know\\s+(?:about|of)\\s+(?:any\\s+)?foods?"
|
|
1896
|
+
+ "|where\\s+is\\s+(?:the\\s+)?food"
|
|
1897
|
+
+ ")[?.!\\s]*$",
|
|
1898
|
+
"i",
|
|
1899
|
+
);
|
|
1146
1900
|
|
|
1147
1901
|
/** The in-game orientation asides, answered from the world fold: the player's
|
|
1148
1902
|
* room, the room's real affordances, and the world's objective. Null when the
|
|
1149
1903
|
* line is none of them, so an ordinary question keeps its lane. */
|
|
1150
|
-
async function worldContextAnswer(line, { memoryDir }) {
|
|
1904
|
+
async function worldContextAnswer(line, { memoryDir, actingSubject = "player" }) {
|
|
1151
1905
|
const l = String(line).trim();
|
|
1152
1906
|
const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
|
|
1153
1907
|
const asksOptions = WORLD_OPTIONS_RE.test(l);
|
|
1154
1908
|
const asksQuest = WORLD_QUEST_RE.test(l);
|
|
1155
|
-
|
|
1909
|
+
const asksWhoIsHere = WORLD_WHO_HERE_RE.test(l);
|
|
1910
|
+
if (!asksWhere && !asksOptions && !asksQuest && !asksWhoIsHere) return null;
|
|
1156
1911
|
let rows;
|
|
1157
1912
|
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
1158
1913
|
const state = foldWorldState(worldActionRows(rows));
|
|
1159
|
-
const here = state.placements.get(
|
|
1914
|
+
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
1915
|
+
|
|
1916
|
+
if (asksWhoIsHere) {
|
|
1917
|
+
const cast = here ? castInRoom(rows, state, here, actingSubject) : [];
|
|
1918
|
+
return answer(
|
|
1919
|
+
cast.length
|
|
1920
|
+
? `here with you in the ${here}: the ${cast.join(", the ")}. You can talk to ${cast.length > 1 ? "any of them" : `the ${cast[0]}`}.`
|
|
1921
|
+
: `nobody else is${here ? ` in the ${here}` : " here"} right now.`,
|
|
1922
|
+
`ADVENTURE — who-is-here aside: the ${here}'s cast from the current placements fold, the same set the talk verb resolves against`,
|
|
1923
|
+
{ goal: "see who else is here" },
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1160
1926
|
|
|
1161
1927
|
if (asksWhere) {
|
|
1162
1928
|
return here
|
|
@@ -1165,7 +1931,7 @@ async function worldContextAnswer(line, { memoryDir }) {
|
|
|
1165
1931
|
}
|
|
1166
1932
|
|
|
1167
1933
|
if (asksOptions) {
|
|
1168
|
-
const actions = here ? roomAffordances(rows, state, here) : [];
|
|
1934
|
+
const actions = here ? roomAffordances(rows, state, here, actingSubject) : [];
|
|
1169
1935
|
return answer(
|
|
1170
1936
|
actions.length ? `you can: ${actions.join(", ")}.` : `nothing obvious here — say "look" to look around${here ? ` the ${here}` : ""}.`,
|
|
1171
1937
|
`ADVENTURE — options aside: the ${here}'s roomAffordances, the same list "look" appends`,
|
|
@@ -1187,12 +1953,73 @@ async function worldContextAnswer(line, { memoryDir }) {
|
|
|
1187
1953
|
);
|
|
1188
1954
|
}
|
|
1189
1955
|
|
|
1190
|
-
|
|
1956
|
+
/** "what food do you know about" — the ASKING character's own durable food
|
|
1957
|
+
* knowledge, read from the same mgx:knows-about facts recordTold/
|
|
1958
|
+
* recordExamined write (personKnownFoodLines). An honest "you don't know of
|
|
1959
|
+
* any food yet" when none is known — a real, on-topic answer, not a miss,
|
|
1960
|
+
* the same convention inventoryAnswer's own empty-carry case already uses.
|
|
1961
|
+
* Null when the line isn't this aside, so an ordinary question keeps its
|
|
1962
|
+
* lane. */
|
|
1963
|
+
async function worldKnownFoodAnswer(line, { memoryDir, actingSubject = "player" }) {
|
|
1964
|
+
const l = String(line).trim();
|
|
1965
|
+
if (!WORLD_KNOWN_FOOD_RE.test(l)) return null;
|
|
1966
|
+
let rows;
|
|
1967
|
+
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
1968
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
1969
|
+
const foods = personKnownFoodLines(rows, state, actingSubject);
|
|
1970
|
+
if (!foods.length) {
|
|
1971
|
+
return answer(
|
|
1972
|
+
"you don't know of any food yet.",
|
|
1973
|
+
`ADVENTURE — known-food aside: ${actingSubject}'s mgx:knows-about facts reach no food-classed thing; the honest empty answer`,
|
|
1974
|
+
{ goal: "check what food you know about" },
|
|
1975
|
+
);
|
|
1976
|
+
}
|
|
1977
|
+
return answer(
|
|
1978
|
+
`you know about: the ${foods.join(", the ")}.`,
|
|
1979
|
+
`ADVENTURE — known-food aside: ${actingSubject}'s durable mgx:knows-about facts, filtered to the food class`,
|
|
1980
|
+
{ goal: "check what food you know about" },
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// A question that names one of the world's OWN minted ids — "sett-1",
|
|
1985
|
+
// "groundhog-1", "carrot-2" — can only be about this world: nothing else in
|
|
1986
|
+
// the session has ever heard that token. So when no world shape matched it,
|
|
1987
|
+
// the fall-through is a plain misroute, and in a session with no code graph it
|
|
1988
|
+
// comes back as the code-graph wall, which says nothing true about a burrow.
|
|
1989
|
+
// The gate is the hyphen: a world id that is a plain dictionary word ("lamp",
|
|
1990
|
+
// "garden") stays out of this, so an ordinary mid-game question about an
|
|
1991
|
+
// ordinary word keeps the lane it has always had.
|
|
1992
|
+
const WORLD_QUESTION_LEAD_RE =
|
|
1993
|
+
/^(?:who|what|where|which|how|why|when|tell\s+me|describe|do\s+you|does|is|are|can\s+you|any)\b/i;
|
|
1994
|
+
const WORLD_MINTED_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
1995
|
+
|
|
1996
|
+
/** A digest about the world-minted id a question names, or null when the line
|
|
1997
|
+
* is not a question, names none, or the world places nothing by that name. */
|
|
1998
|
+
async function worldMentionAnswer(line, { memoryDir, graph, actingSubject = "player" }) {
|
|
1999
|
+
const l = String(line).trim();
|
|
2000
|
+
if (!/\?\s*$/.test(l) && !WORLD_QUESTION_LEAD_RE.test(l)) return null;
|
|
2001
|
+
const spoken = new Set(l.toLowerCase().replace(/[?.!,;:"']/g, " ").split(/\s+/).filter(Boolean));
|
|
2002
|
+
let memory;
|
|
2003
|
+
try { memory = await loadMemory(memoryDir); } catch { return null; }
|
|
2004
|
+
const rows = readFactRows(memory);
|
|
2005
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2006
|
+
const named = worldIndividualNames(rows)
|
|
2007
|
+
.find((subject) => WORLD_MINTED_ID_RE.test(subject) && spoken.has(subject));
|
|
2008
|
+
if (!named) return null;
|
|
2009
|
+
const digest = await worldDigest(named, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
2010
|
+
return answer(
|
|
2011
|
+
digest ?? `nothing more about the ${named} is written down yet.`,
|
|
2012
|
+
`ADVENTURE — world-mention aside: "${named}" is an id this world minted, so the question is the world's to answer; digested from the current fold`,
|
|
2013
|
+
{ goal: `find out about the ${named}` },
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
async function inventoryAnswer({ memoryDir, graph, actingSubject = "player" }) {
|
|
1191
2018
|
const memory = await loadMemory(memoryDir);
|
|
1192
2019
|
const rows = readFactRows(memory);
|
|
1193
2020
|
const state = foldWorldState(worldActionRows(rows));
|
|
1194
2021
|
const carried = [...state.placements]
|
|
1195
|
-
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object ===
|
|
2022
|
+
.filter(([, p]) => p.predicate === "mgx:located-in" && p.object === actingSubject)
|
|
1196
2023
|
.map(([thing]) => thing)
|
|
1197
2024
|
.sort();
|
|
1198
2025
|
if (!carried.length) {
|
|
@@ -1202,7 +2029,7 @@ async function inventoryAnswer({ memoryDir, graph }) {
|
|
|
1202
2029
|
{ goal: "check what you carry" },
|
|
1203
2030
|
);
|
|
1204
2031
|
}
|
|
1205
|
-
const digest = await worldDigest(
|
|
2032
|
+
const digest = await worldDigest(actingSubject, { memoryDir, memory, rows, state, graph, actingSubject });
|
|
1206
2033
|
return answer(
|
|
1207
2034
|
digest ?? `you are carrying the ${carried.join(", the ")}.`,
|
|
1208
2035
|
"ADVENTURE — inventory: an extractive completions digest over the facts mentioning the player",
|
|
@@ -1246,14 +2073,14 @@ const commandHasPronoun = (cmd) => PRONOUN_SLOTS.some((s) => cmd[s] && OBJECT_PR
|
|
|
1246
2073
|
* real, actionable object from the current room when one is on show (else a
|
|
1247
2074
|
* static example). Never the "I don't know the word" line — the vocabulary
|
|
1248
2075
|
* misdiagnosis is unreachable for a pronoun. */
|
|
1249
|
-
async function noFocusPronounNudge(pronoun, { memoryDir }) {
|
|
2076
|
+
async function noFocusPronounNudge(pronoun, { memoryDir, actingSubject = "player" }) {
|
|
1250
2077
|
let example = null;
|
|
1251
2078
|
try {
|
|
1252
2079
|
const rows = readFactRows(await loadMemory(memoryDir));
|
|
1253
2080
|
const state = foldWorldState(worldActionRows(rows));
|
|
1254
|
-
const here = state.placements.get(
|
|
2081
|
+
const here = state.placements.get(actingSubject)?.object ?? null;
|
|
1255
2082
|
if (here) {
|
|
1256
|
-
for (const action of roomAffordances(rows, state, here)) {
|
|
2083
|
+
for (const action of roomAffordances(rows, state, here, actingSubject)) {
|
|
1257
2084
|
const m = action.match(/^(?:examine|take|open|unlock|talk to) (.+)$/);
|
|
1258
2085
|
if (m) { example = m[1]; break; }
|
|
1259
2086
|
}
|
|
@@ -1274,14 +2101,15 @@ async function noFocusPronounNudge(pronoun, { memoryDir }) {
|
|
|
1274
2101
|
* passes straight through untouched. All four surface pronouns
|
|
1275
2102
|
* (it/them/him/her) normalize to the one `it` probe, then bind to the newest
|
|
1276
2103
|
* referent THIS lane registered — the record may also hold code-graph
|
|
1277
|
-
* referents, so the bind is scoped to `lane: "adventure"`.
|
|
1278
|
-
|
|
2104
|
+
* referents, so the bind is scoped to `lane: "adventure"`. The record is one
|
|
2105
|
+
* per session, so several acting subjects sharing a world share one focus. */
|
|
2106
|
+
async function bindPronouns(cmd, { discourseHolder, memoryDir, actingSubject = "player" }) {
|
|
1279
2107
|
if (!commandHasPronoun(cmd)) return { cmd };
|
|
1280
2108
|
const probe = discourseHolder ? bindDiscourseForm(discourseHolder.record, "it") : null;
|
|
1281
2109
|
const focusTerm = (probe?.candidates || []).find((r) => r.from?.lane === "adventure")?.label ?? null;
|
|
1282
2110
|
if (!focusTerm) {
|
|
1283
2111
|
const pronoun = PRONOUN_SLOTS.map((s) => cmd[s]).find((v) => v && OBJECT_PRONOUNS.has(v));
|
|
1284
|
-
return { nudge: await noFocusPronounNudge(pronoun, { memoryDir }) };
|
|
2112
|
+
return { nudge: await noFocusPronounNudge(pronoun, { memoryDir, actingSubject }) };
|
|
1285
2113
|
}
|
|
1286
2114
|
const bound = { ...cmd };
|
|
1287
2115
|
for (const s of PRONOUN_SLOTS) {
|
|
@@ -1290,6 +2118,78 @@ async function bindPronouns(cmd, { discourseHolder, memoryDir }) {
|
|
|
1290
2118
|
return { cmd: bound };
|
|
1291
2119
|
}
|
|
1292
2120
|
|
|
2121
|
+
// ---- the world's own vocabulary ----------------------------------------------
|
|
2122
|
+
//
|
|
2123
|
+
// A world's minted ids are words only that world knows. "groundhog-1" is in no
|
|
2124
|
+
// dictionary, so the parser's lexicon gate rejects "talk to groundhog-1" as an
|
|
2125
|
+
// undeclared word and the whole command dies before the talk verb ever sees
|
|
2126
|
+
// it. Declaring those ids as PROPER NAMES for the duration of a world command
|
|
2127
|
+
// fixes that: a proper name outranks every other category, so the id resolves
|
|
2128
|
+
// as itself. Ids the core lexicon already knows are left out, so no ordinary
|
|
2129
|
+
// word changes category because a world happens to use it — and the extension
|
|
2130
|
+
// is scoped to this lane, so the teach and ask lanes keep the plain lexicon.
|
|
2131
|
+
|
|
2132
|
+
let worldLexiconCache = { key: null, base: null, lexicon: null };
|
|
2133
|
+
|
|
2134
|
+
function worldLexicon(rows, base) {
|
|
2135
|
+
const names = worldIndividualNames(rows).filter((name) => !classify(name, base));
|
|
2136
|
+
const key = names.join("");
|
|
2137
|
+
if (worldLexiconCache.base === base && worldLexiconCache.key === key) return worldLexiconCache.lexicon;
|
|
2138
|
+
const lexicon = withProperNames(base, names);
|
|
2139
|
+
worldLexiconCache = { key, base, lexicon };
|
|
2140
|
+
return lexicon;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
async function worldAwareLexicon(memoryDir, lexicon) {
|
|
2144
|
+
const base = lexicon ?? loadLexicon();
|
|
2145
|
+
try {
|
|
2146
|
+
return worldLexicon(readFactRows(await loadMemory(memoryDir)), base);
|
|
2147
|
+
} catch {
|
|
2148
|
+
return base;
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// ---- the vocative: naming who the line is addressed to ------------------------
|
|
2153
|
+
//
|
|
2154
|
+
// Give a window a character's name and players start using it: "groundhog-1
|
|
2155
|
+
// what do you know about food", "mole-1, dig north". The name is who the line
|
|
2156
|
+
// is addressed to, not part of the question — but it makes the line fit no
|
|
2157
|
+
// world shape at all, so the whole turn leaves this lane and comes back
|
|
2158
|
+
// answered as something else entirely (a code question, in a session with no
|
|
2159
|
+
// code graph). Stripping a vocative that names one of the world's OWN placed
|
|
2160
|
+
// individuals costs one fold read, and only on a line that has already failed
|
|
2161
|
+
// on its own terms.
|
|
2162
|
+
|
|
2163
|
+
const escapeForRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
|
|
2164
|
+
|
|
2165
|
+
/** `line` with a leading or trailing vocative naming a placed world
|
|
2166
|
+
* individual removed, or null when it carries none (or when the name is the
|
|
2167
|
+
* whole line, which is a bare mention, not an address). Pure. */
|
|
2168
|
+
export function withoutWorldVocative(line, names) {
|
|
2169
|
+
const l = String(line).trim();
|
|
2170
|
+
for (const name of names) {
|
|
2171
|
+
const escaped = escapeForRegExp(name);
|
|
2172
|
+
const leading = new RegExp(`^${escaped}\\s*[,:;]?\\s+`, "i");
|
|
2173
|
+
if (leading.test(l)) {
|
|
2174
|
+
const rest = l.replace(leading, "").trim();
|
|
2175
|
+
if (rest) return rest;
|
|
2176
|
+
}
|
|
2177
|
+
const trailing = new RegExp(`[\\s,]+${escaped}\\s*([?.!]*)$`, "i");
|
|
2178
|
+
if (trailing.test(l)) {
|
|
2179
|
+
const rest = l.replace(trailing, "$1").trim();
|
|
2180
|
+
if (rest) return rest;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
return null;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
async function addressedLine(line, { memoryDir }) {
|
|
2187
|
+
let rows;
|
|
2188
|
+
try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
|
|
2189
|
+
const state = foldWorldState(worldActionRows(rows));
|
|
2190
|
+
return withoutWorldVocative(line, [...state.placements.keys()].sort());
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1293
2193
|
// ---- the lane ----------------------------------------------------------------
|
|
1294
2194
|
|
|
1295
2195
|
/**
|
|
@@ -1301,7 +2201,7 @@ async function bindPronouns(cmd, { discourseHolder, memoryDir }) {
|
|
|
1301
2201
|
* recognizer, injected so the two lanes can never disagree about what a plan
|
|
1302
2202
|
* frame is.
|
|
1303
2203
|
*/
|
|
1304
|
-
export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false, discourseHolder = null }) {
|
|
2204
|
+
export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "", env, lexicon = null, graph = null, cache = null, isPlanFrameLine = () => false, discourseHolder = null, actingSubject = "player" }) {
|
|
1305
2205
|
const slot = planHolder?.state ?? null;
|
|
1306
2206
|
const adventure = slot?.adventure ?? null;
|
|
1307
2207
|
const opening = matchAdventureOpening(line);
|
|
@@ -1357,13 +2257,29 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1357
2257
|
note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
|
|
1358
2258
|
};
|
|
1359
2259
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
2260
|
+
const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2261
|
+
if (direct) return direct;
|
|
2262
|
+
const addressed = await addressedLine(line, { memoryDir });
|
|
2263
|
+
if (addressed) {
|
|
2264
|
+
const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
|
|
2265
|
+
if (readdressed) return readdressed;
|
|
2266
|
+
}
|
|
2267
|
+
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/** One line against a LIVE world: inventory, an imperative command, then the
|
|
2271
|
+
* in-game asides. Null when the world has no answer for it, which is what
|
|
2272
|
+
* lets an ordinary mid-game question keep its own lane. Split out from the
|
|
2273
|
+
* lane itself so a line carrying a vocative can be re-offered here once,
|
|
2274
|
+
* stripped, without the two paths ever drifting apart. */
|
|
2275
|
+
async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject }) {
|
|
2276
|
+
if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph, actingSubject });
|
|
2277
|
+
const parsed = parseImperative(line, await worldAwareLexicon(memoryDir, lexicon));
|
|
1362
2278
|
if (parsed) {
|
|
1363
|
-
const bound = await bindPronouns(parsed, { discourseHolder, memoryDir });
|
|
2279
|
+
const bound = await bindPronouns(parsed, { discourseHolder, memoryDir, actingSubject });
|
|
1364
2280
|
if (bound.nudge) return bound.nudge;
|
|
1365
2281
|
const cmd = bound.cmd;
|
|
1366
|
-
const result = await runWorldCommand(cmd, { world
|
|
2282
|
+
const result = await runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject });
|
|
1367
2283
|
// The object a command SUCCESSFULLY named registers as a discourse referent
|
|
1368
2284
|
// a later pronoun binds to — so "look lamp" then "examine it" reads the
|
|
1369
2285
|
// lamp, and "talk to housekeeper" makes "him"/"her" the housekeeper. A miss
|
|
@@ -1387,11 +2303,15 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
|
|
|
1387
2303
|
note: `${result.note}; corrected ${cmd.corrected.map((c) => `"${c.from}" -> "${c.to}"`).join(", ")} before executing`,
|
|
1388
2304
|
};
|
|
1389
2305
|
}
|
|
1390
|
-
const whereAside = await worldWhereAnswer(line, { memoryDir });
|
|
2306
|
+
const whereAside = await worldWhereAnswer(line, { memoryDir, actingSubject });
|
|
1391
2307
|
if (whereAside) return whereAside;
|
|
1392
2308
|
const opennessAside = await worldOpennessAnswer(line, { memoryDir });
|
|
1393
2309
|
if (opennessAside) return opennessAside;
|
|
1394
|
-
const contextAside = await worldContextAnswer(line, { memoryDir });
|
|
2310
|
+
const contextAside = await worldContextAnswer(line, { memoryDir, actingSubject });
|
|
1395
2311
|
if (contextAside) return contextAside;
|
|
2312
|
+
const knownFoodAside = await worldKnownFoodAnswer(line, { memoryDir, actingSubject });
|
|
2313
|
+
if (knownFoodAside) return knownFoodAside;
|
|
2314
|
+
const mentionAside = await worldMentionAnswer(line, { memoryDir, graph, actingSubject });
|
|
2315
|
+
if (mentionAside) return mentionAside;
|
|
1396
2316
|
return null; // a mid-game aside — the ordinary lanes answer, world untouched
|
|
1397
2317
|
}
|