@polycode-projects/the-mechanical-code-talker 4.0.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 (68) 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 +34 -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/domain/grammar/ace.mjs +40 -4
  46. package/src/domain/grammar/lexicon-core.json +2 -1
  47. package/src/domain/grammar/lexicon.mjs +18 -0
  48. package/src/domain/reference-pack.mjs +31 -7
  49. package/src/domain/spider-fly-world.mjs +1 -1
  50. package/src/domain/sprite-templates.mjs +8 -6
  51. package/src/services/adventure-viz.mjs +130 -47
  52. package/src/services/adventure.mjs +440 -367
  53. package/src/services/chat-page-viz.mjs +37 -9
  54. package/src/services/chat.mjs +99 -18
  55. package/src/services/code-explorer-viz.mjs +52 -14
  56. package/src/services/extract-facts.mjs +4 -7
  57. package/src/services/ingest-viz.mjs +54 -26
  58. package/src/services/ledger-viz.mjs +105 -56
  59. package/src/services/memory-panel-viz.mjs +24 -0
  60. package/src/services/mud-viz.mjs +940 -21
  61. package/src/services/plan-viz.mjs +119 -61
  62. package/src/services/research-viz.mjs +151 -57
  63. package/src/services/spider-fly-turn.mjs +1 -1
  64. package/src/services/spider-fly-viz.mjs +67 -43
  65. package/src/services/sprite-catalog-viz.mjs +175 -51
  66. package/src/services/viz-theme.mjs +15 -0
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +107 -107
  68. package/src/surfaces/web/mud-browser-entry.mjs +44 -2
@@ -227,6 +227,33 @@ export function worldActionRows(rows) {
227
227
  });
228
228
  }
229
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
+
230
257
  /** Every individual the world names — its rooms, its cast, its props, and
231
258
  * anything dug up since — as the plain id strings a parser has to have
232
259
  * DECLARED before it can resolve them. @turnN snapshots are skipped: a
@@ -1193,7 +1220,7 @@ function containerDatatypeState(state, object) {
1193
1220
  export function personKnowledgeLines(rows, state, person) {
1194
1221
  const lines = [];
1195
1222
  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.`);
1223
+ lines.push(`the ${objective} is what you're after — find it and carry it, and the adventure is won.`);
1197
1224
  }
1198
1225
  for (const thing of factObjects(rows, person, "mgx:knows-where")) {
1199
1226
  const place = state.placements.get(thing);
@@ -1239,6 +1266,410 @@ export function personRoomReport(rows, state, person, actingSubject = "player")
1239
1266
  return parts.join(" ");
1240
1267
  }
1241
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
+
1242
1673
  export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache, actingSubject = "player" }) {
1243
1674
  const memory = await loadMemory(memoryDir);
1244
1675
  const rows = readFactRows(memory);
@@ -1410,376 +1841,18 @@ export async function runWorldCommand(cmd, { world, memoryDir, env, graph, cache
1410
1841
  const object = cmd.object;
1411
1842
  const place = object ? state.placements.get(object) ?? null : null;
1412
1843
 
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)) {
1844
+ const handler = STATE_VERB_HANDLERS[cmd.verb];
1845
+ if (!handler) {
1764
1846
  return answer(
1765
- `you're not carrying the ${cmd.instrument}.`,
1766
- noteFor(`unlock — the ${cmd.instrument} isn't carried; precondition declined by name`),
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`),
1767
1849
  { miss: true },
1768
1850
  );
1769
1851
  }
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
- );
1852
+ return handler({
1853
+ cmd, rows, state, here, k, family, families, ruleRows, object, place,
1854
+ commit, noteFor, actingSubject, memoryDir, world, cache, graph, memory,
1855
+ });
1783
1856
  }
1784
1857
 
1785
1858
  // A mid-game "where is X" aside. Optional trailing "now": the question means