@polycode-projects/the-mechanical-code-talker 4.0.0 → 4.1.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 (108) hide show
  1. package/README.md +178 -9
  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 +401 -0
  6. package/corpus/worlds/index.json.gz +0 -0
  7. package/corpus/worlds/manifest.json +49 -9
  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-hollow.jsonl.gz +0 -0
  11. package/corpus/worlds/shards/mud-warren.jsonl.gz +0 -0
  12. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  13. package/corpus/worlds/src/greyvale-museum.jsonl +136 -0
  14. package/corpus/worlds/src/lantern-cottage.jsonl +60 -0
  15. package/corpus/worlds/src/mud-hollow.jsonl +82 -0
  16. package/corpus/worlds/src/mud-warren.jsonl +124 -0
  17. package/corpus/worlds/src/spider-fly.jsonl +1 -1
  18. package/data/sprites/animal-icon.toml +11 -5
  19. package/data/sprites/book-icon.toml +9 -5
  20. package/data/sprites/cabinet-icon.toml +10 -5
  21. package/data/sprites/cellar-icon.toml +16 -6
  22. package/data/sprites/container-icon.toml +7 -3
  23. package/data/sprites/desk-icon.toml +8 -5
  24. package/data/sprites/dog-icon.toml +8 -5
  25. package/data/sprites/dog-with-colour-icon.toml +13 -12
  26. package/data/sprites/drawing-room-icon.toml +14 -5
  27. package/data/sprites/egg-icon.toml +6 -3
  28. package/data/sprites/fly-icon.toml +10 -5
  29. package/data/sprites/furniture-icon.toml +5 -3
  30. package/data/sprites/garden-icon.toml +10 -3
  31. package/data/sprites/key-icon.toml +3 -1
  32. package/data/sprites/kitchen-icon.toml +15 -6
  33. package/data/sprites/lamp-icon.toml +9 -4
  34. package/data/sprites/letter-icon.toml +7 -4
  35. package/data/sprites/library-icon.toml +14 -3
  36. package/data/sprites/pan-icon.toml +7 -3
  37. package/data/sprites/person-icon.toml +6 -2
  38. package/data/sprites/poodle-icon.toml +1 -1
  39. package/data/sprites/portable-icon.toml +6 -4
  40. package/data/sprites/portrait-icon.toml +7 -4
  41. package/data/sprites/room-icon.toml +11 -2
  42. package/data/sprites/spider-icon.toml +9 -3
  43. package/data/sprites/study-icon.toml +10 -2
  44. package/package.json +5 -4
  45. package/src/adapters/memory/core.mjs +20 -0
  46. package/src/domain/ask-vocab.mjs +71 -0
  47. package/src/domain/ask.mjs +168 -0
  48. package/src/domain/game-config.mjs +11 -0
  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 +18 -0
  52. package/src/domain/mud-facts.mjs +15 -0
  53. package/src/domain/reference-pack.mjs +31 -7
  54. package/src/domain/router/drive.mjs +35 -9
  55. package/src/domain/router/registry.mjs +24 -4
  56. package/src/domain/router/resolver.mjs +102 -40
  57. package/src/domain/scene-compose.mjs +117 -0
  58. package/src/domain/spider-fly-world.mjs +37 -1
  59. package/src/domain/sprite-facts.mjs +0 -0
  60. package/src/domain/sprite-request.mjs +156 -0
  61. package/src/domain/sprite-templates.mjs +169 -20
  62. package/src/services/adventure-editor.mjs +8 -14
  63. package/src/services/adventure-viz.mjs +209 -157
  64. package/src/services/adventure.mjs +526 -391
  65. package/src/services/chat-page-viz.mjs +69 -25
  66. package/src/services/chat.mjs +200 -51
  67. package/src/services/code-explorer-viz.mjs +102 -62
  68. package/src/services/extract-facts.mjs +4 -7
  69. package/src/services/ingest-viz.mjs +68 -82
  70. package/src/services/ledger-viz.mjs +136 -67
  71. package/src/services/memory-panel-viz.mjs +62 -0
  72. package/src/services/mud-editor.mjs +10 -15
  73. package/src/services/mud-turn.mjs +6 -6
  74. package/src/services/mud-viz.mjs +1016 -208
  75. package/src/services/p2p-room.mjs +90 -23
  76. package/src/services/plan-pddl.mjs +3 -1
  77. package/src/services/plan-viz.mjs +123 -64
  78. package/src/services/research-viz.mjs +160 -108
  79. package/src/services/spider-fly-turn.mjs +15 -23
  80. package/src/services/spider-fly-viz.mjs +146 -161
  81. package/src/services/spider-fly.mjs +69 -11
  82. package/src/services/sprite-catalog-viz.mjs +414 -240
  83. package/src/services/viz-boot.mjs +71 -0
  84. package/src/services/viz-room-graph.mjs +203 -0
  85. package/src/services/viz-theme.mjs +90 -1
  86. package/src/services/viz-ticker.mjs +22 -0
  87. package/src/surfaces/web/adventure-browser-entry.mjs +49 -33
  88. package/src/surfaces/web/chat-browser-entry.mjs +30 -105
  89. package/src/surfaces/web/code-explorer-browser-entry.mjs +168 -24
  90. package/src/surfaces/web/ingest-browser-entry.mjs +3 -13
  91. package/src/surfaces/web/ledger-browser-entry.mjs +7 -47
  92. package/src/surfaces/web/memory-ask-browser.bundle.js +127 -124
  93. package/src/surfaces/web/memory-stats.mjs +11 -0
  94. package/src/surfaces/web/mud-browser-entry.mjs +71 -29
  95. package/src/surfaces/web/plan-browser-entry.mjs +22 -40
  96. package/src/surfaces/web/research-browser-entry.mjs +26 -41
  97. package/src/surfaces/web/spider-fly-browser-entry.mjs +45 -28
  98. package/src/surfaces/web/sprites-browser-entry.mjs +14 -27
  99. package/src/surfaces/web/turn-session.mjs +120 -0
  100. package/src/tools/definitions.mjs +30 -0
  101. package/src/tools/handlers/index.mjs +6 -3
  102. package/src/tools/handlers/kit.mjs +19 -2
  103. package/src/tools/handlers/tmct-ask.mjs +11 -6
  104. package/src/tools/handlers/tmct-ingest.mjs +5 -1
  105. package/src/tools/handlers/tmct-related.mjs +4 -4
  106. package/src/tools/handlers/tmct-sprite.mjs +147 -0
  107. package/src/tools/memory-fallthrough.mjs +9 -2
  108. package/src/tools/server.mjs +25 -1
@@ -174,7 +174,43 @@ async function resumedPosition(memoryDir) {
174
174
 
175
175
  // ---- the world-state fold ----------------------------------------------------
176
176
 
177
- const SNAPSHOT_RE = /^(.+)@turn(\d+)$/;
177
+ // A snapshot subject is `base@turnN`, or `base@epochE@turnN` once a recast has
178
+ // moved the world past epoch 0 — the fold below reads both, and epoch-0 writes
179
+ // keep the bare form so an unrecast store never changes shape.
180
+ const SNAPSHOT_RE = /^(.+?)@(?:epoch(\d+)@)?turn(\d+)$/;
181
+
182
+ /** Which run of the world a store is on. Recasting a shared world (mud.html's
183
+ * RESET, a slider, the scenario dropdown) reopens the same deterministic
184
+ * instance ids over a fresh store while peers may still hold the old run's
185
+ * snapshots — this marker is how every reader agrees the world has started
186
+ * over. It is an ordinary add-only fact: each recast appends a larger value,
187
+ * the fold takes the max, and merging two peers' stores converges because max
188
+ * is order-free. */
189
+ export const WORLD_EPOCH_PREDICATE = "mgx:world-epoch";
190
+ const WORLD_EPOCH_SUBJECT = "world";
191
+
192
+ /** The bare triple a recast writes to move the world onto `epoch`. The caller
193
+ * supplies provenance the same way it does for the seed facts, so the marker
194
+ * travels (and folds) as a world row. */
195
+ export function worldEpochFact(epoch) {
196
+ return { subject: WORLD_EPOCH_SUBJECT, predicate: WORLD_EPOCH_PREDICATE, object: String(epoch) };
197
+ }
198
+
199
+ /** `{ base, epoch, turn }` for a snapshot subject, or null for a base subject.
200
+ * The one parser every reader outside this file should use — a local
201
+ * `@turn(\d+)$` regex reads an epoch-stamped subject's base as
202
+ * "mole-1@epoch2", which matches no character. */
203
+ export function parseSnapshotSubject(subject) {
204
+ const m = SNAPSHOT_RE.exec(String(subject || ""));
205
+ return m ? { base: m[1], epoch: m[2] ? Number(m[2]) : 0, turn: Number(m[3]) } : null;
206
+ }
207
+
208
+ /** The snapshot subject a turn writes: bare `@turnN` while the world is on
209
+ * epoch 0 (every store that has never been recast, and every fact written
210
+ * before epochs existed), the epoch-stamped form after. */
211
+ export function snapshotSubject(base, turn, epoch = 0) {
212
+ return epoch > 0 ? `${base}@epoch${epoch}@turn${turn}` : `${base}@turn${turn}`;
213
+ }
178
214
  const PLACEMENT_PREDICATES = new Set([
179
215
  "mgx:currently-in", "mgx:located-in", "mgx:fixed-in", "mgx:stands-locked-in", "mgx:hidden-in",
180
216
  ]);
@@ -227,6 +263,36 @@ export function worldActionRows(rows) {
227
263
  });
228
264
  }
229
265
 
266
+ // The predicates a played turn can write. Every one of them already exists
267
+ // above as a private constant; this set is the one place they are gathered so
268
+ // a caller outside this file can ask "is this fact part of the live world"
269
+ // without learning each name. rdf:type and the display name earn their place
270
+ // alongside the folded ones: a dug room and a dug object are minted mid-play,
271
+ // and a reader that never receives their class or their plain name cannot draw
272
+ // them at all.
273
+ const MUD_STATE_PREDICATES = new Set([
274
+ ...PLACEMENT_PREDICATES,
275
+ ...POSITION_PREDICATES,
276
+ OPEN_PREDICATE,
277
+ MASS_PREDICATE,
278
+ KNOWS_ABOUT_PREDICATE,
279
+ DISPLAY_NAME_PREDICATE,
280
+ "rdf:type",
281
+ // Which run the world is on IS live world state: a peer that misses the
282
+ // recast marker keeps folding the old run's snapshots as current.
283
+ WORLD_EPOCH_PREDICATE,
284
+ ]);
285
+
286
+ /** Whether `predicate` carries live world state — where a thing stands, what
287
+ * it weighs, whether it is open, what somebody knows, which way a room leads,
288
+ * and the class and name a dug thing is minted with. The P2P sync filter
289
+ * reads this to tell a fact a turn produced from the page chrome around it.
290
+ * Pure. */
291
+ export function isMudStatePredicate(predicate) {
292
+ const p = String(predicate || "");
293
+ return MUD_STATE_PREDICATES.has(p) || EXIT_PREDICATE_RE.test(p);
294
+ }
295
+
230
296
  /** Every individual the world names — its rooms, its cast, its props, and
231
297
  * anything dug up since — as the plain id strings a parser has to have
232
298
  * DECLARED before it can resolve them. @turnN snapshots are skipped: a
@@ -242,41 +308,61 @@ export function worldIndividualNames(rows) {
242
308
  }
243
309
 
244
310
  /** Fold fact rows into the CURRENT world state: per subject, the newest
245
- * placement (base row = turn 0, @turnN snapshots override), the newest
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. */
311
+ * placement (base row = turn 0 of the current epoch, snapshots override), the
312
+ * newest open/closed state, the newest mass, the exit map, the turn counter
313
+ * (the largest snapshot turn written in the current epoch — derived, never
314
+ * stored), and the epoch itself.
315
+ *
316
+ * Rows rank by the (epoch, turn) pair, so a recast can never be outranked by
317
+ * the run it replaced: a peer's turn-9 snapshot from before the recast loses
318
+ * to a turn-1 snapshot written after it. Base rows carry no stamp of their
319
+ * own and rank as turn 0 of the CURRENT epoch — a recast re-seeds the same
320
+ * deterministic ids, so the shard's own rows are exactly the state the new
321
+ * run starts from. A store with no epoch marker and no stamped snapshot is
322
+ * wholly on epoch 0 and folds as it always has. Pure. */
248
323
  export function foldWorldState(factRows) {
249
- const placements = new Map(); // subject -> { predicate, object, turn }
250
- const positions = new Map(); // subject -> { predicate, object, turn }
251
- const openness = new Map(); // subject -> { open, turn }
252
- const masses = new Map(); // subject -> { value, turn }
324
+ const rows = factRows || [];
325
+ let epoch = 0;
326
+ for (const row of rows) {
327
+ if (row.predicate === WORLD_EPOCH_PREDICATE) {
328
+ const marked = Number(row.object);
329
+ if (Number.isInteger(marked) && marked > epoch) epoch = marked;
330
+ continue;
331
+ }
332
+ const m = SNAPSHOT_RE.exec(row.subject);
333
+ if (m && m[2] && Number(m[2]) > epoch) epoch = Number(m[2]);
334
+ }
335
+ const placements = new Map(); // subject -> { predicate, object, turn, epoch }
336
+ const positions = new Map(); // subject -> { predicate, object, turn, epoch }
337
+ const openness = new Map(); // subject -> { open, turn, epoch }
338
+ const masses = new Map(); // subject -> { value, turn, epoch }
253
339
  const exits = new Map(); // room -> Map(direction -> room)
254
340
  let turnCount = 0;
255
- for (const row of factRows || []) {
341
+ const outranks = (rowEpoch, turn, prior) =>
342
+ !prior || rowEpoch > prior.epoch || (rowEpoch === prior.epoch && turn >= prior.turn);
343
+ for (const row of rows) {
344
+ if (row.predicate === WORLD_EPOCH_PREDICATE) continue;
256
345
  const m = SNAPSHOT_RE.exec(row.subject);
257
346
  const base = m ? m[1] : row.subject;
258
- const turn = m ? Number(m[2]) : 0;
259
- if (m) turnCount = Math.max(turnCount, turn);
347
+ const rowEpoch = m ? (m[2] ? Number(m[2]) : 0) : epoch;
348
+ const turn = m ? Number(m[3]) : 0;
349
+ if (m && rowEpoch === epoch) turnCount = Math.max(turnCount, turn);
260
350
  if (PLACEMENT_PREDICATES.has(row.predicate)) {
261
- const prior = placements.get(base);
262
- if (!prior || turn >= prior.turn) placements.set(base, { predicate: row.predicate, object: row.object, turn });
351
+ if (outranks(rowEpoch, turn, placements.get(base))) placements.set(base, { predicate: row.predicate, object: row.object, turn, epoch: rowEpoch });
263
352
  continue;
264
353
  }
265
354
  if (POSITION_PREDICATES.has(row.predicate)) {
266
- const prior = positions.get(base);
267
- if (!prior || turn >= prior.turn) positions.set(base, { predicate: row.predicate, object: row.object, turn });
355
+ if (outranks(rowEpoch, turn, positions.get(base))) positions.set(base, { predicate: row.predicate, object: row.object, turn, epoch: rowEpoch });
268
356
  continue;
269
357
  }
270
358
  if (row.predicate === OPEN_PREDICATE) {
271
- const prior = openness.get(base);
272
- if (!prior || turn >= prior.turn) openness.set(base, { open: row.object === "true", turn });
359
+ if (outranks(rowEpoch, turn, openness.get(base))) openness.set(base, { open: row.object === "true", turn, epoch: rowEpoch });
273
360
  continue;
274
361
  }
275
362
  if (row.predicate === MASS_PREDICATE) {
276
363
  const value = Number(row.object);
277
364
  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 });
365
+ if (outranks(rowEpoch, turn, masses.get(base))) masses.set(base, { value, turn, epoch: rowEpoch });
280
366
  continue;
281
367
  }
282
368
  const exit = EXIT_PREDICATE_RE.exec(row.predicate);
@@ -285,7 +371,7 @@ export function foldWorldState(factRows) {
285
371
  exits.get(row.subject).set(exit[1], row.object);
286
372
  }
287
373
  }
288
- return { placements, positions, openness, masses, exits, turnCount };
374
+ return { placements, positions, openness, masses, exits, turnCount, epoch };
289
375
  }
290
376
 
291
377
  /** A subject's CURRENT within-room position, or null. A position goes stale
@@ -296,7 +382,7 @@ export function currentPosition(state, subject) {
296
382
  const pos = state.positions.get(subject);
297
383
  if (!pos) return null;
298
384
  const place = state.placements.get(subject);
299
- if (place && pos.turn < place.turn) return null;
385
+ if (place && (pos.epoch < place.epoch || (pos.epoch === place.epoch && pos.turn < place.turn))) return null;
300
386
  return pos;
301
387
  }
302
388
 
@@ -524,7 +610,7 @@ export function runNpcPass({ rows, state, k, families, playerRoomAfter }) {
524
610
  if (!covered) continue;
525
611
  const linked = [...(state.exits.get(from)?.values() ?? [])].includes(target);
526
612
  if (!linked) continue;
527
- writes.push({ subject: `${npc}@turn${k}`, predicate: effectPredicate, object: target });
613
+ writes.push({ subject: snapshotSubject(npc, k, state.epoch), predicate: effectPredicate, object: target });
528
614
  if (playerRoomAfter === target) lines.push(`the ${npc} walks in.`);
529
615
  else if (playerRoomAfter === from) lines.push(`the ${npc} leaves.`);
530
616
  }
@@ -677,8 +763,8 @@ export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn,
677
763
  const left = Math.max(0, Math.round((mass - drainPerTurn) * 100) / 100);
678
764
  const k = state.turnCount + 1;
679
765
  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 }]),
766
+ { subject: snapshotSubject(subject, k, state.epoch), predicate: MASS_PREDICATE, object: String(left) },
767
+ ...(left > 0 ? [] : [{ subject: snapshotSubject(subject, k, state.epoch), predicate: "mgx:currently-in", object: STARVED_PLACE }]),
682
768
  ], cache);
683
769
  return { mass: left, starved: left <= 0 };
684
770
  }
@@ -697,6 +783,9 @@ export async function recordMassDrain(memoryDir, { world, subject, drainPerTurn,
697
783
  // schedule) stay out of the view: hidden means hidden.
698
784
 
699
785
  const VIEW_EXCLUDED_PREDICATES = new Set([
786
+ // The recast counter is bookkeeping, not scenery — "World mgx:world-epoch 2"
787
+ // must never read back as room prose.
788
+ WORLD_EPOCH_PREDICATE,
700
789
  "mgx:hidden-in", "mgx:is-open", "mgx:is-npc", "mgx:is-container",
701
790
  // A bare number reads as an untranslated triple in room prose ("Mole-1
702
791
  // mgx:hasMass 8"). Mass reaches a player through the verbs that change it.
@@ -1193,7 +1282,7 @@ function containerDatatypeState(state, object) {
1193
1282
  export function personKnowledgeLines(rows, state, person) {
1194
1283
  const lines = [];
1195
1284
  for (const objective of factObjects(rows, person, "mgx:knows-objective")) {
1196
- lines.push(`the ${objective} is what you're after — find it and carry it out of the house.`);
1285
+ lines.push(`the ${objective} is what you're after — find it and carry it, and the adventure is won.`);
1197
1286
  }
1198
1287
  for (const thing of factObjects(rows, person, "mgx:knows-where")) {
1199
1288
  const place = state.placements.get(thing);
@@ -1239,6 +1328,410 @@ export function personRoomReport(rows, state, person, actingSubject = "player")
1239
1328
  return parts.join(" ");
1240
1329
  }
1241
1330
 
1331
+ // ---- state-changing verb handlers --------------------------------------------
1332
+ //
1333
+ // One function per state-changing verb (or per shared-precondition group of
1334
+ // verbs), keyed by name in STATE_VERB_HANDLERS below rather than chained as
1335
+ // if/else-if branches: adding a verb means adding a table entry, never
1336
+ // widening an existing conditional. Each handler takes the one `ctx` object
1337
+ // runWorldCommand builds once per call — cmd, the current fold, the taught
1338
+ // action family, and the shared `commit`/`noteFor` closures — so a handler
1339
+ // reads exactly like the branch it replaces, just lifted to its own name.
1340
+
1341
+ async function handleGoVerb(ctx) {
1342
+ const { cmd, rows, state, here, k, family, commit, noteFor, memoryDir, world, cache, actingSubject } = ctx;
1343
+ const target = state.exits.get(here)?.get(cmd.direction);
1344
+ if (!target) {
1345
+ return answer(
1346
+ `there's no exit ${cmd.direction} from the ${here}.`,
1347
+ noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
1348
+ { miss: true },
1349
+ );
1350
+ }
1351
+ // A predator eats whatever walks in, and the room it guards is the one
1352
+ // room a move never comes back from — so this write bypasses commit()
1353
+ // entirely: the auto-relook there would describe a room the mover is no
1354
+ // longer standing in, and the world has nobody left to look with.
1355
+ const predator = predatorIn(rows, state, target);
1356
+ if (predator) {
1357
+ await writeWorldTurn(memoryDir, world, k, [
1358
+ { subject: snapshotSubject(actingSubject, k, state.epoch), predicate: "mgx:currently-in", object: CONSUMED_PLACE },
1359
+ ], cache);
1360
+ return answer(
1361
+ `you go ${cmd.direction} into the ${target} — and the ${predator} is waiting. It eats the ${actingSubject}. That's the end of its run.`,
1362
+ noteFor(`go — the ${target} holds the predator ${predator}; ${actingSubject} is placed out of play at turn ${k} and takes no further turns`),
1363
+ { goal: `move through the world (eaten by the ${predator} in the ${target})` },
1364
+ );
1365
+ }
1366
+ return commit(
1367
+ [{ subject: snapshotSubject(actingSubject, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1368
+ `you go ${cmd.direction}. Now in the ${target}.`,
1369
+ `go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
1370
+ `move through the world (now in the ${target})`,
1371
+ target,
1372
+ );
1373
+ }
1374
+
1375
+ async function handleTakeVerb(ctx) {
1376
+ const { rows, state, here, k, family, object, place, commit, noteFor, actingSubject } = ctx;
1377
+ if (isTyped(rows, object, "room")) {
1378
+ return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
1379
+ }
1380
+ if (carriedBy(state, object, actingSubject)) {
1381
+ return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
1382
+ }
1383
+ if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
1384
+ return answer(
1385
+ `the ${object} is fixed in place — it can't be taken.`,
1386
+ noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
1387
+ { miss: true },
1388
+ );
1389
+ }
1390
+ if (place && place.predicate === "mgx:currently-in" && place.object === here) {
1391
+ return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
1392
+ }
1393
+ if (visibleRoomOf(object, { rows, state }) !== here) {
1394
+ if (backgroundOnlyMention(rows, state, object)) {
1395
+ return answer(
1396
+ `the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
1397
+ noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
1398
+ { miss: true },
1399
+ );
1400
+ }
1401
+ 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 });
1402
+ }
1403
+ return commit(
1404
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1405
+ `you take the ${object}.`,
1406
+ `take — the taught "take" family fired; ${object} is now carried`,
1407
+ `carry the ${object}`,
1408
+ );
1409
+ }
1410
+
1411
+ async function handleDropOrGiveVerb(ctx) {
1412
+ const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
1413
+ if (!carriedBy(state, object, actingSubject)) {
1414
+ return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
1415
+ }
1416
+ if (cmd.verb === "drop") {
1417
+ return commit(
1418
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1419
+ `you drop the ${object} in the ${here}.`,
1420
+ `drop — the taught "drop" family fired; ${object} rests in the ${here}`,
1421
+ `set the ${object} down`,
1422
+ );
1423
+ }
1424
+ const receiver = cmd.indirectObject;
1425
+ if (!isCastMember(rows, state, receiver) || state.placements.get(receiver)?.object !== here) {
1426
+ 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 });
1427
+ }
1428
+ return commit(
1429
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1430
+ `you give the ${object} to the ${receiver}.`,
1431
+ `give — the taught "give" family fired; the ${receiver} holds the ${object}`,
1432
+ `hand the ${object} over`,
1433
+ );
1434
+ }
1435
+
1436
+ async function handleDigVerb(ctx) {
1437
+ const { cmd, rows, state, here, k, commit, noteFor } = ctx;
1438
+ const direction = cmd.direction;
1439
+ if (state.exits.get(here)?.get(direction)) {
1440
+ return answer(
1441
+ `there's already an exit ${direction} from the ${here}.`,
1442
+ noteFor(`dig — an mgx:has-exit-${direction} fact already stands on ${here}; declined, a dig never overwrites an exit`),
1443
+ { miss: true },
1444
+ );
1445
+ }
1446
+ const back = OPPOSITE_DIRECTION.get(direction);
1447
+ if (!back) {
1448
+ return answer(
1449
+ `I don't know which way back a ${direction} tunnel would run.`,
1450
+ noteFor(`dig — "${direction}" has no opposite to write the return exit with; declined by name`),
1451
+ { miss: true },
1452
+ );
1453
+ }
1454
+ const roomKind = roomKindOf(rows, here);
1455
+ const dugKind = (DIGGABLE_BY_ROOM_KIND.get(roomKind) ?? new Map()).get(direction) ?? null;
1456
+ if (!dugKind) {
1457
+ return answer(
1458
+ DIG_DECLINE_BY_ROOM_KIND[roomKind](here, direction),
1459
+ noteFor(`dig — the ${here} is an ${roomKind} room, which cannot be dug ${direction}; declined by the room's own kind`),
1460
+ { miss: true },
1461
+ );
1462
+ }
1463
+ if (atDigBoundary(rows, state, here)) {
1464
+ const reach = roomDistanceFromOrigin(rows, state, here);
1465
+ return answer(
1466
+ `the earth ${direction} of the ${here} is packed hard and endless — you have reached the far edge of the burrow.`,
1467
+ noteFor(reach === null
1468
+ ? `dig — no chain of exits joins the ${here} to the ${originRoomOf(rows)}, so there is no distance to measure a dig against; declined`
1469
+ : `dig — the ${here} stands ${reach} rooms from the ${originRoomOf(rows)}, and this world digs ${digReachOf(rows)}; declined by distance from the origin`),
1470
+ { miss: true },
1471
+ );
1472
+ }
1473
+ const dug = freshRoomId(rows, here, direction);
1474
+ const denChanceIn = declaredCountOr(rows, dugKind, DEN_CHANCE_PREDICATE, DEFAULT_DEN_CHANCE_IN);
1475
+ const isDen = dugKind === "underground-space" && stableIndex(`den:${dug}`, denChanceIn) === 0;
1476
+ const spawnMax = declaredCountOr(rows, dugKind, DIG_SPAWN_MAX_PREDICATE, DEFAULT_DIG_SPAWN_MAX);
1477
+ const spawnCount = DIG_SPAWN_MIN + stableIndex(dug, spawnMax - DIG_SPAWN_MIN + 1);
1478
+ const spawnedKinds = isDen
1479
+ ? declaredKindsOr(rows, dugKind, DEN_SPAWN_PREDICATE, DIG_SPAWN_KINDS)
1480
+ : declaredKindsOr(rows, dugKind, DIG_SPAWN_PREDICATE, DIG_SPAWN_KINDS).slice(0, spawnCount);
1481
+ const minted = new Set();
1482
+ const spawned = spawnedKinds.map((kind) => {
1483
+ const id = freshObjectId(rows, kind, minted);
1484
+ minted.add(id);
1485
+ return id;
1486
+ });
1487
+ const residentChanceIn = declaredCountOr(rows, dugKind, DEN_RESIDENT_CHANCE_PREDICATE, DEFAULT_DEN_RESIDENT_CHANCE_IN);
1488
+ const residentKind = isDen && stableIndex(`resident:${dug}`, residentChanceIn) === 0
1489
+ ? factObjects(rows, dugKind, DEN_RESIDENT_PREDICATE)[0] ?? null
1490
+ : null;
1491
+ const resident = residentKind ? freshObjectId(rows, residentKind, minted) : null;
1492
+ return commit(
1493
+ [
1494
+ { subject: dug, predicate: "rdf:type", object: "room" },
1495
+ { subject: dug, predicate: "rdf:type", object: dugKind },
1496
+ ...(isDen ? [{ subject: dug, predicate: "rdf:type", object: DEN_ROOM_CLASS }] : []),
1497
+ { subject: here, predicate: `mgx:has-exit-${direction}`, object: dug },
1498
+ { subject: dug, predicate: `mgx:has-exit-${back}`, object: here },
1499
+ // Typed to its OWN kind, not a flat "portable" — a spawned kind the
1500
+ // world declares rdfs:subClassOf food needs its real class reachable
1501
+ // here for isFood's own objectClassChain walk, or digging up "carrot-1"
1502
+ // would still read as inedible scenery. The class's own mass copies
1503
+ // onto the instance for the same reason: eat reads the instance.
1504
+ ...spawnedKinds.flatMap((kind, i) => ([
1505
+ { subject: spawned[i], predicate: "rdf:type", object: kind },
1506
+ { subject: spawned[i], predicate: DISPLAY_NAME_PREDICATE, object: kind },
1507
+ { subject: spawned[i], predicate: "mgx:located-in", object: dug },
1508
+ ...classMassFacts(rows, spawned[i], kind),
1509
+ ])),
1510
+ // A resident is placed with currently-in, the predicate that makes an
1511
+ // individual one of the cast, and knows about what its own den holds —
1512
+ // so an animal that digs one out has somebody new to ask about food.
1513
+ ...(resident ? [
1514
+ { subject: resident, predicate: "rdf:type", object: residentKind },
1515
+ { subject: resident, predicate: DISPLAY_NAME_PREDICATE, object: residentKind },
1516
+ { subject: resident, predicate: "mgx:currently-in", object: dug },
1517
+ ...classMassFacts(rows, resident, residentKind),
1518
+ ...spawned.map((thing) => ({ subject: resident, predicate: KNOWS_ABOUT_PREDICATE, object: thing })),
1519
+ ] : []),
1520
+ ],
1521
+ digNarration(direction, { isDen, spawned, resident }),
1522
+ `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}`,
1523
+ `dig ${direction} out of the ${here}`,
1524
+ );
1525
+ }
1526
+
1527
+ async function handleEatVerb(ctx) {
1528
+ const { rows, state, here, k, object, commit, noteFor, memoryDir, cache, actingSubject } = ctx;
1529
+ const present = visibleRoomOf(object, { rows, state }) === here || carriedBy(state, object, actingSubject);
1530
+ if (!present) {
1531
+ return answer(
1532
+ `I don't see a ${object} here.`,
1533
+ noteFor(`eat — ${object} is neither visible in the ${here} nor carried; declined`),
1534
+ { miss: true },
1535
+ );
1536
+ }
1537
+ if (!objectClassChain(rows, object).includes(FOOD_CLASS)) {
1538
+ return answer(
1539
+ `the ${object} isn't food.`,
1540
+ noteFor(`eat — ${object}'s rdf:type/rdfs:subClassOf chain never reaches "${FOOD_CLASS}"; declined by name`),
1541
+ { miss: true },
1542
+ );
1543
+ }
1544
+ const eaterMass = state.masses.get(actingSubject)?.value ?? null;
1545
+ if (eaterMass !== null && eaterMass >= ASSUMED_FULL_MASS * HUNGRY_FRACTION) {
1546
+ return answer(
1547
+ `you're too full to eat the ${object}.`,
1548
+ noteFor(`eat — ${actingSubject} weighs ${eaterMass}, at or over half of ${ASSUMED_FULL_MASS}; declined by name`),
1549
+ { miss: true },
1550
+ );
1551
+ }
1552
+ const gained = state.masses.get(object)?.value ?? DEFAULT_FOOD_MASS;
1553
+ const grown = Math.round(((eaterMass ?? 0) + gained) * 100) / 100;
1554
+ // Eating is the one act that ends a thing, so the eater is the one witness
1555
+ // whose knowledge of it goes out of date on the spot. Every route into the
1556
+ // eat verb — a typed command, a scripted mud turn — passes here, so the
1557
+ // claim gets written once for all of them.
1558
+ await recordGone(memoryDir, { observer: actingSubject, thing: object, k, cache });
1559
+ return commit(
1560
+ [
1561
+ { subject: snapshotSubject(actingSubject, k, state.epoch), predicate: MASS_PREDICATE, object: String(grown) },
1562
+ { subject: snapshotSubject(object, k, state.epoch), predicate: "mgx:located-in", object: CONSUMED_PLACE },
1563
+ ],
1564
+ `you eat the ${object}. It adds ${gained} to your mass, so you weigh ${grown} now.`,
1565
+ `eat — the ${object}'s ${gained} mass moves onto ${actingSubject} (now ${grown}) and the ${object} leaves the world`,
1566
+ `eat the ${object}`,
1567
+ );
1568
+ }
1569
+
1570
+ async function handlePutVerb(ctx) {
1571
+ const { cmd, rows, state, here, k, family, object, commit, noteFor, actingSubject } = ctx;
1572
+ const container = cmd.indirectObject;
1573
+ if (!carriedBy(state, object, actingSubject)) {
1574
+ return answer(
1575
+ `you're not carrying the ${object}.`,
1576
+ noteFor(`put — ${object} isn't carried; precondition declined by name`),
1577
+ { miss: true },
1578
+ );
1579
+ }
1580
+ if (visibleRoomOf(container, { rows, state }) !== here) {
1581
+ return answer(
1582
+ `I don't see a ${container} here.`,
1583
+ noteFor(`put — ${container} isn't visible in the ${here}; declined`),
1584
+ { miss: true },
1585
+ );
1586
+ }
1587
+ if (!isContainer(rows, container)) {
1588
+ return answer(
1589
+ `the ${container} doesn't hold things.`,
1590
+ noteFor(`put — no mgx:is-container fact on ${container}; declined by name`),
1591
+ { miss: true },
1592
+ );
1593
+ }
1594
+ if (!state.openness.get(container)?.open) {
1595
+ return answer(
1596
+ `the ${container} is closed.`,
1597
+ noteFor(`put — the ${container} isn't open; precondition declined by name`),
1598
+ { miss: true },
1599
+ );
1600
+ }
1601
+ return commit(
1602
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: container }],
1603
+ `you put the ${object} in the ${container}.`,
1604
+ `put — the taught "put" family fired; the ${object} now sits in the ${container}`,
1605
+ `put the ${object} in the ${container}`,
1606
+ );
1607
+ }
1608
+
1609
+ // open / unlock / close — the container verbs, sharing one handler (all
1610
+ // three keys in STATE_VERB_HANDLERS below point here) because they share a
1611
+ // precondition: presence and container-ness stay hand-checked (visibility
1612
+ // gating, not a state precondition), the same visibleRoomOf check
1613
+ // examine/talk/take already use, not a hand-rolled duplicate. unlock's
1614
+ // instrument match stays fully hand-written below it too — it needs a
1615
+ // third, externally-supplied binding beyond subject/target, which this
1616
+ // retrofit does not attempt. open/close's lock-state and open/closed
1617
+ // checks, and their mgx:is-open write, are taught "fact-value" precond/
1618
+ // effect rows consulted through domain.mjs; only the hidden-contents
1619
+ // reveal (a variable-arity effect over a discovered set) stays hand-written
1620
+ // JS, since no shipped rule shape covers that either.
1621
+ async function handleContainerVerb(ctx) {
1622
+ const { cmd, rows, state, here, k, object, place, commit, noteFor, ruleRows } = ctx;
1623
+ if (visibleRoomOf(object, { rows, state }) !== here) {
1624
+ return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
1625
+ }
1626
+ if (!isContainer(rows, object)) {
1627
+ return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
1628
+ }
1629
+
1630
+ if (cmd.verb === "open" || cmd.verb === "close") {
1631
+ const domain = compileDomain(rows, ruleRows);
1632
+ const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
1633
+ const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
1634
+ if (!effect) {
1635
+ return answer(
1636
+ `this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
1637
+ noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
1638
+ { miss: true },
1639
+ );
1640
+ }
1641
+ const factState = containerDatatypeState(state, object);
1642
+ const failed = taughtAction.preconds.find((p) => !precondHolds(p, ctx.actingSubject, object, factState, domain));
1643
+ if (failed) {
1644
+ const text = failed.predicate === "mgx:stands-locked-in"
1645
+ ? `the ${object} is locked.`
1646
+ : cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
1647
+ return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
1648
+ }
1649
+ const effSubject = roleBinding(effect.subjectRole, ctx.actingSubject, object, domain);
1650
+ const writeIsOpen = { subject: snapshotSubject(effSubject, k, state.epoch), predicate: effect.predicate, object: effect.value };
1651
+
1652
+ if (cmd.verb === "open") {
1653
+ const revealed = [...state.placements]
1654
+ .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
1655
+ .map(([thing]) => thing)
1656
+ .sort();
1657
+ return commit(
1658
+ [
1659
+ writeIsOpen,
1660
+ ...revealed.map((thing) => ({ subject: snapshotSubject(thing, k, state.epoch), predicate: "mgx:located-in", object })),
1661
+ ],
1662
+ revealed.length
1663
+ ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
1664
+ : `you open the ${object}. It's empty.`,
1665
+ `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
1666
+ `open the ${object}`,
1667
+ );
1668
+ }
1669
+ return commit(
1670
+ [writeIsOpen],
1671
+ `you close the ${object}.`,
1672
+ `close — ${object} closes via the taught "close" family's effect`,
1673
+ `close the ${object}`,
1674
+ );
1675
+ }
1676
+
1677
+ // unlock
1678
+ if (place.predicate !== "mgx:stands-locked-in") {
1679
+ return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
1680
+ }
1681
+ if (!cmd.instrument) {
1682
+ return answer(
1683
+ `unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
1684
+ noteFor("unlock — no instrument named; asked, never guessed"),
1685
+ { miss: true },
1686
+ );
1687
+ }
1688
+ const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
1689
+ if (!required) {
1690
+ return answer(
1691
+ `nothing in this world says what unlocks the ${object}.`,
1692
+ noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
1693
+ { miss: true },
1694
+ );
1695
+ }
1696
+ if (!carriedBy(state, cmd.instrument, ctx.actingSubject)) {
1697
+ return answer(
1698
+ `you're not carrying the ${cmd.instrument}.`,
1699
+ noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
1700
+ { miss: true },
1701
+ );
1702
+ }
1703
+ if (cmd.instrument !== required) {
1704
+ return answer(
1705
+ `the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
1706
+ noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
1707
+ { miss: true },
1708
+ );
1709
+ }
1710
+ return commit(
1711
+ [{ subject: snapshotSubject(object, k, state.epoch), predicate: "mgx:fixed-in", object: here }],
1712
+ `you unlock the ${object} with the ${required}.`,
1713
+ `unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
1714
+ `unlock the ${object}`,
1715
+ );
1716
+ }
1717
+
1718
+ // The data-driven verb table itself: every state-changing verb this engine
1719
+ // knows, mapped to the handler that runs it. A world's own taught action
1720
+ // family (checked before this table is ever consulted) decides whether the
1721
+ // verb exists in THIS world; this table decides which JS runs once it does.
1722
+ const STATE_VERB_HANDLERS = {
1723
+ go: handleGoVerb,
1724
+ take: handleTakeVerb,
1725
+ drop: handleDropOrGiveVerb,
1726
+ give: handleDropOrGiveVerb,
1727
+ dig: handleDigVerb,
1728
+ eat: handleEatVerb,
1729
+ put: handlePutVerb,
1730
+ open: handleContainerVerb,
1731
+ close: handleContainerVerb,
1732
+ unlock: handleContainerVerb,
1733
+ };
1734
+
1242
1735
  export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject = "player" }) {
1243
1736
  const memory = await loadMemory(memoryDir);
1244
1737
  const rows = readFactRows(memory);
@@ -1410,376 +1903,18 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
1410
1903
  const object = cmd.object;
1411
1904
  const place = object ? state.placements.get(object) ?? null : null;
1412
1905
 
1413
- if (cmd.verb === "go") {
1414
- const target = state.exits.get(here)?.get(cmd.direction);
1415
- if (!target) {
1416
- return answer(
1417
- `there's no exit ${cmd.direction} from the ${here}.`,
1418
- noteFor(`go — no mgx:has-exit-${cmd.direction} fact on ${here}; precondition declined by name`),
1419
- { miss: true },
1420
- );
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
- }
1437
- return commit(
1438
- [{ subject: `${actingSubject}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:currently-in", object: target }],
1439
- `you go ${cmd.direction}. Now in the ${target}.`,
1440
- `go — the taught "go" family fired; ${actingSubject} moves ${here} -> ${target}`,
1441
- `move through the world (now in the ${target})`,
1442
- target,
1443
- );
1444
- }
1445
-
1446
- if (cmd.verb === "take") {
1447
- if (isTyped(rows, object, "room")) {
1448
- return answer(`you can't take the ${object} — it's a whole room.`, noteFor("take — the object is a room; declined"), { miss: true });
1449
- }
1450
- if (carriedBy(state, object, actingSubject)) {
1451
- return answer(`you're already carrying the ${object}.`, noteFor("take — already carried; declined"), { miss: true });
1452
- }
1453
- if (place && (place.predicate === "mgx:fixed-in" || place.predicate === "mgx:stands-locked-in") && place.object === here) {
1454
- return answer(
1455
- `the ${object} is fixed in place — it can't be taken.`,
1456
- noteFor(`take — ${object} is placed by ${place.predicate}, not portable; precondition declined by name`),
1457
- { miss: true },
1458
- );
1459
- }
1460
- if (place && place.predicate === "mgx:currently-in" && place.object === here) {
1461
- return answer(`you can't take the ${object}.`, noteFor("take — the object is one of the cast; declined"), { miss: true });
1462
- }
1463
- if (visibleRoomOf(object, { rows, state }) !== here) {
1464
- if (backgroundOnlyMention(rows, state, object)) {
1465
- return answer(
1466
- `the ${object} isn't something you can take — it's only mentioned in passing here, not a real prop in this scene.`,
1467
- noteFor(`take — ${object} is a background-only mention, never a placed object; declined honestly`),
1468
- { miss: true },
1469
- );
1470
- }
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 });
1472
- }
1473
- return commit(
1474
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: actingSubject }],
1475
- `you take the ${object}.`,
1476
- `take — the taught "take" family fired; ${object} is now carried`,
1477
- `carry the ${object}`,
1478
- );
1479
- }
1480
-
1481
- if (cmd.verb === "drop" || cmd.verb === "give") {
1482
- if (!carriedBy(state, object, actingSubject)) {
1483
- return answer(`you're not carrying the ${object}.`, noteFor(`${cmd.verb} — ${object} isn't carried; precondition declined by name`), { miss: true });
1484
- }
1485
- if (cmd.verb === "drop") {
1486
- return commit(
1487
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: here }],
1488
- `you drop the ${object} in the ${here}.`,
1489
- `drop — the taught "drop" family fired; ${object} rests in the ${here}`,
1490
- `set the ${object} down`,
1491
- );
1492
- }
1493
- const receiver = cmd.indirectObject;
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 });
1496
- }
1497
- return commit(
1498
- [{ subject: `${object}@turn${k}`, predicate: familyEffectPredicate(family) ?? "mgx:located-in", object: receiver }],
1499
- `you give the ${object} to the ${receiver}.`,
1500
- `give — the taught "give" family fired; the ${receiver} holds the ${object}`,
1501
- `hand the ${object} over`,
1502
- );
1503
- }
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
-
1675
- // open / unlock / close — the container verbs. presence and container-ness
1676
- // stay hand-checked here (visibility gating, not a state precondition);
1677
- // unlock's instrument match stays fully hand-written below it too — it
1678
- // needs a third, externally-supplied binding beyond subject/target, which
1679
- // this retrofit does not attempt. open/close's lock-state and open/closed
1680
- // checks, and their mgx:is-open write, are now taught "fact-value"
1681
- // precond/effect rows consulted through domain.mjs below; only the
1682
- // hidden-contents reveal (a variable-arity effect over a discovered set)
1683
- // stays hand-written JS, since no shipped rule shape covers that either.
1684
- // Presence reuses visibleRoomOf (the SAME check examine/talk/take already
1685
- // use), not a hand-rolled duplicate: an earlier version of this check
1686
- // excluded mgx:currently-in outright, so a genuinely-present NPC (placed
1687
- // that way, not fixed-in/stands-locked-in) fell into "I don't see a X
1688
- // here" instead of reaching the isContainer check just below, which would
1689
- // have honestly said "the X doesn't open."
1690
- if (visibleRoomOf(object, { rows, state }) !== here) {
1691
- return answer(`I don't see a ${object} here.`, noteFor(`${cmd.verb} — ${object} isn't in the ${here}; declined`), { miss: true });
1692
- }
1693
- if (!isContainer(rows, object)) {
1694
- return answer(`the ${object} doesn't open.`, noteFor(`${cmd.verb} — no mgx:is-container fact on ${object}; declined by name`), { miss: true });
1695
- }
1696
-
1697
- if (cmd.verb === "open" || cmd.verb === "close") {
1698
- const domain = compileDomain(rows, ruleRows);
1699
- const taughtAction = domain.actions.find((a) => a.name === cmd.verb);
1700
- const effect = taughtAction?.effects.find((e) => e.predicate === OPEN_PREDICATE);
1701
- if (!effect) {
1702
- return answer(
1703
- `this world doesn't teach how ${cmd.verb === "open" ? "opening" : "closing"} changes the ${object}.`,
1704
- noteFor(`${cmd.verb} — the taught "${cmd.verb}" family carries no ${OPEN_PREDICATE} effect; honest decline`),
1705
- { miss: true },
1706
- );
1707
- }
1708
- const factState = containerDatatypeState(state, object);
1709
- const failed = taughtAction.preconds.find((p) => !precondHolds(p, actingSubject, object, factState, domain));
1710
- if (failed) {
1711
- const text = failed.predicate === "mgx:stands-locked-in"
1712
- ? `the ${object} is locked.`
1713
- : cmd.verb === "open" ? `the ${object} is already open.` : `the ${object} isn't open.`;
1714
- return answer(text, noteFor(`${cmd.verb} — the taught "${cmd.verb}" family's ${failed.predicate} precondition declined by name`), { miss: true });
1715
- }
1716
- const effSubject = roleBinding(effect.subjectRole, actingSubject, object, domain);
1717
- const writeIsOpen = { subject: `${effSubject}@turn${k}`, predicate: effect.predicate, object: effect.value };
1718
-
1719
- if (cmd.verb === "open") {
1720
- const revealed = [...state.placements]
1721
- .filter(([, p]) => p.predicate === "mgx:hidden-in" && p.object === object)
1722
- .map(([thing]) => thing)
1723
- .sort();
1724
- return commit(
1725
- [
1726
- writeIsOpen,
1727
- ...revealed.map((thing) => ({ subject: `${thing}@turn${k}`, predicate: "mgx:located-in", object })),
1728
- ],
1729
- revealed.length
1730
- ? `you open the ${object} — inside: the ${revealed.join(", the ")}.`
1731
- : `you open the ${object}. It's empty.`,
1732
- `open — ${object} opens${revealed.length ? `, revealing ${revealed.join(", ")}` : ""} via the taught "open" family's effect`,
1733
- `open the ${object}`,
1734
- );
1735
- }
1736
- return commit(
1737
- [writeIsOpen],
1738
- `you close the ${object}.`,
1739
- `close — ${object} closes via the taught "close" family's effect`,
1740
- `close the ${object}`,
1741
- );
1742
- }
1743
-
1744
- // unlock
1745
- if (place.predicate !== "mgx:stands-locked-in") {
1746
- return answer(`the ${object} isn't locked.`, noteFor("unlock — not locked; declined"), { miss: true });
1747
- }
1748
- if (!cmd.instrument) {
1749
- return answer(
1750
- `unlock the ${object} with what? Name the thing to use, e.g. "unlock the ${object} with the key".`,
1751
- noteFor("unlock — no instrument named; asked, never guessed"),
1752
- { miss: true },
1753
- );
1754
- }
1755
- const required = factObjects(rows, object, "mgx:unlocks-with")[0] ?? null;
1756
- if (!required) {
1757
- return answer(
1758
- `nothing in this world says what unlocks the ${object}.`,
1759
- noteFor(`unlock — no mgx:unlocks-with fact on ${object}; honest decline`),
1760
- { miss: true },
1761
- );
1762
- }
1763
- if (!carriedBy(state, cmd.instrument, actingSubject)) {
1906
+ const handler = STATE_VERB_HANDLERS[cmd.verb];
1907
+ if (!handler) {
1764
1908
  return answer(
1765
- `you're not carrying the ${cmd.instrument}.`,
1766
- noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
1909
+ `this world doesn't know how to run the verb "${cmd.verb}" yet.`,
1910
+ noteFor(`${cmd.verb} a taught family exists but no handler is wired for it; honest decline`),
1767
1911
  { miss: true },
1768
1912
  );
1769
1913
  }
1770
- if (cmd.instrument !== required) {
1771
- return answer(
1772
- `the ${cmd.instrument} doesn't fit the ${object}'s lock.`,
1773
- noteFor(`unlock — the taught instrument is ${required}, not ${cmd.instrument}; declined by name`),
1774
- { miss: true },
1775
- );
1776
- }
1777
- return commit(
1778
- [{ subject: `${object}@turn${k}`, predicate: "mgx:fixed-in", object: here }],
1779
- `you unlock the ${object} with the ${required}.`,
1780
- `unlock — the lock releases; ${object} now stands unlocked (still fixed) in the ${here}`,
1781
- `unlock the ${object}`,
1782
- );
1914
+ return handler({
1915
+ cmd, rows, state, here, k, family, families, ruleRows, object, place,
1916
+ commit, noteFor, actingSubject, memoryDir, world, cache, graph, memory,
1917
+ });
1783
1918
  }
1784
1919
 
1785
1920
  // A mid-game "where is X" aside. Optional trailing "now": the question means