@polycode-projects/the-mechanical-code-talker 4.1.0 → 4.1.2

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 (60) hide show
  1. package/README.md +31 -18
  2. package/bin/tmct.mjs +3 -0
  3. package/data/templates/responses.jsonl +3 -0
  4. package/package.json +2 -1
  5. package/src/adapters/memory/core.mjs +1358 -196
  6. package/src/adapters/memory/inspect.mjs +11 -0
  7. package/src/adapters/memory/shacl.mjs +38 -0
  8. package/src/adapters/p2p/webrtc-transport.mjs +28 -5
  9. package/src/domain/ask-vocab.mjs +39 -0
  10. package/src/domain/ask.mjs +183 -34
  11. package/src/domain/grammar/assert.mjs +8 -2
  12. package/src/domain/hanoi-board.mjs +232 -0
  13. package/src/domain/ingest-facts.mjs +120 -0
  14. package/src/domain/interpret/normalize.mjs +49 -0
  15. package/src/domain/memory/compaction.mjs +284 -0
  16. package/src/domain/memory/resolution.mjs +171 -0
  17. package/src/domain/memory/trust.mjs +175 -5
  18. package/src/domain/memory-facts.mjs +139 -0
  19. package/src/domain/p2p/facts.mjs +21 -0
  20. package/src/domain/p2p/peer-id.mjs +15 -0
  21. package/src/domain/p2p/provenance-relabel.mjs +13 -2
  22. package/src/domain/p2p/sync-filter.mjs +5 -1
  23. package/src/domain/p2p/wire.mjs +7 -4
  24. package/src/domain/scene-compose.mjs +2 -2
  25. package/src/domain/sprite-facts.mjs +0 -0
  26. package/src/domain/sprite-request.mjs +1 -1
  27. package/src/services/adventure-viz.mjs +43 -39
  28. package/src/services/adventure.mjs +70 -44
  29. package/src/services/chat-page-viz.mjs +403 -332
  30. package/src/services/chat.mjs +274 -156
  31. package/src/services/code-explorer-viz.mjs +142 -55
  32. package/src/services/index.mjs +1 -1
  33. package/src/services/ingest-viz.mjs +153 -28
  34. package/src/services/ledger-viz.mjs +47 -47
  35. package/src/services/memory-panel-viz.mjs +8 -3
  36. package/src/services/mud-turn.mjs +11 -8
  37. package/src/services/mud-viz.mjs +474 -234
  38. package/src/services/p2p-room.mjs +110 -23
  39. package/src/services/plan-viz.mjs +72 -13
  40. package/src/services/research-viz.mjs +35 -24
  41. package/src/services/share-overlay-viz.mjs +623 -0
  42. package/src/services/spider-fly-viz.mjs +24 -24
  43. package/src/services/sprite-catalog-viz.mjs +307 -82
  44. package/src/surfaces/web/adventure-browser-entry.mjs +46 -25
  45. package/src/surfaces/web/chat-browser-entry.mjs +55 -9
  46. package/src/surfaces/web/code-explorer-browser-entry.mjs +30 -16
  47. package/src/surfaces/web/engine-surface.mjs +82 -0
  48. package/src/surfaces/web/ingest-browser-entry.mjs +81 -11
  49. package/src/surfaces/web/ledger-browser-entry.mjs +47 -14
  50. package/src/surfaces/web/memory-ask-browser-entry.mjs +55 -13
  51. package/src/surfaces/web/memory-ask-browser.bundle.js +149 -116
  52. package/src/surfaces/web/mud-browser-entry.mjs +77 -25
  53. package/src/surfaces/web/p2p-browser-entry.mjs +1 -1
  54. package/src/surfaces/web/plan-browser-entry.mjs +49 -11
  55. package/src/surfaces/web/research-browser-entry.mjs +33 -24
  56. package/src/surfaces/web/spider-fly-browser-entry.mjs +32 -13
  57. package/src/surfaces/web/sprites-browser-entry.mjs +51 -11
  58. package/src/surfaces/web/tmct-surface.mjs +159 -0
  59. package/src/surfaces/web/turn-session.mjs +16 -5
  60. package/src/tools/server.mjs +13 -6
@@ -42,17 +42,17 @@
42
42
  //
43
43
  // Four further pure helpers are exported for testing but NOT spliced,
44
44
  // because each calls another module's export — the in-page script instead
45
- // reaches through the browser bundle's own `tmctAdventure` global (mirroring
46
- // how the inline script calls `tmctSpiderFly.*` rather than re-importing
45
+ // reaches through the browser bundle's own `tmct` surface (mirroring
46
+ // how the spider-fly page's inline script calls it rather than re-importing
47
47
  // spider-fly-world.mjs): `roomCaptionText` (calls `worldDigestRows`; the
48
- // in-page `captionFor` mirrors it against `tmctAdventure.worldDigestRows`),
48
+ // in-page `captionFor` mirrors it against `tmct.page.worldDigestRows`),
49
49
  // `pillsForRoom` (a thin wrapper over adventure.mjs's own exported
50
50
  // `roomAffordances`, whose header explains why its list can never promise an
51
51
  // action one of take/open/talk/examine would then refuse; the in-page
52
- // `pillsFor` mirrors it against `tmctAdventure.roomAffordances`),
52
+ // `pillsFor` mirrors it against `tmct.page.roomAffordances`),
53
53
  // `goalStatusLines` (calls `foldWorldState` and adventure-autoplay.mjs's own
54
54
  // `exposedFacts`; the in-page `goalStatusLinesFor` mirrors both against the
55
- // `tmctAdventure` global too), and `visitedRoomGraph` (a directions-only
55
+ // `tmct` surface too), and `visitedRoomGraph` (a directions-only
56
56
  // layout of the rooms a session has actually visited, now a thin wrapper over
57
57
  // viz-room-graph.mjs's shared `directedGridLayout` — mud-viz.mjs's own
58
58
  // burrowGraph wrote the same BFS-grid layout a second time under its own
@@ -60,7 +60,7 @@
60
60
  // header for the algorithm and what `hints`/disconnected components mean).
61
61
  // The in-page script never splices `directedGridLayout`/`roomGraphSvg`
62
62
  // either, for the same not-self-contained reason: it calls
63
- // `tmctAdventure.directedGridLayout`/`tmctAdventure.roomGraphSvg` straight
63
+ // `tmct.page.directedGridLayout`/`tmct.page.roomGraphSvg` straight
64
64
  // through the bundle instead of re-implementing the room map a second time.
65
65
  //
66
66
  // The chat dock (chatlog/chatform/chatq/pills, below) mirrors
@@ -479,8 +479,8 @@ export function groundedPlaceholder(actions, fallback) {
479
479
  * row. `[]` on an honest miss (neither lookup returns anything) — never a
480
480
  * fabricated suggestion. NOT .toString()-splice-safe (it calls two other
481
481
  * modules' exports) — the in-page script mirrors this same combination
482
- * against the browser bundle's own `tmctAdventure.relatedForTerm`/
483
- * `tmctAdventure.classAncestorChain`, the same reach-through-the-global
482
+ * against the browser bundle's own `tmct.page.relatedForTerm`/
483
+ * `tmct.page.classAncestorChain`, the same reach-through-the-global
484
484
  * pattern `pillsFor`/`captionFor` already use for their own adventure.mjs
485
485
  * calls (see this module's header). */
486
486
  export function suggestionsForTerm(rows, term) {
@@ -1199,7 +1199,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1199
1199
  let allStoreRows = [];
1200
1200
 
1201
1201
  // ---- persistence — the manor remembers your visit, on this device -------
1202
- // Best-effort IndexedDB (tmctAdventure.openPersistedStore): after any
1202
+ // Best-effort IndexedDB (tmct.page.openPersistedStore): after any
1203
1203
  // state-changing redraw, the whole Backend-B payload plus the visited-room
1204
1204
  // exposure set snapshots under the "adventure" key, debounced. Restored on
1205
1205
  // boot; reset clears it and starts the world over. The home page's preview
@@ -1208,6 +1208,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1208
1208
  let persist = null;
1209
1209
  let persistSaveTimer = null;
1210
1210
  window.tmctAdventureLastSave = null;
1211
+ window.tmct.lastSave = null;
1211
1212
 
1212
1213
  // The deploy's own version, read off the service worker file the build
1213
1214
  // already stamps (its cache name embeds package.json's version) — the only
@@ -1238,7 +1239,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1238
1239
  }
1239
1240
  const visitedRoomIds = lastSnapshot ? lastSnapshot.visitedRoomIds : [];
1240
1241
  persist.save({ memoryPayload: memoryPayload, visitedRoomIds: visitedRoomIds }).then((saved) => {
1241
- if (saved) window.tmctAdventureLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1242
+ if (saved) {
1243
+ window.tmctAdventureLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
1244
+ window.tmct.lastSave = window.tmctAdventureLastSave;
1245
+ }
1242
1246
  });
1243
1247
  }, 500);
1244
1248
  }
@@ -1297,22 +1301,22 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1297
1301
 
1298
1302
  function captionFor(rows, state, here) {
1299
1303
  const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
1300
- const lines = tmctAdventure.worldDigestRows(rows, state)
1304
+ const lines = tmct.page.worldDigestRows(rows, state)
1301
1305
  .filter((row) => row.subject !== "Player" && (row.object === here || row.subject === hereCased))
1302
1306
  .map((row) => row.subject + " " + row.predicate + " " + row.object + ".");
1303
1307
  return lines.length ? lines.join(" ") : "Nothing more about the " + here + " is written down yet.";
1304
1308
  }
1305
1309
 
1306
1310
  // ---- goal status — mirrors adventure-viz.mjs's own goalStatusLines
1307
- // against tmctAdventure.foldWorldState/tmctAdventure.exposedFacts (the
1311
+ // against tmct.page.foldWorldState/tmct.page.exposedFacts (the
1308
1312
  // same mirroring captionFor/pillsFor already do against their own
1309
1313
  // adventure.mjs exports), so a location is never claimed before the room
1310
1314
  // holding it has actually been visited this session.
1311
1315
  function goalStatusLinesFor(rows, state, visitedRoomIds) {
1312
1316
  const ids = Array.from(new Set(rows.filter((r) => r.predicate === "mgx:is-objective" && r.object === "true").map((r) => r.subject)));
1313
1317
  const carriedIds = new Set(carriedItems(rows, state, ACTING_SUBJECT).map((o) => o.subject));
1314
- const exposedRows = tmctAdventure.exposedFacts(rows, visitedRoomIds);
1315
- const exposedState = tmctAdventure.foldWorldState(exposedRows);
1318
+ const exposedRows = tmct.page.exposedFacts(rows, visitedRoomIds);
1319
+ const exposedState = tmct.page.foldWorldState(exposedRows);
1316
1320
  return ids.map((id) => {
1317
1321
  if (carriedIds.has(id)) return { subject: id, status: "carried", text: "carrying the " + id + " \\u2014 the adventure is won." };
1318
1322
  const room = visibleRoomOf(exposedRows, exposedState, id, ACTING_SUBJECT);
@@ -1340,21 +1344,21 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1340
1344
  // cursor per node; play mode's own map stays purely informational.
1341
1345
  // roomGraphSvgFor mirrors the OLD inline roomMapSvg's own board-game sizing
1342
1346
  // (a 64px square cell, 56x26px room footprints) through the shared
1343
- // viz-room-graph.mjs renderer, reached via the tmctAdventure global the
1347
+ // viz-room-graph.mjs renderer, reached via the tmct surface the
1344
1348
  // same way captionFor/pillsFor already reach their own adventure.mjs
1345
1349
  // calls: roomGraphSvg needs escapeHtml and a module-level exit-delta table
1346
1350
  // this splice-safe script carries neither of, so it runs through the
1347
1351
  // bundle rather than being spliced as text (see this page's own module
1348
1352
  // header). visitedRoomGraphFor is the same posture for the layout half.
1349
1353
  function roomGraphSvgFor(graph, clickable) {
1350
- return tmctAdventure.roomGraphSvg(graph, {
1354
+ return tmct.page.roomGraphSvg(graph, {
1351
1355
  cellX: 64, cellY: 64, roomW: 56, roomH: 26,
1352
1356
  clickable, selectedRoomId,
1353
1357
  label: clickable ? "the whole manor \\u2014 click a room to inspect it" : "the rooms visited so far",
1354
1358
  });
1355
1359
  }
1356
1360
  function visitedRoomGraphFor(state, visitedIds) {
1357
- return tmctAdventure.directedGridLayout(state, visitedIds, { actingSubject: ACTING_SUBJECT });
1361
+ return tmct.page.directedGridLayout(state, visitedIds, { actingSubject: ACTING_SUBJECT });
1358
1362
  }
1359
1363
  function renderRoomMap(rows, state, visitedRoomIds) {
1360
1364
  mapWrapEl.innerHTML = roomGraphSvgFor(visitedRoomGraphFor(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
@@ -1417,7 +1421,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1417
1421
  addChatLine("u", esc(line));
1418
1422
  addObjDockLine("u", esc(line));
1419
1423
  return withLock(async () => {
1420
- const result = await session.turn(line);
1424
+ const result = await tmct.turn(line);
1421
1425
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1422
1426
  addObjDockLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1423
1427
  const snap = await session.snapshot();
@@ -1442,7 +1446,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1442
1446
  // nothing, so it needs no main-transcript echo — it IS the board's own
1443
1447
  // content); pill/typed turns below do echo, since they can change state.
1444
1448
  await withLock(async () => {
1445
- const result = await session.turn("look " + subject);
1449
+ const result = await tmct.turn("look " + subject);
1446
1450
  objLookEl.textContent = result.answer;
1447
1451
  });
1448
1452
  }
@@ -1505,8 +1509,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1505
1509
 
1506
1510
  // ---- contextual pills — refreshed every redraw from the CURRENT room's
1507
1511
  // own roomAffordances-derived list (mirroring pillsForRoom against
1508
- // tmctAdventure.roomAffordances, the same way captionFor above mirrors
1509
- // roomCaptionText against tmctAdventure.worldDigestRows), so a pill for a
1512
+ // tmct.page.roomAffordances, the same way captionFor above mirrors
1513
+ // roomCaptionText against tmct.page.worldDigestRows), so a pill for a
1510
1514
  // room the player has left, or an object already taken, never lingers.
1511
1515
  // Clicking one inserts its exact command text into the input and focuses
1512
1516
  // it; it never auto-submits, so free typing still works and a clicked
@@ -1517,7 +1521,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1517
1521
  // ever wants to edit before running, and drawing it in both places would
1518
1522
  // make one control a suggestion and its twin an action.
1519
1523
  function pillsFor(rows, state, here) {
1520
- return tmctAdventure.roomAffordances(rows, state, here);
1524
+ return tmct.page.roomAffordances(rows, state, here);
1521
1525
  }
1522
1526
  function renderPills(rows, state, here) {
1523
1527
  const actions = pillsFor(rows, state, here).filter((a) => a.indexOf("go ") !== 0);
@@ -1560,10 +1564,10 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1560
1564
  const ROOT_FALLBACK_BY_CLASS = { adventurer: "person", person: "person", room: "room" };
1561
1565
  const rootFallbackFor = (cls) => ROOT_FALLBACK_BY_CLASS[cls] || "portable";
1562
1566
  function resolveObjectSprite(rows, s) {
1563
- if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { rootFallback: "person", instanceKey: "you" });
1564
- return tmctAdventure.resolveSpriteAsset(
1567
+ if (s.subject === "you") return tmct.page.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmct.page.SPRITE_REGISTRY, { rootFallback: "person", instanceKey: "you" });
1568
+ return tmct.page.resolveSpriteAsset(
1565
1569
  s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
1566
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1570
+ activeSpriteTemplates, tmct.page.SPRITE_REGISTRY,
1567
1571
  { rootFallback: rootFallbackFor(s.spriteClass), instanceKey: s.subject },
1568
1572
  );
1569
1573
  }
@@ -1574,9 +1578,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1574
1578
  // everything else falls back through room's own ancestor chain to the
1575
1579
  // generic room icon.
1576
1580
  function roomKindIconSvg(rows, here) {
1577
- return tmctAdventure.resolveSpriteAsset(
1581
+ return tmct.page.resolveSpriteAsset(
1578
1582
  here, spriteAncestryRows(rows, here), factsForSubject(rows, here),
1579
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1583
+ activeSpriteTemplates, tmct.page.SPRITE_REGISTRY,
1580
1584
  { rootFallback: "room", instanceKey: "roomkind-" + here },
1581
1585
  );
1582
1586
  }
@@ -1641,7 +1645,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1641
1645
  ticker.pause();
1642
1646
  addChatLine("u", esc(line));
1643
1647
  return withLock(async () => {
1644
- const result = await session.turn(line);
1648
+ const result = await tmct.turn(line);
1645
1649
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1646
1650
  const snap = await session.snapshot();
1647
1651
  redraw(snap);
@@ -1703,9 +1707,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1703
1707
  const classes = new Set(["adventurer"]);
1704
1708
  subjects.forEach((s) => { if (s !== ACTING_SUBJECT) classes.add(spriteClassForObject(rows, s)); });
1705
1709
  legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
1706
- const svg = tmctAdventure.resolveSpriteAsset(
1710
+ const svg = tmct.page.resolveSpriteAsset(
1707
1711
  cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
1708
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1712
+ activeSpriteTemplates, tmct.page.SPRITE_REGISTRY,
1709
1713
  { rootFallback: rootFallbackFor(cls), instanceKey: "legend-" + cls },
1710
1714
  );
1711
1715
  return spriteCardHtml(cls, cls, svg);
@@ -1724,8 +1728,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1724
1728
  }
1725
1729
 
1726
1730
  // ---- cursor-driven suggestion pills — the lateral SKOS neighbourhood
1727
- // (tmctAdventure.relatedForTerm) plus the vertical is-a ancestor chain
1728
- // (tmctAdventure.classAncestorChain), mirroring adventure-viz.mjs's own
1731
+ // (tmct.page.relatedForTerm) plus the vertical is-a ancestor chain
1732
+ // (tmct.page.classAncestorChain), mirroring adventure-viz.mjs's own
1729
1733
  // suggestionsForTerm against the browser bundle's global (the same
1730
1734
  // reach-through-the-global pattern captionFor/pillsFor already use).
1731
1735
  // Reads the FULL store (allStoreRows), not worldOnlyRows — a term's
@@ -1734,8 +1738,8 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1734
1738
  function renderSuggestionPills() {
1735
1739
  const term = wordBeforeCursor(editorTextEl.value, editorTextEl.selectionStart);
1736
1740
  if (!term) { editorPillsEl.innerHTML = ""; return; }
1737
- const related = tmctAdventure.relatedForTerm(allStoreRows, term);
1738
- const chain = tmctAdventure.classAncestorChain(term, allStoreRows);
1741
+ const related = tmct.page.relatedForTerm(allStoreRows, term);
1742
+ const chain = tmct.page.classAncestorChain(term, allStoreRows);
1739
1743
  const seen = new Set([term]);
1740
1744
  const out = [];
1741
1745
  const push = (label) => { if (label && !seen.has(label)) { seen.add(label); out.push(label); } };
@@ -1766,7 +1770,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1766
1770
  const snap = await session.snapshot();
1767
1771
  allStoreRows = snap.rows;
1768
1772
  editRows = worldOnlyRows(snap.rows);
1769
- editState = tmctAdventure.foldWorldState(editRows);
1773
+ editState = tmct.page.foldWorldState(editRows);
1770
1774
  if (result.unrecognized.length) {
1771
1775
  const lines = result.unrecognized.map((u) => u.line).join(", ");
1772
1776
  editorStatusEl.className = "edit-status pending";
@@ -1797,7 +1801,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1797
1801
  const snap = await session.snapshot();
1798
1802
  allStoreRows = snap.rows;
1799
1803
  editRows = worldOnlyRows(snap.rows);
1800
- editState = tmctAdventure.foldWorldState(editRows);
1804
+ editState = tmct.page.foldWorldState(editRows);
1801
1805
  editorTextEl.value = renderWorldEditorText(editRows, editState);
1802
1806
  editorStatusEl.className = "edit-status";
1803
1807
  editorStatusEl.textContent = "";
@@ -1818,7 +1822,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1818
1822
 
1819
1823
  async function boot({ fresh = false } = {}) {
1820
1824
  const saved = !fresh && persist ? await persist.load() : null;
1821
- session = await tmctAdventure.createAdventureSession(
1825
+ session = await tmct.open(
1822
1826
  world(),
1823
1827
  saved && saved.payload && saved.payload.memoryPayload
1824
1828
  ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
@@ -1877,9 +1881,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1877
1881
  // to. Without that, one world's snapshot would restore into another's graph.
1878
1882
  let siteVersion = "";
1879
1883
  async function openPersistFor(worldName) {
1880
- if (preview || !tmctAdventure.openPersistedStore) return;
1884
+ if (preview || !tmct.page.openPersistedStore) return;
1881
1885
  if (!siteVersion) siteVersion = await fetchSiteVersion();
1882
- persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + worldName });
1886
+ persist = tmct.page.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + worldName });
1883
1887
  }
1884
1888
 
1885
1889
  if (scenarioSelectEl) {
@@ -652,76 +652,101 @@ async function writeWorldTurn(memoryDir, world, k, facts, cache) {
652
652
 
653
653
  const VOIDED_TESTIMONY_SUFFIX = ":gone";
654
654
 
655
- const characterTestimonyTag = (character, k, voided = false) =>
656
- `mud:${character}:turn${k}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
657
-
658
- async function appendTestimony(memoryDir, { knower, source, thing, k, voided = false, cache }) {
655
+ /** The provenance tag one character's claim carries: who said it, which run of
656
+ * the world it was said in, the turn it was said on, and whether it says the
657
+ * thing has gone. The epoch segment follows snapshotSubject's convention —
658
+ * written only once a recast has moved the world past epoch 0, so an unrecast
659
+ * store and every tag written before epochs existed keep the bare form and
660
+ * read back as epoch 0. */
661
+ export function characterTestimonyTag(character, k, { epoch = 0, voided = false } = {}) {
662
+ const when = epoch > 0 ? `epoch${epoch}:turn${k}` : `turn${k}`;
663
+ return `mud:${character}:${when}${voided ? VOIDED_TESTIMONY_SUFFIX : ""}`;
664
+ }
665
+
666
+ async function appendTestimony(memoryDir, { knower, source, thing, k, epoch = 0, voided = false, cache }) {
659
667
  await appendFacts(memoryDir, [{
660
668
  subject: knower, predicate: KNOWS_ABOUT_PREDICATE, object: thing,
661
- provenance: characterTestimonyTag(source, k, voided),
669
+ provenance: characterTestimonyTag(source, k, { epoch, voided }),
662
670
  }]);
663
671
  if (cache) cache.rows = null;
664
672
  }
665
673
 
666
- /** Record that `teller` told `asker` about `thing` on turn `k`. The asker is
667
- * the subject — it is the one who now knows — and the teller is named in the
668
- * provenance, so the claim corroborates the teller's Source, not the asker's. */
669
- export async function recordTold(memoryDir, { asker, teller, thing, k, cache = null }) {
670
- return appendTestimony(memoryDir, { knower: asker, source: teller, thing, k, cache });
674
+ /** Record that `teller` told `asker` about `thing` on turn `k` of `epoch`. The
675
+ * asker is the subject — it is the one who now knows — and the teller is named
676
+ * in the provenance, so the claim corroborates the teller's Source, not the
677
+ * asker's. */
678
+ export async function recordTold(memoryDir, { asker, teller, thing, k, epoch = 0, cache = null }) {
679
+ return appendTestimony(memoryDir, { knower: asker, source: teller, thing, k, epoch, cache });
671
680
  }
672
681
 
673
- /** Record that `observer` examined `thing` on turn `k`. The observer is both
674
- * the subject and the provenance's character: it learned this by looking, so
675
- * it is its own source for it. */
676
- export async function recordExamined(memoryDir, { observer, thing, k, cache = null }) {
677
- return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, cache });
682
+ /** Record that `observer` examined `thing` on turn `k` of `epoch`. The observer
683
+ * is both the subject and the provenance's character: it learned this by
684
+ * looking, so it is its own source for it. */
685
+ export async function recordExamined(memoryDir, { observer, thing, k, epoch = 0, cache = null }) {
686
+ return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, epoch, cache });
678
687
  }
679
688
 
680
- /** Record that `observer` saw `thing` leave the world on turn `k` it ate the
681
- * last of it. Written as a fresh claim on the SAME edge an older one already
682
- * sits on, so the older claim stands untouched and stops being the one that
683
- * rules. The observer is its own source, the way examining is. */
684
- export async function recordGone(memoryDir, { observer, thing, k, cache = null }) {
685
- return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, voided: true, cache });
689
+ /** Record that `observer` saw `thing` leave the world on turn `k` of `epoch`
690
+ * it ate the last of it. Written as a fresh claim on the SAME edge an older one
691
+ * already sits on, so the older claim stands untouched and stops being the one
692
+ * that rules. The observer is its own source, the way examining is. */
693
+ export async function recordGone(memoryDir, { observer, thing, k, epoch = 0, cache = null }) {
694
+ return appendTestimony(memoryDir, { knower: observer, source: observer, thing, k, epoch, voided: true, cache });
686
695
  }
687
696
 
688
- const TESTIMONY_TAG_RE = /^mud:([^:\s]+):turn(\d+)(:gone)?$/;
697
+ const TESTIMONY_TAG_RE = /^mud:([^:\s]+):(?:epoch(\d+):)?turn(\d+)(:gone)?$/;
689
698
  const TURN_STAMP_RE = /:turn(\d+)\b/;
690
699
 
691
700
  /** How one provenance segment on a knows-about edge stands as a claim about
692
- * what `knower` knows: whether the knower vouches for it itself, the turn it
693
- * was asserted on, and whether it says the thing is gone. A tag that is no
694
- * character's testimony a world's own seed fact, a dig spawnreads as
695
- * hearsay stamped with whatever turn it carries. */
696
- function testimonyClaim(segment, knower) {
701
+ * what `knower` knows: which run of the world it was made in, whether the
702
+ * knower vouches for it itself, the turn it was asserted on, and whether it
703
+ * says the thing is gone. A tag that is no character's testimony a world's
704
+ * own seed fact, a dig spawn — reads as hearsay stamped with whatever turn it
705
+ * carries.
706
+ *
707
+ * Epoch reads the same way foldWorldState reads a row's: a stamped tag names
708
+ * its own run, a tag stamped with a turn but no epoch was written on epoch 0
709
+ * (or before epochs existed), and a tag with no turn at all is a seed the
710
+ * world lays down fresh on every recast, so it belongs to the run being read
711
+ * now. */
712
+ function testimonyClaim(segment, knower, currentEpoch = 0) {
697
713
  const mine = TESTIMONY_TAG_RE.exec(segment);
698
- const stamp = Number(mine ? mine[2] : (TURN_STAMP_RE.exec(segment)?.[1] ?? 0));
714
+ const turnStamp = mine ? mine[3] : TURN_STAMP_RE.exec(segment)?.[1];
715
+ const stamp = Number(turnStamp ?? 0);
716
+ const epochStamp = mine?.[2];
699
717
  return {
718
+ epoch: epochStamp ? Number(epochStamp) : (turnStamp === undefined ? currentEpoch : 0),
700
719
  firsthand: !!mine && mine[1] === knower,
701
720
  turn: Number.isFinite(stamp) ? stamp : 0,
702
- voided: !!(mine && mine[3]),
721
+ voided: !!(mine && mine[4]),
703
722
  };
704
723
  }
705
724
 
706
- /** Firsthand beats hearsay outright, then the later turn wins, then "gone"
707
- * takes the tie — an animal that examined a carrot and ate it on one turn ate
708
- * it second. Tier ABOVE recency is what stops an animal being talked back into
709
- * a meal it ate itself: a room-mate can tell it about that carrot the turn
710
- * after, and its own eyes still hold. */
725
+ /** The later epoch beats everything, then firsthand beats hearsay, then the
726
+ * later turn wins, then "gone" takes the tie — an animal that examined a carrot
727
+ * and ate it on one turn ate it second.
728
+ *
729
+ * Epoch on top mirrors the world-state fold: a recast starts the world over, so
730
+ * nothing said in the run it replaced can rule over what a character has seen
731
+ * since, however late in that old run it was said or however firsthand. Within
732
+ * one run, firsthand sits above recency, and that is what stops an animal being
733
+ * talked back into a meal it ate itself: a room-mate can tell it about that
734
+ * carrot the turn after, and its own eyes still hold. */
711
735
  const outranksClaim = (claim, best) => (
712
- claim.firsthand !== best.firsthand ? claim.firsthand
713
- : claim.turn !== best.turn ? claim.turn > best.turn
714
- : claim.voided && !best.voided
736
+ claim.epoch !== best.epoch ? claim.epoch > best.epoch
737
+ : claim.firsthand !== best.firsthand ? claim.firsthand
738
+ : claim.turn !== best.turn ? claim.turn > best.turn
739
+ : claim.voided && !best.voided
715
740
  );
716
741
 
717
742
  /** The claim that rules on one knows-about edge, across every segment its
718
743
  * provenance carries. */
719
- function rulingTestimonyClaim(provenance, knower) {
744
+ function rulingTestimonyClaim(provenance, knower, currentEpoch = 0) {
720
745
  let best = null;
721
746
  for (const segment of String(provenance || "").split(" | ")) {
722
747
  const tag = segment.trim();
723
748
  if (!tag) continue;
724
- const claim = testimonyClaim(tag, knower);
749
+ const claim = testimonyClaim(tag, knower, currentEpoch);
725
750
  if (!best || outranksClaim(claim, best)) best = claim;
726
751
  }
727
752
  return best;
@@ -730,12 +755,13 @@ function rulingTestimonyClaim(provenance, knower) {
730
755
  /** What `person` knows about NOW: the object of every knows-about edge whose
731
756
  * ruling claim still stands, in the order the edges were first written.
732
757
  * Nothing is deleted — an edge whose newest claim says the thing is gone just
733
- * stops reading back. */
734
- function currentKnowsAboutTopics(rows, person) {
758
+ * stops reading back. `currentEpoch` is the run the reader is on, so a claim a
759
+ * recast has left behind cannot decide what a character knows today. */
760
+ function currentKnowsAboutTopics(rows, person, currentEpoch = 0) {
735
761
  const topics = [];
736
762
  for (const row of rows || []) {
737
763
  if (row.subject !== person || row.predicate !== KNOWS_ABOUT_PREDICATE) continue;
738
- if (rulingTestimonyClaim(row.provenance, person)?.voided) continue;
764
+ if (rulingTestimonyClaim(row.provenance, person, currentEpoch)?.voided) continue;
739
765
  topics.push(row.object);
740
766
  }
741
767
  return topics;
@@ -1291,7 +1317,7 @@ export function personKnowledgeLines(rows, state, person) {
1291
1317
  ? `you'll find the ${thing} in the ${place.object}.`
1292
1318
  : `the ${thing} is in the ${place.object}.`);
1293
1319
  }
1294
- return { lines, aboutTopics: currentKnowsAboutTopics(rows, person) };
1320
+ return { lines, aboutTopics: currentKnowsAboutTopics(rows, person, state?.epoch ?? 0) };
1295
1321
  }
1296
1322
 
1297
1323
  /** The FOOD_CLASS things `person` durably knows about — from being told, or
@@ -1302,7 +1328,7 @@ export function personKnowledgeLines(rows, state, person) {
1302
1328
  * food query has no per-topic sub-digest to hand back, so this returns the
1303
1329
  * plain list of known food things rather than a {lines, topics} pair. Pure. */
1304
1330
  export function personKnownFoodLines(rows, state, person) {
1305
- return currentKnowsAboutTopics(rows, person)
1331
+ return currentKnowsAboutTopics(rows, person, state?.epoch ?? 0)
1306
1332
  .filter((thing) => objectClassChain(rows, thing).includes(FOOD_CLASS));
1307
1333
  }
1308
1334