@polycode-projects/the-mechanical-code-talker 3.3.0 → 4.0.1

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 (84) hide show
  1. package/README.md +176 -8
  2. package/corpus/reference/index.json.gz +0 -0
  3. package/corpus/reference/manifest.json +8 -8
  4. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  5. package/corpus/sprites/src/sprite-facts.jsonl +52 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +53 -13
  8. package/corpus/worlds/shards/greyvale-museum.jsonl.gz +0 -0
  9. package/corpus/worlds/shards/lantern-cottage.jsonl.gz +0 -0
  10. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-hollow.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  13. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  14. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  15. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  16. package/corpus/worlds/src/mud-garden.jsonl +34 -1
  17. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  18. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  19. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  20. package/data/sprites/animal-icon.toml +11 -5
  21. package/data/sprites/book-icon.toml +9 -5
  22. package/data/sprites/cabinet-icon.toml +10 -5
  23. package/data/sprites/cellar-icon.toml +16 -6
  24. package/data/sprites/container-icon.toml +7 -3
  25. package/data/sprites/desk-icon.toml +8 -5
  26. package/data/sprites/dog-icon.toml +8 -5
  27. package/data/sprites/dog-with-colour-icon.toml +13 -12
  28. package/data/sprites/drawing-room-icon.toml +14 -5
  29. package/data/sprites/egg-icon.toml +6 -3
  30. package/data/sprites/fly-icon.toml +10 -5
  31. package/data/sprites/furniture-icon.toml +5 -3
  32. package/data/sprites/garden-icon.toml +10 -3
  33. package/data/sprites/key-icon.toml +3 -1
  34. package/data/sprites/kitchen-icon.toml +15 -6
  35. package/data/sprites/lamp-icon.toml +9 -4
  36. package/data/sprites/letter-icon.toml +7 -4
  37. package/data/sprites/library-icon.toml +14 -3
  38. package/data/sprites/pan-icon.toml +7 -3
  39. package/data/sprites/person-icon.toml +6 -2
  40. package/data/sprites/poodle-icon.toml +1 -1
  41. package/data/sprites/portable-icon.toml +6 -4
  42. package/data/sprites/portrait-icon.toml +7 -4
  43. package/data/sprites/room-icon.toml +11 -2
  44. package/data/sprites/spider-icon.toml +9 -3
  45. package/data/sprites/study-icon.toml +10 -2
  46. package/package.json +5 -4
  47. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  48. package/src/domain/game-config.mjs +26 -5
  49. package/src/domain/grammar/ace.mjs +40 -4
  50. package/src/domain/grammar/lexicon-core.json +2 -1
  51. package/src/domain/grammar/lexicon.mjs +31 -0
  52. package/src/domain/memory/trust.mjs +6 -4
  53. package/src/domain/p2p/facts.mjs +81 -0
  54. package/src/domain/p2p/peer-id.mjs +32 -0
  55. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  56. package/src/domain/p2p/sync-filter.mjs +31 -0
  57. package/src/domain/p2p/wire.mjs +123 -0
  58. package/src/domain/reference-pack.mjs +31 -7
  59. package/src/domain/spider-fly-world.mjs +1 -1
  60. package/src/domain/sprite-map.mjs +10 -2
  61. package/src/domain/sprite-templates.mjs +8 -6
  62. package/src/services/adventure-editor.mjs +10 -2
  63. package/src/services/adventure-viz.mjs +309 -73
  64. package/src/services/adventure.mjs +1034 -330
  65. package/src/services/chat-page-viz.mjs +1097 -16
  66. package/src/services/chat.mjs +99 -18
  67. package/src/services/code-explorer-viz.mjs +52 -14
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +54 -26
  70. package/src/services/ledger-viz.mjs +105 -56
  71. package/src/services/memory-panel-viz.mjs +24 -0
  72. package/src/services/mud-editor.mjs +313 -0
  73. package/src/services/mud-turn.mjs +264 -87
  74. package/src/services/mud-viz.mjs +2606 -384
  75. package/src/services/p2p-room.mjs +559 -0
  76. package/src/services/plan-viz.mjs +119 -61
  77. package/src/services/research-viz.mjs +151 -57
  78. package/src/services/spider-fly-turn.mjs +1 -1
  79. package/src/services/spider-fly-viz.mjs +67 -43
  80. package/src/services/sprite-catalog-viz.mjs +175 -51
  81. package/src/services/viz-theme.mjs +15 -0
  82. package/src/surfaces/web/memory-ask-browser.bundle.js +109 -109
  83. package/src/surfaces/web/mud-browser-entry.mjs +226 -18
  84. 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";
@@ -185,11 +186,32 @@ const POSITION_PREDICATES = new Set(["mgx:on-top-of", "mgx:on-plane", "mgx:under
185
186
  const OPEN_PREDICATE = "mgx:is-open";
186
187
  const MASS_PREDICATE = "mgx:hasMass";
187
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";
188
193
  const EXIT_PREDICATE_RE = /^mgx:has-exit-([a-z]+)$/;
189
- // Where an eaten thing is placed. The world has no other way to say "out of
190
- // play", and no room can be called this, so the sentinel is the whole
191
- // convention: the readers below skip it exactly as they skip a hiding place.
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.
192
212
  const CONSUMED_PLACE = "eaten";
213
+ const STARVED_PLACE = "starved";
214
+ const OUT_OF_PLAY_PLACES = new Set([CONSUMED_PLACE, STARVED_PLACE]);
193
215
 
194
216
  /** The rows a live world's STATE fold may see: those the world itself wrote
195
217
  * (provenance empty, or `world:*` — the loaded shard and its @turn
@@ -205,6 +227,47 @@ export function worldActionRows(rows) {
205
227
  });
206
228
  }
207
229
 
230
+ // The predicates a played turn can write. Every one of them already exists
231
+ // above as a private constant; this set is the one place they are gathered so
232
+ // a caller outside this file can ask "is this fact part of the live world"
233
+ // without learning each name. rdf:type and the display name earn their place
234
+ // alongside the folded ones: a dug room and a dug object are minted mid-play,
235
+ // and a reader that never receives their class or their plain name cannot draw
236
+ // them at all.
237
+ const MUD_STATE_PREDICATES = new Set([
238
+ ...PLACEMENT_PREDICATES,
239
+ ...POSITION_PREDICATES,
240
+ OPEN_PREDICATE,
241
+ MASS_PREDICATE,
242
+ KNOWS_ABOUT_PREDICATE,
243
+ DISPLAY_NAME_PREDICATE,
244
+ "rdf:type",
245
+ ]);
246
+
247
+ /** Whether `predicate` carries live world state — where a thing stands, what
248
+ * it weighs, whether it is open, what somebody knows, which way a room leads,
249
+ * and the class and name a dug thing is minted with. The P2P sync filter
250
+ * reads this to tell a fact a turn produced from the page chrome around it.
251
+ * Pure. */
252
+ export function isMudStatePredicate(predicate) {
253
+ const p = String(predicate || "");
254
+ return MUD_STATE_PREDICATES.has(p) || EXIT_PREDICATE_RE.test(p);
255
+ }
256
+
257
+ /** Every individual the world names — its rooms, its cast, its props, and
258
+ * anything dug up since — as the plain id strings a parser has to have
259
+ * DECLARED before it can resolve them. @turnN snapshots are skipped: a
260
+ * snapshot only ever repeats a subject its base row already named. Pure. */
261
+ export function worldIndividualNames(rows) {
262
+ const names = new Set();
263
+ for (const row of rows || []) {
264
+ if (SNAPSHOT_RE.test(row.subject)) continue;
265
+ if (row.predicate === "rdf:type" || PLACEMENT_PREDICATES.has(row.predicate)) names.add(row.subject);
266
+ if (EXIT_PREDICATE_RE.test(row.predicate)) { names.add(row.subject); names.add(row.object); }
267
+ }
268
+ return [...names].sort();
269
+ }
270
+
208
271
  /** Fold fact rows into the CURRENT world state: per subject, the newest
209
272
  * placement (base row = turn 0, @turnN snapshots override), the newest
210
273
  * open/closed state, the newest mass, the exit map, and the turn counter (the
@@ -346,6 +409,59 @@ function backgroundOnlyMention(rows, state, object) {
346
409
  return (rows || []).some((r) => r.subject === object || r.object === object);
347
410
  }
348
411
 
412
+ /** True when `subject` is one of the world's cast rather than a prop: a
413
+ * declared person, or anything the world places with mgx:currently-in — the
414
+ * predicate every world reserves for a character standing in a room, props
415
+ * riding located-in/fixed-in/stands-locked-in instead. Both halves matter:
416
+ * ashcombe-hall types its staff `person`, while mud-garden types its animals
417
+ * `adventurer` and places them the same way, so a person-only test leaves a
418
+ * whole cast with nobody able to speak to it. */
419
+ function isCastMember(rows, state, subject) {
420
+ return isTyped(rows, subject, "person") || state.placements.get(subject)?.predicate === "mgx:currently-in";
421
+ }
422
+
423
+ /** Who else is standing in `room` right now, sorted — the same currently-in
424
+ * placement the talk verb and the room affordances read, exposed so a caller
425
+ * rendering a room can name its cast without re-deriving the test. Pure. */
426
+ export function castInRoom(rows, state, room, exclude = null) {
427
+ return [...state.placements.keys()]
428
+ .filter((subject) => subject !== exclude && subject !== room)
429
+ .filter((subject) => state.placements.get(subject).object === room)
430
+ .filter((subject) => isCastMember(rows, state, subject))
431
+ .sort();
432
+ }
433
+
434
+ // A predator eats whatever walks into its room. The marker is a world fact,
435
+ // so which individual is dangerous is the world's business, never this
436
+ // module's.
437
+ const PREDATOR_PREDICATE = "mgx:is-predator";
438
+
439
+ /** The predator standing in `room`, or null — read from the same placements
440
+ * fold every other presence check uses. Pure. */
441
+ function predatorIn(rows, state, room) {
442
+ return castInRoom(rows, state, room)
443
+ .find((subject) => factObjects(rows, subject, PREDATOR_PREDICATE).includes("true")) ?? null;
444
+ }
445
+
446
+ /** True when `subject` has left the world: placed at an out-of-play sentinel
447
+ * no room can be called. Its part in the world is finished — every command it
448
+ * gives declines, and its scripted turns stop. Pure. */
449
+ export const isOutOfPlay = (state, subject) => OUT_OF_PLAY_PLACES.has(state.placements.get(subject)?.object);
450
+
451
+ /** WHY `subject` is out of play — "eaten" or "starved" — or null while it is
452
+ * still playing. The two fates end a run the same way and read nothing alike,
453
+ * so anything narrating one needs to tell them apart. Pure. */
454
+ export const outOfPlayReasonOf = (state, subject) => {
455
+ const place = state.placements.get(subject)?.object;
456
+ return OUT_OF_PLAY_PLACES.has(place) ? place : null;
457
+ };
458
+
459
+ /** How a fate reads in a sentence: "the mole-1 has been eaten", "the mole-1 has
460
+ * starved". One phrase per sentinel, so nothing anywhere else has to spell the
461
+ * difference out. Pure. */
462
+ export const outOfPlayPhrase = (subject, reason) =>
463
+ (reason === STARVED_PLACE ? `the ${subject} has starved` : `the ${subject} has been eaten`);
464
+
349
465
  /** The room's real affordances — every exit, and every visible object's
350
466
  * applicable verb — read from the EXACT SAME data take/open/talk/examine
351
467
  * already check (visibleRoomOf, isContainer, isTyped, the placement
@@ -375,7 +491,7 @@ export function roomAffordances(rows, state, here, actingSubject = "player") {
375
491
  actions.push(`examine ${subject}`);
376
492
  continue;
377
493
  }
378
- if (isTyped(rows, subject, "person")) {
494
+ if (isCastMember(rows, state, subject)) {
379
495
  actions.push(`talk to ${subject}`);
380
496
  continue;
381
497
  }
@@ -465,13 +581,25 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
465
581
  // trust track record. The side effect is that worldActionRows filters these
466
582
  // out of the playable state fold, which is what you want — being told about a
467
583
  // stone must never move the stone.
584
+ //
585
+ // A claim can also go out of date, and none of them is ever retracted. Eating
586
+ // the last carrot appends a SECOND claim to the same edge, tagged `:gone`, and
587
+ // appendFacts unions the two tags onto the one fact exactly as it unions any
588
+ // repeat assertion. "The carrot was here on turn 2" and "the carrot is gone on
589
+ // turn 5" are both true; the reader's job is to say which one rules, the same
590
+ // recency question the p2p layer asks of its own tags. So reading knowledge
591
+ // back means reading the newest claim per edge, never the union of every claim
592
+ // ever made.
593
+
594
+ const VOIDED_TESTIMONY_SUFFIX = ":gone";
468
595
 
469
- const characterTestimonyTag = (character, k) => `mud:${character}:turn${k}`;
596
+ const characterTestimonyTag = (character, k, voided = false) =>
597
+ `mud:${character}:turn${k}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
470
598
 
471
- async function appendTestimony(memoryDir, { knower, source, thing, k, cache }) {
599
+ async function appendTestimony(memoryDir, { knower, source, thing, k, voided = false, cache }) {
472
600
  await appendFacts(memoryDir, [{
473
601
  subject: knower, predicate: KNOWS_ABOUT_PREDICATE, object: thing,
474
- provenance: characterTestimonyTag(source, k),
602
+ provenance: characterTestimonyTag(source, k, voided),
475
603
  }]);
476
604
  if (cache) cache.rows = null;
477
605
  }
@@ -490,6 +618,98 @@ export async function recordExamined(memoryDir, { observer, thing, k, cache = nu
490
618
  return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, cache });
491
619
  }
492
620
 
621
+ /** Record that `observer` saw `thing` leave the world on turn `k` — it ate the
622
+ * last of it. Written as a fresh claim on the SAME edge an older one already
623
+ * sits on, so the older claim stands untouched and stops being the one that
624
+ * rules. The observer is its own source, the way examining is. */
625
+ export async function recordGone(memoryDir, { observer, thing, k, cache = null }) {
626
+ return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, voided: true, cache });
627
+ }
628
+
629
+ const TESTIMONY_TAG_RE = /^mud:([^:\s]+):turn(\d+)(:gone)?$/;
630
+ const TURN_STAMP_RE = /:turn(\d+)\b/;
631
+
632
+ /** How one provenance segment on a knows-about edge stands as a claim about
633
+ * what `knower` knows: whether the knower vouches for it itself, the turn it
634
+ * was asserted on, and whether it says the thing is gone. A tag that is no
635
+ * character's testimony — a world's own seed fact, a dig spawn — reads as
636
+ * hearsay stamped with whatever turn it carries. */
637
+ function testimonyClaim(segment, knower) {
638
+ const mine = TESTIMONY_TAG_RE.exec(segment);
639
+ const stamp = Number(mine ? mine[2] : (TURN_STAMP_RE.exec(segment)?.[1] ?? 0));
640
+ return {
641
+ firsthand: !!mine && mine[1] === knower,
642
+ turn: Number.isFinite(stamp) ? stamp : 0,
643
+ voided: !!(mine && mine[3]),
644
+ };
645
+ }
646
+
647
+ /** Firsthand beats hearsay outright, then the later turn wins, then "gone"
648
+ * takes the tie — an animal that examined a carrot and ate it on one turn ate
649
+ * it second. Tier ABOVE recency is what stops an animal being talked back into
650
+ * a meal it ate itself: a room-mate can tell it about that carrot the turn
651
+ * after, and its own eyes still hold. */
652
+ const outranksClaim = (claim, best) => (
653
+ claim.firsthand !== best.firsthand ? claim.firsthand
654
+ : claim.turn !== best.turn ? claim.turn > best.turn
655
+ : claim.voided && !best.voided
656
+ );
657
+
658
+ /** The claim that rules on one knows-about edge, across every segment its
659
+ * provenance carries. */
660
+ function rulingTestimonyClaim(provenance, knower) {
661
+ let best = null;
662
+ for (const segment of String(provenance || "").split(" | ")) {
663
+ const tag = segment.trim();
664
+ if (!tag) continue;
665
+ const claim = testimonyClaim(tag, knower);
666
+ if (!best || outranksClaim(claim, best)) best = claim;
667
+ }
668
+ return best;
669
+ }
670
+
671
+ /** What `person` knows about NOW: the object of every knows-about edge whose
672
+ * ruling claim still stands, in the order the edges were first written.
673
+ * Nothing is deleted — an edge whose newest claim says the thing is gone just
674
+ * stops reading back. */
675
+ function currentKnowsAboutTopics(rows, person) {
676
+ const topics = [];
677
+ for (const row of rows || []) {
678
+ if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
679
+ if (rulingTestimonyClaim(row.provenance, person)?.voided) continue;
680
+ topics.push(row.object);
681
+ }
682
+ return topics;
683
+ }
684
+
685
+ /**
686
+ * Charge `subject` the mass a turn costs it, and place it out of play at the
687
+ * starved sentinel once nothing is left. Returns `{ mass, starved }` — the mass
688
+ * it is left with, and whether that ended its run. Writes nothing and charges
689
+ * nothing when the drain is zero, when the subject is already out of play, or
690
+ * when the world gives it no mass at all (`mass` is then null: a thing with no
691
+ * mass cannot run out of it).
692
+ *
693
+ * The write lands on the world's OWN next turn, read fresh here rather than
694
+ * taken from the caller. A scripted turn runs several world commands, each
695
+ * stamping a turn of its own, so a caller's tick number can trail the world's
696
+ * count — and a mass snapshot stamped behind the newest placement would fold
697
+ * away as stale the moment it was written.
698
+ */
699
+ export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn, cache = null }) {
700
+ const rows = readFactRows(await loadMemory(memoryDir));
701
+ const state = foldWorldState(worldActionRows(rows));
702
+ const mass = state.masses.get(subject)?.value ?? null;
703
+ if (mass === null || !(drainPerTurn > 0) || isOutOfPlay(state, subject)) return { mass, starved: false };
704
+ const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
705
+ const k = state.turnCount + 1;
706
+ await writeWorldTurn(memoryDir, world, k, [
707
+ { subject: `${subject}@turn${k}`, predicate: MASS_PREDICATE, object: String(left) },
708
+ ...(left > 0 ? [] : [{ subject: `${subject}@turn${k}`, predicate: "mgx:currently-in", object: STARVED_PLACE }]),
709
+ ], cache);
710
+ return { mass: left, starved: left <= 0 };
711
+ }
712
+
493
713
  // ---- the look/inventory digest ----------------------------------------------
494
714
  //
495
715
  // "look" and "what am I carrying" are generateCompletion calls (the shipped
@@ -523,6 +743,21 @@ const VIEW_EXCLUDED_PREDICATES = new Set([
523
743
  // subClassOf drive positional rendering by their own readers, and read as
524
744
  // raw triples if they land in room prose.
525
745
  "mgx:default-contains", "mgx:default-plane", "rdfs:subClassOf",
746
+ // A screen name is presentation, not scenery — it reads as a raw triple in
747
+ // room prose ("Carrot-1 mgx:display-name carrot") and says nothing the
748
+ // room's own sentences don't already say.
749
+ DISPLAY_NAME_PREDICATE,
750
+ // Which individual is dangerous is the predator mechanic's own wiring; a
751
+ // room look that announced it would give the trap away as a bare triple.
752
+ "mgx:is-predator",
753
+ // The dig mechanic's wiring is the same kind of thing: it tells the verb what
754
+ // a dug room may hold and how far the world reaches, and says nothing about
755
+ // the room anyone is standing in.
756
+ ORIGIN_PREDICATE, DIG_SPAWN_PREDICATE, DEN_SPAWN_PREDICATE, DEN_RESIDENT_PREDICATE,
757
+ DIG_REACH_PREDICATE, DIG_SPAWN_MAX_PREDICATE, DEN_CHANCE_PREDICATE, DEN_RESIDENT_CHANCE_PREDICATE,
758
+ // How fast a turn wears a species down is the mass economy's own wiring, and
759
+ // reads as a bare number in room prose the same way hasMass does.
760
+ MASS_DRAIN_PREDICATE,
526
761
  ]);
527
762
 
528
763
  const sentenceCase = (term) => String(term).charAt(0).toUpperCase() + String(term).slice(1);
@@ -573,7 +808,7 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
573
808
  const isCarryingCharacter = (holder) =>
574
809
  isTyped(rows, holder, "person") || state.placements.get(holder)?.predicate === "mgx:currently-in";
575
810
  for (const [subject, place] of state.placements) {
576
- if (place.predicate === "mgx:hidden-in" || place.object === CONSUMED_PLACE) continue;
811
+ if (place.predicate === "mgx:hidden-in" || OUT_OF_PLAY_PLACES.has(place.object)) continue;
577
812
  if (place.predicate === "mgx:located-in" && place.object === actingSubject) {
578
813
  push(actingSubject, "carries the", subject);
579
814
  continue;
@@ -596,18 +831,18 @@ export function worldDigestRows(rows, state, actingSubject = "player") {
596
831
  // already assumes, so it is left unsaid.
597
832
  const POSITION_PHRASE = { "mgx:on-top-of": "is on the", "mgx:on-plane": "is on the", "mgx:under": "is under the" };
598
833
  for (const [subject, place] of state.placements) {
599
- if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || place.object === CONSUMED_PLACE) continue;
834
+ if (place.predicate === "mgx:hidden-in" || place.object === actingSubject || OUT_OF_PLAY_PLACES.has(place.object)) continue;
600
835
  const pos = currentPosition(state, subject);
601
836
  if (pos && POSITION_PHRASE[pos.predicate]) { push(subject, POSITION_PHRASE[pos.predicate], pos.object); continue; }
602
837
  const plane = classDefaultPlane(rows, subject);
603
838
  if (plane && plane !== "floor") push(subject, "is usually on the", plane);
604
839
  }
605
- const consumed = new Set([...state.placements]
606
- .filter(([, place]) => place.object === CONSUMED_PLACE)
840
+ const gone = new Set([...state.placements]
841
+ .filter(([, place]) => OUT_OF_PLAY_PLACES.has(place.object))
607
842
  .map(([subject]) => subject));
608
843
  for (const row of rows || []) {
609
844
  if (SNAPSHOT_RE.test(row.subject)) continue; // folded above
610
- if (consumed.has(row.subject)) continue; // eaten, so out of the world entirely
845
+ if (gone.has(row.subject)) continue; // out of the world entirely
611
846
  // Room text comes from the world source only. A merged corpus overlaps a
612
847
  // room's own vocabulary ("library rdfs:subClassOf literary study"), and
613
848
  // without this those rows leak into the room description as stray sentences.
@@ -704,12 +939,160 @@ const OPPOSITE_DIRECTION = new Map([
704
939
  ["up", "down"], ["down", "up"],
705
940
  ]);
706
941
 
707
- // What a freshly dug room holds. The pool is placeholder scenery so a new room
708
- // is never bare; a later workstream swaps it for the garden's real food
709
- // content, which this module has no business naming.
942
+ // What a freshly dug room holds, and how often a dig opens something better
943
+ // than a bare tunnel. The world names all of it a room kind declares the
944
+ // kinds a plain dig turns up, how many of them, the richer set a den holds, how
945
+ // often a dig finds one, and which animal lives in it. The numbers below are
946
+ // only the fallback for a world that declares none.
710
947
  const DIG_SPAWN_KINDS = ["root", "carrot", "worm"];
711
948
  const DIG_SPAWN_MIN = 0;
712
- const DIG_SPAWN_MAX = 2;
949
+ const DEFAULT_DIG_SPAWN_MAX = 2;
950
+ const DEFAULT_DEN_CHANCE_IN = 5;
951
+ const DEFAULT_DEN_RESIDENT_CHANCE_IN = 3;
952
+ const DEN_ROOM_CLASS = "den";
953
+
954
+ // How far from the world's origin room a dig may carry it, when the origin
955
+ // writes no reach of its own. Without a cap a burrow sprawls in every direction
956
+ // at once, and an animal twenty hops out has nothing around it, no food it
957
+ // knows of, and no reason to be anywhere — the stranding this bound exists to
958
+ // stop. Six keeps every room inside one pathfinder search of the origin
959
+ // (mud-turn.mjs walks eight hops), so an animal standing at the frontier can
960
+ // always still walk home to the rooms with food in them.
961
+ const DEFAULT_DIG_REACH = 6;
962
+
963
+ /** The NEWEST value `subject` declares under `predicate`, as a number, or null
964
+ * when it declares none. Newest rather than first on purpose: the store is
965
+ * append-only, so a later write is the current truth — the same rule
966
+ * foldWorldState already applies to placements, and what lets an edit to one of
967
+ * these knobs take effect over the world's own seed fact. Pure. */
968
+ function declaredNumber(rows, subject, predicate) {
969
+ const written = factObjects(rows, subject, predicate);
970
+ if (!written.length) return null;
971
+ const value = Number(written[written.length - 1]);
972
+ return Number.isFinite(value) ? value : null;
973
+ }
974
+
975
+ /** A positive count `subject` declares under `predicate`, or `fallback` when it
976
+ * declares none (or writes something that is not a usable count). Pure. */
977
+ function declaredCountOr(rows, subject, predicate, fallback) {
978
+ const written = declaredNumber(rows, subject, predicate);
979
+ return written !== null && written > 0 ? written : fallback;
980
+ }
981
+
982
+ /** How many rooms out from its origin this world lets a dig reach — the origin
983
+ * room's own `mgx:dig-reach` fact, or the shipped default when it writes none.
984
+ * Pure. */
985
+ export function digReachOf(rows) {
986
+ const origin = originRoomOf(rows);
987
+ return origin ? declaredCountOr(rows, origin, DIG_REACH_PREDICATE, DEFAULT_DIG_REACH) : DEFAULT_DIG_REACH;
988
+ }
989
+
990
+ /** What one turn costs `subject` in mass, from a `mgx:mass-drain-per-turn` fact
991
+ * on its own class chain (so a whole species is tuned in one line, and one
992
+ * individual can still overrule its species by writing its own). Null when
993
+ * nothing in the chain declares one — a knob nobody set is not a reason to
994
+ * invent a number and starve something with it. Pure. */
995
+ export function massDrainPerTurnOf(rows, subject) {
996
+ for (const kind of objectClassChain(rows, subject)) {
997
+ const written = declaredNumber(rows, kind, MASS_DRAIN_PREDICATE);
998
+ if (written !== null && written >= 0) return written;
999
+ }
1000
+ return null;
1001
+ }
1002
+
1003
+ // Which way a room of each kind can be dug, and what the room it opens is
1004
+ // typed as. Above ground there is nothing to tunnel sideways through, so the
1005
+ // only dig is straight down into the soil; below ground the burrow spreads
1006
+ // across its own level and can surface again. Digging deeper is left out so
1007
+ // the burrow stays the one level the soil cross-section draws.
1008
+ const DIGGABLE_BY_ROOM_KIND = new Map([
1009
+ ["outdoor", new Map([["down", "underground-space"]])],
1010
+ ["underground", new Map([
1011
+ ["north", "underground-space"],
1012
+ ["south", "underground-space"],
1013
+ ["east", "underground-space"],
1014
+ ["west", "underground-space"],
1015
+ ["up", "outdoor-space"],
1016
+ ])],
1017
+ ["indoor", new Map()],
1018
+ ]);
1019
+
1020
+ const DIG_DECLINE_BY_ROOM_KIND = {
1021
+ outdoor: (room, direction) => (direction === "up"
1022
+ ? `there's nothing but sky above the ${room}.`
1023
+ : `you can't tunnel ${direction} out here — the ${room} is open ground, not soil to dig through. Dig down to get under it.`),
1024
+ underground: (room) => `the earth below the ${room} is packed solid — this burrow runs one level deep.`,
1025
+ indoor: (room, direction) => `you can't dig ${direction} out of the ${room}.`,
1026
+ };
1027
+
1028
+ /** A room's own kind, from the rdf:type facts the world writes about it:
1029
+ * "outdoor" (the surface), "underground" (the burrow), or "indoor" for a
1030
+ * walled room that says neither. Pure. */
1031
+ export function roomKindOf(rows, room) {
1032
+ const typedAs = (kind) => (rows || []).some((r) => r.subject === room && r.predicate === "rdf:type" && r.object === kind);
1033
+ if (typedAs("outdoor-space")) return "outdoor";
1034
+ if (typedAs("underground-space")) return "underground";
1035
+ return "indoor";
1036
+ }
1037
+
1038
+ /** The room a world calls its origin — the one every dig is measured from — or
1039
+ * null when it names none. A world with no origin fact is simply not bounded.
1040
+ * Pure. */
1041
+ export function originRoomOf(rows) {
1042
+ return (rows || []).find((r) => r.predicate === ORIGIN_PREDICATE && r.object === "true")?.subject ?? null;
1043
+ }
1044
+
1045
+ /** How many exits a walk from the world's origin to `room` crosses, or null
1046
+ * when the world declares no origin or no chain of exits joins the two. Pure. */
1047
+ export function roomDistanceFromOrigin(rows, state, room) {
1048
+ const origin = originRoomOf(rows);
1049
+ if (!origin) return null;
1050
+ if (origin === room) return 0;
1051
+ const seen = new Set([origin]);
1052
+ let frontier = [origin];
1053
+ for (let distance = 1; frontier.length; distance += 1) {
1054
+ const next = [];
1055
+ for (const from of frontier) {
1056
+ for (const target of state.exits.get(from)?.values() ?? []) {
1057
+ if (seen.has(target)) continue;
1058
+ seen.add(target);
1059
+ if (target === room) return distance;
1060
+ next.push(target);
1061
+ }
1062
+ }
1063
+ frontier = next;
1064
+ }
1065
+ return null;
1066
+ }
1067
+
1068
+ /** True when `room` is as far from the origin as this world digs, or off the
1069
+ * origin's map altogether. A freshly dug room's only other exit is the one
1070
+ * back, so its distance is always this room's plus one — which makes the whole
1071
+ * boundary test a property of where the digger stands, never of the direction
1072
+ * it faces.
1073
+ *
1074
+ * A room the origin cannot reach is the strictest case, not the loosest: it
1075
+ * has no measurable distance, so nothing would ever stop it growing, and a
1076
+ * burrow with no way home is precisely what the bound exists to prevent. A
1077
+ * world that declares no origin at all is a different thing and stays
1078
+ * unbounded. Pure. */
1079
+ function atDigBoundary(rows, state, room) {
1080
+ if (!originRoomOf(rows)) return false;
1081
+ const distance = roomDistanceFromOrigin(rows, state, room);
1082
+ return distance === null || distance >= digReachOf(rows);
1083
+ }
1084
+
1085
+ /** Every direction a dig could actually open a room in from `room`: allowed
1086
+ * by the room's own kind, with no exit already written that way, and inside
1087
+ * the world's dig boundary. This is the exact set the dig verb accepts, so a
1088
+ * caller offering these as hints can never suggest a dig the verb would then
1089
+ * refuse. Pure. */
1090
+ export function diggableDirections(rows, state, room) {
1091
+ if (atDigBoundary(rows, state, room)) return [];
1092
+ const exits = state.exits.get(room);
1093
+ return [...(DIGGABLE_BY_ROOM_KIND.get(roomKindOf(rows, room)) ?? new Map()).keys()]
1094
+ .filter((direction) => !exits?.has(direction));
1095
+ }
713
1096
 
714
1097
  const FOOD_CLASS = "food";
715
1098
  // A shared reference mass standing in for per-species maxima until the game
@@ -741,6 +1124,55 @@ function freshRoomId(rows, here, direction) {
741
1124
  return `${base}-${(rows || []).length + 3}`;
742
1125
  }
743
1126
 
1127
+ /** An unused id for a freshly dug object, reading as its plain kind and a
1128
+ * small number ("carrot-1"). The short id is what keeps a pouch readable:
1129
+ * naming a spawned object after the room it came out of inherits that room's
1130
+ * whole nested dig path ("carrot-sett-1-north-east-east"), which is an id, not
1131
+ * a name anyone can read. `alsoTaken` holds the ids minted earlier in this
1132
+ * same dig, which are not in `rows` yet. Pure. */
1133
+ function freshObjectId(rows, kind, alsoTaken) {
1134
+ const taken = (id) => alsoTaken.has(id) || (rows || []).some((r) => r.subject === id || r.object === id);
1135
+ for (let n = 1; n <= (rows || []).length + 2; n += 1) {
1136
+ if (!taken(`${kind}-${n}`)) return `${kind}-${n}`;
1137
+ }
1138
+ return `${kind}-${(rows || []).length + 3}`;
1139
+ }
1140
+
1141
+ /** The kinds a room kind declares for one of the spawn pools, in the order the
1142
+ * world wrote them, or `fallback` when it declares none. Pure. */
1143
+ function declaredKindsOr(rows, roomClass, predicate, fallback) {
1144
+ const declared = factObjects(rows, roomClass, predicate);
1145
+ return declared.length ? declared : fallback;
1146
+ }
1147
+
1148
+ /** The mass row a freshly minted instance needs, copied off its own class, or
1149
+ * nothing when the class declares no mass. eat reads the instance's mass, so a
1150
+ * dug carrot with none would be worth the flat default however the world
1151
+ * values a carrot. Pure. */
1152
+ function classMassFacts(rows, instance, kind) {
1153
+ const mass = factObjects(rows, kind, MASS_PREDICATE)[0];
1154
+ return mass ? [{ subject: instance, predicate: MASS_PREDICATE, object: mass }] : [];
1155
+ }
1156
+
1157
+ /** What a dig reads like: a bare tunnel, a scrap or two in the loose earth, or
1158
+ * a den — and, when somebody lives in it, who looked up. Pure. */
1159
+ function digNarration(direction, { isDen, spawned, resident }) {
1160
+ const opened = isDen
1161
+ ? `you dig ${direction} and break into a den somebody hollowed out.`
1162
+ : `you dig ${direction} and open up a new room.`;
1163
+ const held = spawned.length
1164
+ ? ` ${isDen ? "Stored in it" : "In the loose earth"}: the ${spawned.join(", the ")}.`
1165
+ : " There's nothing in it but bare earth.";
1166
+ return `${opened}${held}${resident ? ` The ${resident} lives here, and looks up as you come through.` : ""}`;
1167
+ }
1168
+
1169
+ /** What a thing should be CALLED on screen: its declared display name, else
1170
+ * its own id. A dug object carries one so a pouch can list "carrot" while the
1171
+ * world keeps the distinct id ("carrot-1") every verb resolves against. Pure. */
1172
+ export function displayNameOf(rows, subject) {
1173
+ return factObjects(rows, subject, DISPLAY_NAME_PREDICATE)[0] ?? subject;
1174
+ }
1175
+
744
1176
  /** A container's open/locked status, stated plainly, and (only once already
745
1177
  * open) its visible contents — the one thing examine/talk's reused
746
1178
  * worldDigest call never states on its own, since mgx:is-open is a
@@ -783,11 +1215,12 @@ function containerDatatypeState(state, object) {
783
1215
  * thing's current location, a hidden one included: talking to the staff is
784
1216
  * the sanctioned way to learn a hiding place, while the where-is aside keeps
785
1217
  * declining. `knows-objective` states the quest. `knows-about` topics come
786
- * back for the caller to digest. Pure. */
1218
+ * back for the caller to digest, each one read from its newest surviving
1219
+ * claim. Pure. */
787
1220
  export function personKnowledgeLines(rows, state, person) {
788
1221
  const lines = [];
789
1222
  for (const objective of factObjects(rows, person, "mgx:knows-objective")) {
790
- lines.push(`the ${objective} is what you're after — find it and carry it out of the house.`);
1223
+ lines.push(`the ${objective} is what you're after — find it and carry it, and the adventure is won.`);
791
1224
  }
792
1225
  for (const thing of factObjects(rows, person, "mgx:knows-where")) {
793
1226
  const place = state.placements.get(thing);
@@ -796,7 +1229,7 @@ export function personKnowledgeLines(rows, state, person) {
796
1229
  ? `you'll find the ${thing} in the ${place.object}.`
797
1230
  : `the ${thing} is in the ${place.object}.`);
798
1231
  }
799
- return { lines, aboutTopics: factObjects(rows, person, KNOWS_ABOUT_PREDICATE) };
1232
+ return { lines, aboutTopics: currentKnowsAboutTopics(rows, person) };
800
1233
  }
801
1234
 
802
1235
  /** The FOOD_CLASS things `person` durably knows about — from being told, or
@@ -807,7 +1240,7 @@ export function personKnowledgeLines(rows, state, person) {
807
1240
  * food query has no per-topic sub-digest to hand back, so this returns the
808
1241
  * plain list of known food things rather than a {lines, topics} pair. Pure. */
809
1242
  export function personKnownFoodLines(rows, state, person) {
810
- return factObjects(rows, person, KNOWS_ABOUT_PREDICATE)
1243
+ return currentKnowsAboutTopics(rows, person)
811
1244
  .filter((thing) => objectClassChain(rows, thing).includes(FOOD_CLASS));
812
1245
  }
813
1246
 
@@ -833,6 +1266,410 @@ export function personRoomReport(rows, state, person, actingSubject = "player")
833
1266
  return parts.join(" ");
834
1267
  }
835
1268
 
1269
+ // ---- state-changing verb handlers --------------------------------------------
1270
+ //
1271
+ // One function per state-changing verb (or per shared-precondition group of
1272
+ // verbs), keyed by name in STATE_VERB_HANDLERS below rather than chained as
1273
+ // if/else-if branches: adding a verb means adding a table entry, never
1274
+ // widening an existing conditional. Each handler takes the one `ctx` object
1275
+ // runWorldCommand builds once per call — cmd, the current fold, the taught
1276
+ // action family, and the shared `commit`/`noteFor` closures — so a handler
1277
+ // reads exactly like the branch it replaces, just lifted to its own name.
1278
+
1279
+ async function handleGoVerb(ctx) {
1280
+ const { cmd, rows, state, here, k, family, commit, noteFor, memoryDir, world, cache, actingSubject } = ctx;
1281
+ const target = state.exits.get(here)?.get(cmd.direction);
1282
+ if (!target) {
1283
+ return answer(
1284
+ `there's no exit ${cmd.direction} from the ${here}.`,
1285
+ noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
1286
+ { miss: true },
1287
+ );
1288
+ }
1289
+ // A predator eats whatever walks in, and the room it guards is the one
1290
+ // room a move never comes back from — so this write bypasses commit()
1291
+ // entirely: the auto-relook there would describe a room the mover is no
1292
+ // longer standing in, and the world has nobody left to look with.
1293
+ const predator = predatorIn(rows, state, target);
1294
+ if (predator) {
1295
+ await writeWorldTurn(memoryDir, world, k, [
1296
+ { subject: `${actingSubject}@turn${k}`, predicate: "mgx:currently-in", object: CONSUMED_PLACE },
1297
+ ], cache);
1298
+ return answer(
1299
+ `you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
1300
+ noteFor(`go — the ${target} holds the predator ${predator}; ${actingSubject} is placed out of play at turn ${k} and takes no further turns`),
1301
+ { goal: `move through the world (eaten by the ${predator} in the ${target})` },
1302
+ );
1303
+ }
1304
+ return commit(
1305
+ [{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1306
+ `you go ${cmd.direction}. Now in the ${target}.`,
1307
+ `go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
1308
+ `move through the world (now in the ${target})`,
1309
+ target,
1310
+ );
1311
+ }
1312
+
1313
+ async function handleTakeVerb(ctx) {
1314
+ const { rows, state, here, k, family, object, place, commit, noteFor, actingSubject } = ctx;
1315
+ if (isTyped(rows, object, "room")) {
1316
+ return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
1317
+ }
1318
+ if (carriedBy(state, object, actingSubject)) {
1319
+ return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
1320
+ }
1321
+ if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
1322
+ return answer(
1323
+ `the ${object} is fixed in place — it can't be taken.`,
1324
+ noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
1325
+ { miss: true },
1326
+ );
1327
+ }
1328
+ if (place && place.predicate === "mgx:currently-in" && place.object === here) {
1329
+ return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
1330
+ }
1331
+ if (visibleRoomOf(object, { rows, state }) !== here) {
1332
+ if (backgroundOnlyMention(rows, state, object)) {
1333
+ return answer(
1334
+ `the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
1335
+ noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
1336
+ { miss: true },
1337
+ );
1338
+ }
1339
+ 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 });
1340
+ }
1341
+ return commit(
1342
+ [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1343
+ `you take the ${object}.`,
1344
+ `take — the taught "take" family fired; ${object} is now carried`,
1345
+ `carry the ${object}`,
1346
+ );
1347
+ }
1348
+
1349
+ async function handleDropOrGiveVerb(ctx) {
1350
+ const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
1351
+ if (!carriedBy(state, object, actingSubject)) {
1352
+ return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
1353
+ }
1354
+ if (cmd.verb === "drop") {
1355
+ return commit(
1356
+ [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1357
+ `you drop the ${object} in the ${here}.`,
1358
+ `drop — the taught "drop" family fired; ${object} rests in the ${here}`,
1359
+ `set the ${object} down`,
1360
+ );
1361
+ }
1362
+ const receiver = cmd.indirectObject;
1363
+ if (!isCastMember(rows, state, receiver) || state.placements.get(receiver)?.object !== here) {
1364
+ 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 });
1365
+ }
1366
+ return commit(
1367
+ [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1368
+ `you give the ${object} to the ${receiver}.`,
1369
+ `give — the taught "give" family fired; the ${receiver} holds the ${object}`,
1370
+ `hand the ${object} over`,
1371
+ );
1372
+ }
1373
+
1374
+ async function handleDigVerb(ctx) {
1375
+ const { cmd, rows, state, here, k, commit, noteFor } = ctx;
1376
+ const direction = cmd.direction;
1377
+ if (state.exits.get(here)?.get(direction)) {
1378
+ return answer(
1379
+ `there's already an exit ${direction} from the ${here}.`,
1380
+ noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
1381
+ { miss: true },
1382
+ );
1383
+ }
1384
+ const back = OPPOSITE_DIRECTION.get(direction);
1385
+ if (!back) {
1386
+ return answer(
1387
+ `I don't know which way back a ${direction} tunnel would run.`,
1388
+ noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
1389
+ { miss: true },
1390
+ );
1391
+ }
1392
+ const roomKind = roomKindOf(rows, here);
1393
+ const dugKind = (DIGGABLE_BY_ROOM_KIND.get(roomKind) ?? new Map()).get(direction) ?? null;
1394
+ if (!dugKind) {
1395
+ return answer(
1396
+ DIG_DECLINE_BY_ROOM_KIND[roomKind](here, direction),
1397
+ noteFor(`dig — the ${here} is an ${roomKind} room, which cannot be dug ${direction}; declined by the room's own kind`),
1398
+ { miss: true },
1399
+ );
1400
+ }
1401
+ if (atDigBoundary(rows, state, here)) {
1402
+ const reach = roomDistanceFromOrigin(rows, state, here);
1403
+ return answer(
1404
+ `the earth ${direction} of the ${here} is packed hard and endless — you have reached the far edge of the burrow.`,
1405
+ noteFor(reach === null
1406
+ ? `dig — no chain of exits joins the ${here} to the ${originRoomOf(rows)}, so there is no distance to measure a dig against; declined`
1407
+ : `dig — the ${here} stands ${reach} rooms from the ${originRoomOf(rows)}, and this world digs ${digReachOf(rows)}; declined by distance from the origin`),
1408
+ { miss: true },
1409
+ );
1410
+ }
1411
+ const dug = freshRoomId(rows, here, direction);
1412
+ const denChanceIn = declaredCountOr(rows, dugKind, DEN_CHANCE_PREDICATE, DEFAULT_DEN_CHANCE_IN);
1413
+ const isDen = dugKind === "underground-space" && stableIndex(`den:${dug}`, denChanceIn) === 0;
1414
+ const spawnMax = declaredCountOr(rows, dugKind, DIG_SPAWN_MAX_PREDICATE, DEFAULT_DIG_SPAWN_MAX);
1415
+ const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
1416
+ const spawnedKinds = isDen
1417
+ ? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
1418
+ : declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
1419
+ const minted = new Set();
1420
+ const spawned = spawnedKinds.map((kind) => {
1421
+ const id = freshObjectId(rows, kind, minted);
1422
+ minted.add(id);
1423
+ return id;
1424
+ });
1425
+ const residentChanceIn = declaredCountOr(rows, dugKind, DEN_RESIDENT_CHANCE_PREDICATE, DEFAULT_DEN_RESIDENT_CHANCE_IN);
1426
+ const residentKind = isDen && stableIndex(`resident:${dug}`, residentChanceIn) === 0
1427
+ ? factObjects(rows, dugKind, DEN_RESIDENT_PREDICATE)[0] ?? null
1428
+ : null;
1429
+ const resident = residentKind ? freshObjectId(rows, residentKind, minted) : null;
1430
+ return commit(
1431
+ [
1432
+ { subject: dug, predicate: "rdf:type", object: "room" },
1433
+ { subject: dug, predicate: "rdf:type", object: dugKind },
1434
+ ...(isDen ? [{ subject: dug, predicate: "rdf:type", object: DEN_ROOM_CLASS }] : []),
1435
+ { subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
1436
+ { subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
1437
+ // Typed to its OWN kind, not a flat "portable" — a spawned kind the
1438
+ // world declares rdfs:subClassOf food needs its real class reachable
1439
+ // here for isFood's own objectClassChain walk, or digging up "carrot-1"
1440
+ // would still read as inedible scenery. The class's own mass copies
1441
+ // onto the instance for the same reason: eat reads the instance.
1442
+ ...spawnedKinds.flatMap((kind, i) => ([
1443
+ { subject: spawned[i], predicate: "rdf:type", object: kind },
1444
+ { subject: spawned[i], predicate: DISPLAY_NAME_PREDICATE, object: kind },
1445
+ { subject: spawned[i], predicate: "mgx:located-in", object: dug },
1446
+ ...classMassFacts(rows, spawned[i], kind),
1447
+ ])),
1448
+ // A resident is placed with currently-in, the predicate that makes an
1449
+ // individual one of the cast, and knows about what its own den holds —
1450
+ // so an animal that digs one out has somebody new to ask about food.
1451
+ ...(resident ? [
1452
+ { subject: resident, predicate: "rdf:type", object: residentKind },
1453
+ { subject: resident, predicate: DISPLAY_NAME_PREDICATE, object: residentKind },
1454
+ { subject: resident, predicate: "mgx:currently-in", object: dug },
1455
+ ...classMassFacts(rows, resident, residentKind),
1456
+ ...spawned.map((thing) => ({ subject: resident, predicate: KNOWS_ABOUT_PREDICATE, object: thing })),
1457
+ ] : []),
1458
+ ],
1459
+ digNarration(direction, { isDen, spawned, resident }),
1460
+ `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}`,
1461
+ `dig ${direction} out of the ${here}`,
1462
+ );
1463
+ }
1464
+
1465
+ async function handleEatVerb(ctx) {
1466
+ const { rows, state, here, k, object, commit, noteFor, memoryDir, cache, actingSubject } = ctx;
1467
+ const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
1468
+ if (!present) {
1469
+ return answer(
1470
+ `I don't see a ${object} here.`,
1471
+ noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
1472
+ { miss: true },
1473
+ );
1474
+ }
1475
+ if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
1476
+ return answer(
1477
+ `the ${object} isn't food.`,
1478
+ noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
1479
+ { miss: true },
1480
+ );
1481
+ }
1482
+ const eaterMass = state.masses.get(actingSubject)?.value ?? null;
1483
+ if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
1484
+ return answer(
1485
+ `you're too full to eat the ${object}.`,
1486
+ noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
1487
+ { miss: true },
1488
+ );
1489
+ }
1490
+ const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
1491
+ const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
1492
+ // Eating is the one act that ends a thing, so the eater is the one witness
1493
+ // whose knowledge of it goes out of date on the spot. Every route into the
1494
+ // eat verb — a typed command, a scripted mud turn — passes here, so the
1495
+ // claim gets written once for all of them.
1496
+ await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
1497
+ return commit(
1498
+ [
1499
+ { subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
1500
+ { subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
1501
+ ],
1502
+ `you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
1503
+ `eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
1504
+ `eat the ${object}`,
1505
+ );
1506
+ }
1507
+
1508
+ async function handlePutVerb(ctx) {
1509
+ const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
1510
+ const container = cmd.indirectObject;
1511
+ if (!carriedBy(state, object, actingSubject)) {
1512
+ return answer(
1513
+ `you're not carrying the ${object}.`,
1514
+ noteFor(`put — ${object} isn't carried; precondition declined by name`),
1515
+ { miss: true },
1516
+ );
1517
+ }
1518
+ if (visibleRoomOf(container, { rows, state }) !== here) {
1519
+ return answer(
1520
+ `I don't see a ${container} here.`,
1521
+ noteFor(`put — ${container} isn't visible in the ${here}; declined`),
1522
+ { miss: true },
1523
+ );
1524
+ }
1525
+ if (!isContainer(rows, container)) {
1526
+ return answer(
1527
+ `the ${container} doesn't hold things.`,
1528
+ noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
1529
+ { miss: true },
1530
+ );
1531
+ }
1532
+ if (!state.openness.get(container)?.open) {
1533
+ return answer(
1534
+ `the ${container} is closed.`,
1535
+ noteFor(`put — the ${container} isn't open; precondition declined by name`),
1536
+ { miss: true },
1537
+ );
1538
+ }
1539
+ return commit(
1540
+ [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
1541
+ `you put the ${object} in the ${container}.`,
1542
+ `put — the taught "put" family fired; the ${object} now sits in the ${container}`,
1543
+ `put the ${object} in the ${container}`,
1544
+ );
1545
+ }
1546
+
1547
+ // open / unlock / close — the container verbs, sharing one handler (all
1548
+ // three keys in STATE_VERB_HANDLERS below point here) because they share a
1549
+ // precondition: presence and container-ness stay hand-checked (visibility
1550
+ // gating, not a state precondition), the same visibleRoomOf check
1551
+ // examine/talk/take already use, not a hand-rolled duplicate. unlock's
1552
+ // instrument match stays fully hand-written below it too — it needs a
1553
+ // third, externally-supplied binding beyond subject/target, which this
1554
+ // retrofit does not attempt. open/close's lock-state and open/closed
1555
+ // checks, and their mgx:is-open write, are taught "fact-value" precond/
1556
+ // effect rows consulted through domain.mjs; only the hidden-contents
1557
+ // reveal (a variable-arity effect over a discovered set) stays hand-written
1558
+ // JS, since no shipped rule shape covers that either.
1559
+ async function handleContainerVerb(ctx) {
1560
+ const { cmd, rows, state, here, k, object, place, commit, noteFor, ruleRows } = ctx;
1561
+ if (visibleRoomOf(object, { rows, state }) !== here) {
1562
+ return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
1563
+ }
1564
+ if (!isContainer(rows, object)) {
1565
+ return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
1566
+ }
1567
+
1568
+ if (cmd.verb === "open" || cmd.verb === "close") {
1569
+ const domain = compileDomain(rows, ruleRows);
1570
+ const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
1571
+ const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
1572
+ if (!effect) {
1573
+ return answer(
1574
+ `this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
1575
+ noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
1576
+ { miss: true },
1577
+ );
1578
+ }
1579
+ const factState = containerDatatypeState(state, object);
1580
+ const failed = taughtAction.preconds.find((p) => !precondHolds(p, ctx.actingSubject, object, factState, domain));
1581
+ if (failed) {
1582
+ const text = failed.predicate === "mgx:stands-locked-in"
1583
+ ? `the ${object} is locked.`
1584
+ : cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
1585
+ return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
1586
+ }
1587
+ const effSubject = roleBinding(effect.subjectRole, ctx.actingSubject, object, domain);
1588
+ const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
1589
+
1590
+ if (cmd.verb === "open") {
1591
+ const revealed = [...state.placements]
1592
+ .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
1593
+ .map(([thing]) => thing)
1594
+ .sort();
1595
+ return commit(
1596
+ [
1597
+ writeIsOpen,
1598
+ ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
1599
+ ],
1600
+ revealed.length
1601
+ ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
1602
+ : `you open the ${object}. It's empty.`,
1603
+ `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
1604
+ `open the ${object}`,
1605
+ );
1606
+ }
1607
+ return commit(
1608
+ [writeIsOpen],
1609
+ `you close the ${object}.`,
1610
+ `close — ${object} closes via the taught "close" family's effect`,
1611
+ `close the ${object}`,
1612
+ );
1613
+ }
1614
+
1615
+ // unlock
1616
+ if (place.predicate !== "mgx:stands-locked-in") {
1617
+ return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
1618
+ }
1619
+ if (!cmd.instrument) {
1620
+ return answer(
1621
+ `unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
1622
+ noteFor("unlock — no instrument named; asked, never guessed"),
1623
+ { miss: true },
1624
+ );
1625
+ }
1626
+ const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
1627
+ if (!required) {
1628
+ return answer(
1629
+ `nothing in this world says what unlocks the ${object}.`,
1630
+ noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
1631
+ { miss: true },
1632
+ );
1633
+ }
1634
+ if (!carriedBy(state, cmd.instrument, ctx.actingSubject)) {
1635
+ return answer(
1636
+ `you're not carrying the ${cmd.instrument}.`,
1637
+ noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
1638
+ { miss: true },
1639
+ );
1640
+ }
1641
+ if (cmd.instrument !== required) {
1642
+ return answer(
1643
+ `the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
1644
+ noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
1645
+ { miss: true },
1646
+ );
1647
+ }
1648
+ return commit(
1649
+ [{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
1650
+ `you unlock the ${object} with the ${required}.`,
1651
+ `unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
1652
+ `unlock the ${object}`,
1653
+ );
1654
+ }
1655
+
1656
+ // The data-driven verb table itself: every state-changing verb this engine
1657
+ // knows, mapped to the handler that runs it. A world's own taught action
1658
+ // family (checked before this table is ever consulted) decides whether the
1659
+ // verb exists in THIS world; this table decides which JS runs once it does.
1660
+ const STATE_VERB_HANDLERS = {
1661
+ go: handleGoVerb,
1662
+ take: handleTakeVerb,
1663
+ drop: handleDropOrGiveVerb,
1664
+ give: handleDropOrGiveVerb,
1665
+ dig: handleDigVerb,
1666
+ eat: handleEatVerb,
1667
+ put: handlePutVerb,
1668
+ open: handleContainerVerb,
1669
+ close: handleContainerVerb,
1670
+ unlock: handleContainerVerb,
1671
+ };
1672
+
836
1673
  export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject = "player" }) {
837
1674
  const memory = await loadMemory(memoryDir);
838
1675
  const rows = readFactRows(memory);
@@ -854,6 +1691,13 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
854
1691
  { miss: true },
855
1692
  );
856
1693
  }
1694
+ if (OUT_OF_PLAY_PLACES.has(here)) {
1695
+ return answer(
1696
+ `${outOfPlayPhrase(actingSubject, here)} — it takes no more turns in this world.`,
1697
+ noteFor(`${cmd.verb} — ${actingSubject} is placed out of play (${here}); every command it gives declines from here on`),
1698
+ { miss: true },
1699
+ );
1700
+ }
857
1701
 
858
1702
  if (cmd.verb === "look" && !cmd.object) {
859
1703
  const digest = await worldDigest(here, { memoryDir, memory, rows, state, graph, actingSubject });
@@ -903,7 +1747,7 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
903
1747
  { miss: true },
904
1748
  );
905
1749
  }
906
- const person = isTyped(rows, object, "person");
1750
+ const person = isCastMember(rows, state, object);
907
1751
  // "look <object>" on a real placed prop is the grounded close look: every
908
1752
  // physical fact the world writes about the thing (its placement, its
909
1753
  // within-room position, any datatype property — all via the SAME
@@ -997,310 +1841,18 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
997
1841
  const object = cmd.object;
998
1842
  const place = object ? state.placements.get(object) ?? null : null;
999
1843
 
1000
- if (cmd.verb === "go") {
1001
- const target = state.exits.get(here)?.get(cmd.direction);
1002
- if (!target) {
1003
- return answer(
1004
- `there's no exit ${cmd.direction} from the ${here}.`,
1005
- noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
1006
- { miss: true },
1007
- );
1008
- }
1009
- return commit(
1010
- [{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1011
- `you go ${cmd.direction}. Now in the ${target}.`,
1012
- `go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
1013
- `move through the world (now in the ${target})`,
1014
- target,
1015
- );
1016
- }
1017
-
1018
- if (cmd.verb === "take") {
1019
- if (isTyped(rows, object, "room")) {
1020
- return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
1021
- }
1022
- if (carriedBy(state, object, actingSubject)) {
1023
- return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
1024
- }
1025
- if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
1026
- return answer(
1027
- `the ${object} is fixed in place — it can't be taken.`,
1028
- noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
1029
- { miss: true },
1030
- );
1031
- }
1032
- if (place && place.predicate === "mgx:currently-in" && place.object === here) {
1033
- return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
1034
- }
1035
- if (visibleRoomOf(object, { rows, state }) !== here) {
1036
- if (backgroundOnlyMention(rows, state, object)) {
1037
- return answer(
1038
- `the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
1039
- noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
1040
- { miss: true },
1041
- );
1042
- }
1043
- 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 });
1044
- }
1045
- return commit(
1046
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1047
- `you take the ${object}.`,
1048
- `take — the taught "take" family fired; ${object} is now carried`,
1049
- `carry the ${object}`,
1050
- );
1051
- }
1052
-
1053
- if (cmd.verb === "drop" || cmd.verb === "give") {
1054
- if (!carriedBy(state, object, actingSubject)) {
1055
- return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
1056
- }
1057
- if (cmd.verb === "drop") {
1058
- return commit(
1059
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1060
- `you drop the ${object} in the ${here}.`,
1061
- `drop — the taught "drop" family fired; ${object} rests in the ${here}`,
1062
- `set the ${object} down`,
1063
- );
1064
- }
1065
- const receiver = cmd.indirectObject;
1066
- if (!isTyped(rows, receiver, "person") || state.placements.get(receiver)?.object !== here) {
1067
- return answer(`the ${receiver} isn't here.`, noteFor(`give — ${receiver} isn't a person in the ${here}; precondition declined by name`), { miss: true });
1068
- }
1069
- return commit(
1070
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1071
- `you give the ${object} to the ${receiver}.`,
1072
- `give — the taught "give" family fired; the ${receiver} holds the ${object}`,
1073
- `hand the ${object} over`,
1074
- );
1075
- }
1076
-
1077
- if (cmd.verb === "dig") {
1078
- const direction = cmd.direction;
1079
- if (state.exits.get(here)?.get(direction)) {
1080
- return answer(
1081
- `there's already an exit ${direction} from the ${here}.`,
1082
- noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
1083
- { miss: true },
1084
- );
1085
- }
1086
- const back = OPPOSITE_DIRECTION.get(direction);
1087
- if (!back) {
1088
- return answer(
1089
- `I don't know which way back a ${direction} tunnel would run.`,
1090
- noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
1091
- { miss: true },
1092
- );
1093
- }
1094
- const dug = freshRoomId(rows, here, direction);
1095
- const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, DIG_SPAWN_MAX - DIG_SPAWN_MIN + 1);
1096
- const spawnedKinds = DIG_SPAWN_KINDS.slice(0, spawnCount);
1097
- const spawned = spawnedKinds.map((kind) => `${kind}-${dug}`);
1098
- return commit(
1099
- [
1100
- { subject: dug, predicate: "rdf:type", object: "room" },
1101
- { subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
1102
- { subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
1103
- // Typed to its OWN kind, not a flat "portable" — a spawned kind the
1104
- // world already declares rdfs:subClassOf food (DIG_SPAWN_KINDS may
1105
- // carry one) needs its real class reachable here for isFood's own
1106
- // objectClassChain walk, or digging up "carrot-..." would still read
1107
- // as inedible scenery.
1108
- ...spawnedKinds.flatMap((kind, i) => ([
1109
- { subject: spawned[i], predicate: "rdf:type", object: kind },
1110
- { subject: spawned[i], predicate: "mgx:located-in", object: dug },
1111
- ])),
1112
- ],
1113
- spawned.length
1114
- ? `you dig ${direction} and open up a new room. In the loose earth: the ${spawned.join(", the ")}.`
1115
- : `you dig ${direction} and open up a new room. There's nothing in it but bare earth.`,
1116
- `dig — minted the room ${dug} with exits both ways (${direction} out, ${back} back)${spawned.length ? `, and ${spawned.length} object(s) in it` : ""}; digging spends the turn, so the digger stays in the ${here}`,
1117
- `dig ${direction} out of the ${here}`,
1118
- );
1119
- }
1120
-
1121
- if (cmd.verb === "eat") {
1122
- const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
1123
- if (!present) {
1124
- return answer(
1125
- `I don't see a ${object} here.`,
1126
- noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
1127
- { miss: true },
1128
- );
1129
- }
1130
- if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
1131
- return answer(
1132
- `the ${object} isn't food.`,
1133
- noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
1134
- { miss: true },
1135
- );
1136
- }
1137
- const eaterMass = state.masses.get(actingSubject)?.value ?? null;
1138
- if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
1139
- return answer(
1140
- `you're too full to eat the ${object}.`,
1141
- noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
1142
- { miss: true },
1143
- );
1144
- }
1145
- const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
1146
- const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
1147
- return commit(
1148
- [
1149
- { subject: `${actingSubject}@turn${k}`, predicate: MASS_PREDICATE, object: String(grown) },
1150
- { subject: `${object}@turn${k}`, predicate: "mgx:located-in", object: CONSUMED_PLACE },
1151
- ],
1152
- `you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
1153
- `eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
1154
- `eat the ${object}`,
1155
- );
1156
- }
1157
-
1158
- if (cmd.verb === "put") {
1159
- const container = cmd.indirectObject;
1160
- if (!carriedBy(state, object, actingSubject)) {
1161
- return answer(
1162
- `you're not carrying the ${object}.`,
1163
- noteFor(`put — ${object} isn't carried; precondition declined by name`),
1164
- { miss: true },
1165
- );
1166
- }
1167
- if (visibleRoomOf(container, { rows, state }) !== here) {
1168
- return answer(
1169
- `I don't see a ${container} here.`,
1170
- noteFor(`put — ${container} isn't visible in the ${here}; declined`),
1171
- { miss: true },
1172
- );
1173
- }
1174
- if (!isContainer(rows, container)) {
1175
- return answer(
1176
- `the ${container} doesn't hold things.`,
1177
- noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
1178
- { miss: true },
1179
- );
1180
- }
1181
- if (!state.openness.get(container)?.open) {
1182
- return answer(
1183
- `the ${container} is closed.`,
1184
- noteFor(`put — the ${container} isn't open; precondition declined by name`),
1185
- { miss: true },
1186
- );
1187
- }
1188
- return commit(
1189
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
1190
- `you put the ${object} in the ${container}.`,
1191
- `put — the taught "put" family fired; the ${object} now sits in the ${container}`,
1192
- `put the ${object} in the ${container}`,
1193
- );
1194
- }
1195
-
1196
- // open / unlock / close — the container verbs. presence and container-ness
1197
- // stay hand-checked here (visibility gating, not a state precondition);
1198
- // unlock's instrument match stays fully hand-written below it too — it
1199
- // needs a third, externally-supplied binding beyond subject/target, which
1200
- // this retrofit does not attempt. open/close's lock-state and open/closed
1201
- // checks, and their mgx:is-open write, are now taught "fact-value"
1202
- // precond/effect rows consulted through domain.mjs below; only the
1203
- // hidden-contents reveal (a variable-arity effect over a discovered set)
1204
- // stays hand-written JS, since no shipped rule shape covers that either.
1205
- // Presence reuses visibleRoomOf (the SAME check examine/talk/take already
1206
- // use), not a hand-rolled duplicate: an earlier version of this check
1207
- // excluded mgx:currently-in outright, so a genuinely-present NPC (placed
1208
- // that way, not fixed-in/stands-locked-in) fell into "I don't see a X
1209
- // here" instead of reaching the isContainer check just below, which would
1210
- // have honestly said "the X doesn't open."
1211
- if (visibleRoomOf(object, { rows, state }) !== here) {
1212
- return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
1213
- }
1214
- if (!isContainer(rows, object)) {
1215
- return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
1216
- }
1217
-
1218
- if (cmd.verb === "open" || cmd.verb === "close") {
1219
- const domain = compileDomain(rows, ruleRows);
1220
- const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
1221
- const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
1222
- if (!effect) {
1223
- return answer(
1224
- `this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
1225
- noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
1226
- { miss: true },
1227
- );
1228
- }
1229
- const factState = containerDatatypeState(state, object);
1230
- const failed = taughtAction.preconds.find((p) => !precondHolds(p, actingSubject, object, factState, domain));
1231
- if (failed) {
1232
- const text = failed.predicate === "mgx:stands-locked-in"
1233
- ? `the ${object} is locked.`
1234
- : cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
1235
- return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
1236
- }
1237
- const effSubject = roleBinding(effect.subjectRole, actingSubject, object, domain);
1238
- const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
1239
-
1240
- if (cmd.verb === "open") {
1241
- const revealed = [...state.placements]
1242
- .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
1243
- .map(([thing]) => thing)
1244
- .sort();
1245
- return commit(
1246
- [
1247
- writeIsOpen,
1248
- ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
1249
- ],
1250
- revealed.length
1251
- ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
1252
- : `you open the ${object}. It's empty.`,
1253
- `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
1254
- `open the ${object}`,
1255
- );
1256
- }
1257
- return commit(
1258
- [writeIsOpen],
1259
- `you close the ${object}.`,
1260
- `close — ${object} closes via the taught "close" family's effect`,
1261
- `close the ${object}`,
1262
- );
1263
- }
1264
-
1265
- // unlock
1266
- if (place.predicate !== "mgx:stands-locked-in") {
1267
- return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
1268
- }
1269
- if (!cmd.instrument) {
1844
+ const handler = STATE_VERB_HANDLERS[cmd.verb];
1845
+ if (!handler) {
1270
1846
  return answer(
1271
- `unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
1272
- noteFor("unlock — no instrument named; asked, never guessed"),
1847
+ `this world doesn't know how to run the verb "${cmd.verb}" yet.`,
1848
+ noteFor(`${cmd.verb}a taught family exists but no handler is wired for it; honest decline`),
1273
1849
  { miss: true },
1274
1850
  );
1275
1851
  }
1276
- const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
1277
- if (!required) {
1278
- return answer(
1279
- `nothing in this world says what unlocks the ${object}.`,
1280
- noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
1281
- { miss: true },
1282
- );
1283
- }
1284
- if (!carriedBy(state, cmd.instrument, actingSubject)) {
1285
- return answer(
1286
- `you're not carrying the ${cmd.instrument}.`,
1287
- noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
1288
- { miss: true },
1289
- );
1290
- }
1291
- if (cmd.instrument !== required) {
1292
- return answer(
1293
- `the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
1294
- noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
1295
- { miss: true },
1296
- );
1297
- }
1298
- return commit(
1299
- [{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
1300
- `you unlock the ${object} with the ${required}.`,
1301
- `unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
1302
- `unlock the ${object}`,
1303
- );
1852
+ return handler({
1853
+ cmd, rows, state, here, k, family, families, ruleRows, object, place,
1854
+ commit, noteFor, actingSubject, memoryDir, world, cache, graph, memory,
1855
+ });
1304
1856
  }
1305
1857
 
1306
1858
  // A mid-game "where is X" aside. Optional trailing "now": the question means
@@ -1338,10 +1890,10 @@ async function worldWhereAnswer(line, { memoryDir, actingSubject = "player" }) {
1338
1890
  { miss: true, goal: `locate the ${thing}` },
1339
1891
  );
1340
1892
  }
1341
- if (place.object === CONSUMED_PLACE) {
1893
+ if (OUT_OF_PLAY_PLACES.has(place.object)) {
1342
1894
  return answer(
1343
- `the ${thing} has been eaten — it's gone from the world.`,
1344
- `ADVENTURE — where-aside: ${thing} was eaten, so it has no place left to name`,
1895
+ `${outOfPlayPhrase(thing, place.object)} — it's gone from the world.`,
1896
+ `ADVENTURE — where-aside: ${thing} is out of play (${place.object}), so it has no place left to name`,
1345
1897
  { goal: `locate the ${thing}` },
1346
1898
  );
1347
1899
  }
@@ -1396,11 +1948,28 @@ async function worldOpennessAnswer(line, { memoryDir }) {
1396
1948
  const WORLD_WHERE_AM_I_RE = /^where\s+am\s+i(?:\s+now)?[?.!\s]*$/i;
1397
1949
  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;
1398
1950
  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;
1951
+ // "who is here" — the room's cast, the question a shared world invites the
1952
+ // moment a second animal walks in. Answered from the same currently-in
1953
+ // placements the talk verb resolves against, so who is named is exactly who
1954
+ // can be talked to.
1955
+ const WORLD_WHO_HERE_RE =
1956
+ /^(?: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;
1399
1957
  // "what food do you know about" and its natural variants — the asking
1400
1958
  // character's OWN durable food knowledge (personKnownFoodLines), never the
1401
- // whole world's food. Covers the plural ("foods") and the "what do you know
1402
- // about food" inversion alongside the base phrasing.
1403
- const WORLD_KNOWN_FOOD_RE = /^(?:what\s+foods?\s+do\s+you\s+know\s+about|what\s+do\s+you\s+know\s+about\s+food)[?.!\s]*$/i;
1959
+ // whole world's food. The trailing "about" is optional, "know" swaps for
1960
+ // "found"/"seen"/"heard about", and the "what do you know about food"
1961
+ // inversion and a plain yes/no lead-in both count: every one of these is the
1962
+ // same question, and a phrasing this lane doesn't recognise leaves the world
1963
+ // entirely and comes back answered as vocabulary.
1964
+ const WORLD_KNOWN_FOOD_RE = new RegExp(
1965
+ "^(?:"
1966
+ + "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)"
1967
+ + "|what\\s+do\\s+you\\s+know\\s+about\\s+(?:any\\s+)?foods?"
1968
+ + "|do\\s+you\\s+know\\s+(?:about|of)\\s+(?:any\\s+)?foods?"
1969
+ + "|where\\s+is\\s+(?:the\\s+)?food"
1970
+ + ")[?.!\\s]*$",
1971
+ "i",
1972
+ );
1404
1973
 
1405
1974
  /** The in-game orientation asides, answered from the world fold: the player's
1406
1975
  * room, the room's real affordances, and the world's objective. Null when the
@@ -1410,12 +1979,24 @@ async function worldContextAnswer(line, { memoryDir, actingSubject = "player" })
1410
1979
  const asksWhere = WORLD_WHERE_AM_I_RE.test(l);
1411
1980
  const asksOptions = WORLD_OPTIONS_RE.test(l);
1412
1981
  const asksQuest = WORLD_QUEST_RE.test(l);
1413
- if (!asksWhere && !asksOptions && !asksQuest) return null;
1982
+ const asksWhoIsHere = WORLD_WHO_HERE_RE.test(l);
1983
+ if (!asksWhere && !asksOptions && !asksQuest && !asksWhoIsHere) return null;
1414
1984
  let rows;
1415
1985
  try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
1416
1986
  const state = foldWorldState(worldActionRows(rows));
1417
1987
  const here = state.placements.get(actingSubject)?.object ?? null;
1418
1988
 
1989
+ if (asksWhoIsHere) {
1990
+ const cast = here ? castInRoom(rows, state, here, actingSubject) : [];
1991
+ return answer(
1992
+ cast.length
1993
+ ? `here with you in the ${here}: the ${cast.join(", the ")}. You can talk to ${cast.length > 1 ? "any of them" : `the ${cast[0]}`}.`
1994
+ : `nobody else is${here ? ` in the ${here}` : " here"} right now.`,
1995
+ `ADVENTURE — who-is-here aside: the ${here}'s cast from the current placements fold, the same set the talk verb resolves against`,
1996
+ { goal: "see who else is here" },
1997
+ );
1998
+ }
1999
+
1419
2000
  if (asksWhere) {
1420
2001
  return here
1421
2002
  ? answer(`you are in the ${here}.`, "ADVENTURE — where-am-I aside: the player's own room from the current world fold", { goal: "check where you are" })
@@ -1473,6 +2054,39 @@ async function worldKnownFoodAnswer(line, { memoryDir, actingSubject = "player"
1473
2054
  );
1474
2055
  }
1475
2056
 
2057
+ // A question that names one of the world's OWN minted ids — "sett-1",
2058
+ // "groundhog-1", "carrot-2" — can only be about this world: nothing else in
2059
+ // the session has ever heard that token. So when no world shape matched it,
2060
+ // the fall-through is a plain misroute, and in a session with no code graph it
2061
+ // comes back as the code-graph wall, which says nothing true about a burrow.
2062
+ // The gate is the hyphen: a world id that is a plain dictionary word ("lamp",
2063
+ // "garden") stays out of this, so an ordinary mid-game question about an
2064
+ // ordinary word keeps the lane it has always had.
2065
+ const WORLD_QUESTION_LEAD_RE =
2066
+ /^(?:who|what|where|which|how|why|when|tell\s+me|describe|do\s+you|does|is|are|can\s+you|any)\b/i;
2067
+ const WORLD_MINTED_ID_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
2068
+
2069
+ /** A digest about the world-minted id a question names, or null when the line
2070
+ * is not a question, names none, or the world places nothing by that name. */
2071
+ async function worldMentionAnswer(line, { memoryDir, graph, actingSubject = "player" }) {
2072
+ const l = String(line).trim();
2073
+ if (!/\?\s*$/.test(l) && !WORLD_QUESTION_LEAD_RE.test(l)) return null;
2074
+ const spoken = new Set(l.toLowerCase().replace(/[?.!,;:"']/g, " ").split(/\s+/).filter(Boolean));
2075
+ let memory;
2076
+ try { memory = await loadMemory(memoryDir); } catch { return null; }
2077
+ const rows = readFactRows(memory);
2078
+ const state = foldWorldState(worldActionRows(rows));
2079
+ const named = worldIndividualNames(rows)
2080
+ .find((subject) => WORLD_MINTED_ID_RE.test(subject) && spoken.has(subject));
2081
+ if (!named) return null;
2082
+ const digest = await worldDigest(named, { memoryDir, memory, rows, state, graph, actingSubject });
2083
+ return answer(
2084
+ digest ?? `nothing more about the ${named} is written down yet.`,
2085
+ `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`,
2086
+ { goal: `find out about the ${named}` },
2087
+ );
2088
+ }
2089
+
1476
2090
  async function inventoryAnswer({ memoryDir, graph, actingSubject = "player" }) {
1477
2091
  const memory = await loadMemory(memoryDir);
1478
2092
  const rows = readFactRows(memory);
@@ -1577,6 +2191,78 @@ async function bindPronouns(cmd, { discourseHolder, memoryDir, actingSubject = "
1577
2191
  return { cmd: bound };
1578
2192
  }
1579
2193
 
2194
+ // ---- the world's own vocabulary ----------------------------------------------
2195
+ //
2196
+ // A world's minted ids are words only that world knows. "groundhog-1" is in no
2197
+ // dictionary, so the parser's lexicon gate rejects "talk to groundhog-1" as an
2198
+ // undeclared word and the whole command dies before the talk verb ever sees
2199
+ // it. Declaring those ids as PROPER NAMES for the duration of a world command
2200
+ // fixes that: a proper name outranks every other category, so the id resolves
2201
+ // as itself. Ids the core lexicon already knows are left out, so no ordinary
2202
+ // word changes category because a world happens to use it — and the extension
2203
+ // is scoped to this lane, so the teach and ask lanes keep the plain lexicon.
2204
+
2205
+ let worldLexiconCache = { key: null, base: null, lexicon: null };
2206
+
2207
+ function worldLexicon(rows, base) {
2208
+ const names = worldIndividualNames(rows).filter((name) => !classify(name, base));
2209
+ const key = names.join("");
2210
+ if (worldLexiconCache.base === base && worldLexiconCache.key === key) return worldLexiconCache.lexicon;
2211
+ const lexicon = withProperNames(base, names);
2212
+ worldLexiconCache = { key, base, lexicon };
2213
+ return lexicon;
2214
+ }
2215
+
2216
+ async function worldAwareLexicon(memoryDir, lexicon) {
2217
+ const base = lexicon ?? loadLexicon();
2218
+ try {
2219
+ return worldLexicon(readFactRows(await loadMemory(memoryDir)), base);
2220
+ } catch {
2221
+ return base;
2222
+ }
2223
+ }
2224
+
2225
+ // ---- the vocative: naming who the line is addressed to ------------------------
2226
+ //
2227
+ // Give a window a character's name and players start using it: "groundhog-1
2228
+ // what do you know about food", "mole-1, dig north". The name is who the line
2229
+ // is addressed to, not part of the question — but it makes the line fit no
2230
+ // world shape at all, so the whole turn leaves this lane and comes back
2231
+ // answered as something else entirely (a code question, in a session with no
2232
+ // code graph). Stripping a vocative that names one of the world's OWN placed
2233
+ // individuals costs one fold read, and only on a line that has already failed
2234
+ // on its own terms.
2235
+
2236
+ const escapeForRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
2237
+
2238
+ /** `line` with a leading or trailing vocative naming a placed world
2239
+ * individual removed, or null when it carries none (or when the name is the
2240
+ * whole line, which is a bare mention, not an address). Pure. */
2241
+ export function withoutWorldVocative(line, names) {
2242
+ const l = String(line).trim();
2243
+ for (const name of names) {
2244
+ const escaped = escapeForRegExp(name);
2245
+ const leading = new RegExp(`^${escaped}\\s*[,:;]?\\s+`, "i");
2246
+ if (leading.test(l)) {
2247
+ const rest = l.replace(leading, "").trim();
2248
+ if (rest) return rest;
2249
+ }
2250
+ const trailing = new RegExp(`[\\s,]+${escaped}\\s*([?.!]*)$`, "i");
2251
+ if (trailing.test(l)) {
2252
+ const rest = l.replace(trailing, "$1").trim();
2253
+ if (rest) return rest;
2254
+ }
2255
+ }
2256
+ return null;
2257
+ }
2258
+
2259
+ async function addressedLine(line, { memoryDir }) {
2260
+ let rows;
2261
+ try { rows = readFactRows(await loadMemory(memoryDir)); } catch { return null; }
2262
+ const state = foldWorldState(worldActionRows(rows));
2263
+ return withoutWorldVocative(line, [...state.placements.keys()].sort());
2264
+ }
2265
+
1580
2266
  // ---- the lane ----------------------------------------------------------------
1581
2267
 
1582
2268
  /**
@@ -1644,13 +2330,29 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1644
2330
  note: "ADVENTURE — a plan frame arrived mid-adventure; the slot holds one thing at a time",
1645
2331
  };
1646
2332
  }
2333
+ const direct = await liveWorldAnswer(line, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
2334
+ if (direct) return direct;
2335
+ const addressed = await addressedLine(line, { memoryDir });
2336
+ if (addressed) {
2337
+ const readdressed = await liveWorldAnswer(addressed, { world: adventure.world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject });
2338
+ if (readdressed) return readdressed;
2339
+ }
2340
+ return null; // a mid-game aside — the ordinary lanes answer, world untouched
2341
+ }
2342
+
2343
+ /** One line against a LIVE world: inventory, an imperative command, then the
2344
+ * in-game asides. Null when the world has no answer for it, which is what
2345
+ * lets an ordinary mid-game question keep its own lane. Split out from the
2346
+ * lane itself so a line carrying a vocative can be re-offered here once,
2347
+ * stripped, without the two paths ever drifting apart. */
2348
+ async function liveWorldAnswer(line, { world, memoryDir, env, graph, cache, lexicon, discourseHolder, actingSubject }) {
1647
2349
  if (INVENTORY_RE.test(line)) return inventoryAnswer({ memoryDir, graph, actingSubject });
1648
- const parsed = parseImperative(line, lexicon ?? undefined);
2350
+ const parsed = parseImperative(line, await worldAwareLexicon(memoryDir, lexicon));
1649
2351
  if (parsed) {
1650
2352
  const bound = await bindPronouns(parsed, { discourseHolder, memoryDir, actingSubject });
1651
2353
  if (bound.nudge) return bound.nudge;
1652
2354
  const cmd = bound.cmd;
1653
- const result = await runWorldCommand(cmd, { world: adventure.world, memoryDir, env, graph, cache, actingSubject });
2355
+ const result = await runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject });
1654
2356
  // The object a command SUCCESSFULLY named registers as a discourse referent
1655
2357
  // a later pronoun binds to — so "look lamp" then "examine it" reads the
1656
2358
  // lamp, and "talk to housekeeper" makes "him"/"her" the housekeeper. A miss
@@ -1682,5 +2384,7 @@ export async function adventureTurn(line, { planHolder, memoryDir, sessionId = "
1682
2384
  if (contextAside) return contextAside;
1683
2385
  const knownFoodAside = await worldKnownFoodAnswer(line, { memoryDir, actingSubject });
1684
2386
  if (knownFoodAside) return knownFoodAside;
2387
+ const mentionAside = await worldMentionAnswer(line, { memoryDir, graph, actingSubject });
2388
+ if (mentionAside) return mentionAside;
1685
2389
  return null; // a mid-game aside — the ordinary lanes answer, world untouched
1686
2390
  }