@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.
Files changed (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. 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 largest @turnN
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
- if (holder === "player") return null; // carried, not on show in a room
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 carriedByPlayer = (state, thing) => {
366
+ const carriedBy = (state, thing, holder) => {
315
367
  const place = state.placements.get(thing);
316
- return !!place && place.predicate === "mgx:located-in" && place.object === "player";
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 === "player") continue;
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 (isTyped(rows, subject, "person")) {
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", "mgx:knows-about",
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 === "player") {
512
- push("player", "carries the", subject);
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" && isTyped(rows, place.object, "person")) {
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 === "player") continue;
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. Pure. */
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: factObjects(rows, person, "mgx:knows-about") };
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 !== "player" && visibleRoomOf(s, { rows, state }) === room)
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("player")?.object ?? null;
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") && carriedByPlayer(state, object);
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 = isTyped(rows, object, "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: `player@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
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; player moves ${here} -> ${target}`,
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 (carriedByPlayer(state, object)) {
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: "player" }],
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 (!carriedByPlayer(state, object)) {
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 (!isTyped(rows, receiver, "person") || state.placements.get(receiver)?.object !== here) {
940
- return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't a person in the ${here}; precondition declined by name`), { miss: true });
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, "player", object, factState, domain));
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, "player", object, domain);
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 (!carriedByPlayer(state, cmd.instrument)) {
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 (thing === "player") {
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 === "player") {
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
- if (!asksWhere && !asksOptions && !asksQuest) return null;
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("player")?.object ?? null;
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
- async function inventoryAnswer({ memoryDir, graph }) {
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 === "player")
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("player", { memoryDir, memory, rows, state, graph });
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("player")?.object ?? null;
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
- async function bindPronouns(cmd, { discourseHolder, memoryDir }) {
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
- if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph });
1361
- const parsed = parseImperative(line, lexicon ?? undefined);
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: adventure.world, memoryDir, env, graph, cache });
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
  }