@polycode-projects/the-mechanical-code-talker 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/corpus/sprites/src/sprite-facts.jsonl +28 -0
  2. package/corpus/tier2/generate.mjs +10 -1
  3. package/corpus/tier2/human.jsonl +23 -0
  4. package/corpus/tier2/manifest.json +3 -3
  5. package/corpus/worlds/index.json.gz +0 -0
  6. package/corpus/worlds/manifest.json +15 -5
  7. package/corpus/worlds/shards/mud-garden.jsonl.gz +0 -0
  8. package/corpus/worlds/src/mud-garden.jsonl +101 -0
  9. package/package.json +2 -1
  10. package/src/adapters/p2p/webrtc-transport.mjs +146 -0
  11. package/src/domain/game-config.mjs +67 -0
  12. package/src/domain/grammar/ace.mjs +11 -3
  13. package/src/domain/grammar/lexicon-core.json +3 -0
  14. package/src/domain/grammar/lexicon.mjs +13 -0
  15. package/src/domain/memory/trust.mjs +15 -0
  16. package/src/domain/p2p/facts.mjs +81 -0
  17. package/src/domain/p2p/peer-id.mjs +32 -0
  18. package/src/domain/p2p/provenance-relabel.mjs +26 -0
  19. package/src/domain/p2p/sync-filter.mjs +31 -0
  20. package/src/domain/p2p/wire.mjs +123 -0
  21. package/src/domain/sprite-map.mjs +10 -2
  22. package/src/services/adventure-editor.mjs +10 -2
  23. package/src/services/adventure-viz.mjs +192 -39
  24. package/src/services/adventure.mjs +989 -69
  25. package/src/services/chat-page-viz.mjs +1060 -7
  26. package/src/services/chat-session.mjs +28 -3
  27. package/src/services/chat.mjs +2 -2
  28. package/src/services/mud-editor.mjs +313 -0
  29. package/src/services/mud-turn.mjs +572 -0
  30. package/src/services/mud-viz.mjs +2055 -0
  31. package/src/services/p2p-room.mjs +559 -0
  32. package/src/surfaces/web/memory-ask-browser.bundle.js +77 -77
  33. package/src/surfaces/web/mud-browser-entry.mjs +330 -0
  34. package/src/surfaces/web/p2p-browser-entry.mjs +39 -0
@@ -281,6 +281,14 @@ export function parseWorldEditorText(text, contextRows = []) {
281
281
 
282
282
  const tripleKey = (t) => `${t.subject} ${t.predicate} ${t.object}`;
283
283
 
284
+ // The three flag predicates the renderer only ever writes a line for when they
285
+ // read "true" ("Basket is a container."). A row saying "false" has no sentence
286
+ // in this vocabulary, so it must not be diffable either — counted as editable
287
+ // while being unrenderable, it sits in the diff's current set, matches nothing
288
+ // the text can say, and gets retracted on the next clean parse: a fact the
289
+ // player never saw, silently deleted by an edit somewhere else in the document.
290
+ const TRUE_ONLY_FLAG_PREDICATES = ["mgx:is-container", "mgx:is-npc", "mgx:is-objective"];
291
+
284
292
  /** Every raw fact row this editor's "other" family (type/exits/container/
285
293
  * puzzle — never placement/openness) is allowed to touch — the closed set
286
294
  * planWorldEditorSync diffs against and the only rows a removal can ever
@@ -290,8 +298,8 @@ export function editableOtherRows(rows) {
290
298
  if (SNAPSHOT_RE.test(r.subject)) return false;
291
299
  if (r.predicate === "rdf:type") return true;
292
300
  if (EXIT_PREDICATE_RE.test(r.predicate)) return true;
293
- return ["mgx:is-container", "mgx:is-npc", "mgx:is-objective", "mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward"]
294
- .includes(r.predicate);
301
+ if (TRUE_ONLY_FLAG_PREDICATES.includes(r.predicate)) return r.object === "true";
302
+ return ["mgx:unlocks-with", "mgx:acts-on-turn", "mgx:acts-toward"].includes(r.predicate);
295
303
  });
296
304
  }
297
305
 
@@ -33,7 +33,10 @@
33
33
  // ancestor walk, then floor), `roomSceneLayout` (the room split into a wall
34
34
  // band and floor stacks, over `roomSceneObjects` and `scenePlacement`),
35
35
  // `roomKindForRoom` (a room's border treatment, from its own rdf:type),
36
- // `carriedItems` (every object placed with the player), and `visitedRoomGraph`
36
+ // `carriedItems` (every object placed with the player), `exitDoorways` (the
37
+ // written ways out of one room, in compass order, each marked with whether
38
+ // this session has walked it — what the room view's own door plates are
39
+ // drawn from), and `visitedRoomGraph`
37
40
  // (a directions-only layout of the rooms a session has actually visited —
38
41
  // see its own header for the exposure discipline). None of these import
39
42
  // anything beyond this module's own exports, which is what keeps a raw
@@ -109,22 +112,41 @@ export function spriteClassForObject(rows, subject) {
109
112
  return typeRow ? typeRow.object : "portable";
110
113
  }
111
114
 
112
- /** `rows` plus one synthetic rdfs:subClassOf edge from `subject`'s own name
113
- * to its declared sprite class (spriteClassForObject's own result) never
114
- * written back to the corpus, just handed to sprite-templates.mjs's
115
- * resolver so a NAMED object (`cabinet`, `butler`) can carry its own sprite
116
- * template while an unauthored object of the same declared class still
117
- * falls back through it cleanly. Ashcombe Hall's own objects have no
118
- * rdfs:subClassOf chain of their own (each is directly typed, e.g. `cabinet
119
- * rdf:type furniture`); this is the exact same ancestor-walk mechanism
120
- * spider-fly's real poodle-IsA-dog taxonomy already exercises, just
121
- * synthesized at render time for a world whose taxonomy doesn't reach this
122
- * deep. A no-op when the subject's own name already IS its declared class
123
- * (nothing to synthesize). Pure. */
115
+ /** `rows` plus synthetic rdfs:subClassOf edges covering `subject`'s whole
116
+ * class chain never written back to the corpus, just handed to
117
+ * sprite-templates.mjs's resolver so a NAMED object (`cabinet`, `butler`)
118
+ * can carry its own sprite template while an unauthored object of the same
119
+ * class still falls back cleanly, one ancestor at a time, to the nearest
120
+ * ancestor that HAS a sprite. Two kinds of edge are synthesized: one from
121
+ * the subject's own name to its declared sprite class
122
+ * (spriteClassForObject's own result), and one mirroring every `rdf:type`
123
+ * hop further up the chain. The mirroring is what makes the fallback reach
124
+ * past the first hop: the resolver walks rdfs:subClassOf only, so a world
125
+ * that writes `turnip rdf:type vegetable` would otherwise stop at `turnip`
126
+ * and skip a perfectly good `vegetable` sprite. Ashcombe Hall's own objects
127
+ * have no rdfs:subClassOf chain at all (each is directly typed, e.g.
128
+ * `cabinet rdf:type furniture`); this is the same ancestor-walk mechanism
129
+ * spider-fly's real poodle-IsA-dog taxonomy already exercises, synthesized
130
+ * at render time for a world whose taxonomy doesn't reach that deep. Returns
131
+ * `rows` itself when there is nothing to synthesize. Pure. */
124
132
  export function spriteAncestryRows(rows, subject) {
125
133
  const declaredClass = spriteClassForObject(rows, subject);
126
- if (subject === declaredClass) return rows;
127
- return [...(rows || []), { subject, predicate: "rdfs:subClassOf", object: declaredClass }];
134
+ const synthesized = [];
135
+ if (subject !== declaredClass) synthesized.push({ subject, predicate: "rdfs:subClassOf", object: declaredClass });
136
+ const seen = new Set([subject]);
137
+ const queue = [declaredClass];
138
+ while (queue.length) {
139
+ const node = queue.shift();
140
+ if (seen.has(node)) continue;
141
+ seen.add(node);
142
+ for (const row of rows || []) {
143
+ if (row.subject !== node || row.object === node) continue;
144
+ if (row.predicate === "rdf:type") synthesized.push({ subject: node, predicate: "rdfs:subClassOf", object: row.object });
145
+ else if (row.predicate !== "rdfs:subClassOf") continue;
146
+ queue.push(row.object);
147
+ }
148
+ }
149
+ return synthesized.length ? [...(rows || []), ...synthesized] : rows;
128
150
  }
129
151
 
130
152
  /** Every fact row belonging to `subject` — the small `{predicate, object}`
@@ -415,6 +437,28 @@ export function goalStatusLines(rows, state, visitedRoomIds) {
415
437
  });
416
438
  }
417
439
 
440
+ /** Every written way out of `here`, in a fixed compass order, each with the
441
+ * room it leads to and whether this session has already walked there:
442
+ * `[{ direction, target, walked }]`. Only exits the world actually writes
443
+ * appear, so a doorway drawn from this list can never offer a `go` the
444
+ * engine would then refuse. `walked` reads the SAME visited set the manor
445
+ * map and the goal lines already do — the rooms the player has stood in, so
446
+ * marking a door as new tells them nothing they haven't earned. Ashcombe
447
+ * Hall teaches no `dig` action family, so unlike the burrow's own compass
448
+ * ring there is no second kind of way out to draw. Pure and
449
+ * `.toString()`-splice-safe. */
450
+ export function exitDoorways(state, here, visitedRoomIds) {
451
+ const compass = ["north", "east", "south", "west", "up", "down"];
452
+ const walked = new Set(visitedRoomIds || []);
453
+ const exits = state.exits.get(here);
454
+ if (!exits) return [];
455
+ return compass.filter((direction) => exits.has(direction)).map((direction) => ({
456
+ direction,
457
+ target: exits.get(direction),
458
+ walked: walked.has(exits.get(direction)),
459
+ }));
460
+ }
461
+
418
462
  /** The clickable command suggestions for the room the player is CURRENTLY
419
463
  * in — a thin, testable wrapper over adventure.mjs's own `roomAffordances`
420
464
  * (the exact same data take/open/talk/examine already check), so a pill can
@@ -667,10 +711,47 @@ ${THEME_TOKENS_CSS}
667
711
  falls back through room's own ancestor chain to the generic room icon).
668
712
  Rendered at 120px — twice a room-object sprite's own 60px frame
669
713
  (.sprite-frame below) — so the room itself reads as the biggest thing
670
- drawn in its own scene. */
671
- .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; opacity: .92; }
714
+ drawn in its own scene. It takes the wall's own brass rather than the
715
+ page ink: at that size an ink-black icon out-shouts every person and
716
+ prop standing in front of it, and this is a mark ON the wall, not
717
+ another thing in the room. */
718
+ .room-kind-icon { position: absolute; top: .5rem; right: .6rem; width: 120px; height: 120px; color: var(--gilt); opacity: .5; }
672
719
  .room-kind-icon svg { width: 100%; height: 100%; display: block; }
673
720
  .room-kind-icon:empty { display: none; }
721
+ /* the doorways — every written way out drawn where it actually points,
722
+ as a brass door plate set into the wall it leads through. The plate
723
+ borrows the room plaque's own treatment above (parchment ground, gilt
724
+ rule, inset mat): a manor has one brass vocabulary, and a door plate and
725
+ a room plate are the same object. North and south sit in a band OUTSIDE
726
+ the frame so neither fights the plaque or the room-kind icon; west and
727
+ east sit just inside their own walls. Stairs get a round chip instead —
728
+ neither up nor down is a compass point, and neither hangs on a wall. */
729
+ .room-stage { position: relative; padding: 1.35rem 0; }
730
+ .dir-ring { position: absolute; inset: 0; pointer-events: none; }
731
+ .dir-slot { position: absolute; pointer-events: auto; }
732
+ .dir-north { top: 0; left: 50%; transform: translateX(-50%); }
733
+ .dir-south { bottom: 0; left: 50%; transform: translateX(-50%); }
734
+ .dir-west { left: .3rem; top: 50%; transform: translateY(-50%); }
735
+ .dir-east { right: .3rem; top: 50%; transform: translateY(-50%); }
736
+ /* the stairs stand at the left of each band: the frame's own corner
737
+ flourishes sit top-left and bottom-right, and this is the one inset that
738
+ clears both. */
739
+ .dir-up { top: 0; left: 1.4rem; }
740
+ .dir-down { bottom: 0; left: 1.4rem; }
741
+ .dir-door {
742
+ position: relative; display: inline-flex; align-items: center; justify-content: center;
743
+ font-family: ${MONO_STACK}; font-size: .62rem; letter-spacing: .14em; text-transform: uppercase;
744
+ color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt);
745
+ box-shadow: inset 0 0 0 2px var(--parchment-strong);
746
+ padding: .2rem .6rem; white-space: nowrap;
747
+ }
748
+ .dir-door:hover, .dir-door:focus-visible { background: var(--parchment-strong); color: var(--ink); }
749
+ .dir-door.stair { border-radius: 50%; width: 1.5rem; height: 1.5rem; padding: 0; font-size: .72rem; letter-spacing: 0; }
750
+ /* a door onto a room this session has not stood in yet, marked with the
751
+ same gilt dot the manor board prints for a direction it cannot draw a
752
+ room for — one hint shape, two surfaces. */
753
+ .dir-door .unwalked { position: absolute; top: -.16rem; right: -.16rem; width: .34rem; height: .34rem; border-radius: 50%; background: var(--gilt); }
754
+
674
755
  .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
675
756
  /* the floor band: every non-wall-mounted stack, wrapping and pinned to the
676
757
  left edge of the room, as the flat sprite row always has. */
@@ -874,6 +955,10 @@ ${THEME_TOKENS_CSS}
874
955
  #legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
875
956
 
876
957
  body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
958
+ /* the hero iframe is a picture of a room, not a control surface — every
959
+ other control is hidden there, and the doorways go with them. */
960
+ body.preview .room-stage { padding: 0; }
961
+ body.preview .dir-ring { display: none; }
877
962
  body.preview main { padding: 0; max-width: none; }
878
963
  body.preview .stage { display: block; }
879
964
  body.preview .eyebrow, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
@@ -904,13 +989,23 @@ ${THEME_TOKENS_CSS}
904
989
  <h2>satchel</h2>
905
990
  <div class="chips" id="carryList"></div>
906
991
  </div>
907
- <div class="room-frame" id="roomFrame">
908
- <div class="room-plaque mono" id="roomName"></div>
909
- <div class="room-kind-icon" id="roomKindIcon"></div>
910
- <div class="wall-row" id="wallRow"></div>
911
- <div class="sprite-row" id="spriteRow">
912
- <div class="floor-row" id="floorRow"></div>
913
- <div class="you-slot" id="youSlot"></div>
992
+ <div class="room-stage">
993
+ <div class="room-frame" id="roomFrame">
994
+ <div class="room-plaque mono" id="roomName"></div>
995
+ <div class="room-kind-icon" id="roomKindIcon"></div>
996
+ <div class="wall-row" id="wallRow"></div>
997
+ <div class="sprite-row" id="spriteRow">
998
+ <div class="floor-row" id="floorRow"></div>
999
+ <div class="you-slot" id="youSlot"></div>
1000
+ </div>
1001
+ </div>
1002
+ <div class="dir-ring" id="dirRing" aria-label="the ways out of this room">
1003
+ <div class="dir-slot dir-north" id="dir-north"></div>
1004
+ <div class="dir-slot dir-east" id="dir-east"></div>
1005
+ <div class="dir-slot dir-south" id="dir-south"></div>
1006
+ <div class="dir-slot dir-west" id="dir-west"></div>
1007
+ <div class="dir-slot dir-up" id="dir-up"></div>
1008
+ <div class="dir-slot dir-down" id="dir-down"></div>
914
1009
  </div>
915
1010
  </div>
916
1011
  <div class="panel command">
@@ -995,6 +1090,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
995
1090
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
996
1091
  const allRoomIds = ${allRoomIds.toString()};
997
1092
  const groundedPlaceholder = ${groundedPlaceholder.toString()};
1093
+ const exitDoorways = ${exitDoorways.toString()};
998
1094
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
999
1095
  const factsForSubject = ${factsForSubject.toString()};
1000
1096
  const renderWorldEditorText = ${renderWorldEditorText.toString()};
@@ -1004,6 +1100,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1004
1100
  const roomFrameEl = el("roomFrame");
1005
1101
  const roomNameEl = el("roomName");
1006
1102
  const roomKindIconEl = el("roomKindIcon");
1103
+ const dirRingEl = el("dirRing");
1007
1104
  const wallRowEl = el("wallRow");
1008
1105
  const floorRowEl = el("floorRow");
1009
1106
  const youSlotEl = el("youSlot");
@@ -1386,11 +1483,16 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1386
1483
  // Clicking one inserts its exact command text into the input and focuses
1387
1484
  // it; it never auto-submits, so free typing still works and a clicked
1388
1485
  // suggestion can still be edited first.
1486
+ //
1487
+ // The row shows what to do with the things IN the room; the doorways above
1488
+ // own every way OUT of it. A "go" command is the one affordance no player
1489
+ // ever wants to edit before running, and drawing it in both places would
1490
+ // make one control a suggestion and its twin an action.
1389
1491
  function pillsFor(rows, state, here) {
1390
1492
  return tmctAdventure.roomAffordances(rows, state, here);
1391
1493
  }
1392
1494
  function renderPills(rows, state, here) {
1393
- const actions = pillsFor(rows, state, here);
1495
+ const actions = pillsFor(rows, state, here).filter((a) => a.indexOf("go ") !== 0);
1394
1496
  pillsEl.innerHTML = actions.map((a) => '<button type="button" class="pill">' + esc(a) + "</button>").join("");
1395
1497
  // The empty input teaches the grounded noun form off the same affordance
1396
1498
  // list the pills read — real props from THIS room, so a first-time player
@@ -1422,11 +1524,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1422
1524
  // stay keyed on that declared class unchanged. The player's own "you" row
1423
1525
  // has no backing fact row, so it resolves directly by its fixed
1424
1526
  // "adventurer" class with no ancestry/property rows to check.
1527
+ //
1528
+ // When the whole chain misses, the root the resolver lands on is chosen by
1529
+ // what KIND of thing the class is, never the resolver's own "animal"
1530
+ // default: an unknown prop in a manor should read as a plain parcel and an
1531
+ // unknown character as a figure, and neither should ever draw a creature.
1532
+ const ROOT_FALLBACK_BY_CLASS = { adventurer: "person", person: "person", room: "room" };
1533
+ const rootFallbackFor = (cls) => ROOT_FALLBACK_BY_CLASS[cls] || "portable";
1425
1534
  function resolveObjectSprite(rows, s) {
1426
- if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "you" });
1535
+ if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { rootFallback: "person", instanceKey: "you" });
1427
1536
  return tmctAdventure.resolveSpriteAsset(
1428
1537
  s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
1429
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: s.subject },
1538
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1539
+ { rootFallback: rootFallbackFor(s.spriteClass), instanceKey: s.subject },
1430
1540
  );
1431
1541
  }
1432
1542
 
@@ -1438,10 +1548,38 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1438
1548
  function roomKindIconSvg(rows, here) {
1439
1549
  return tmctAdventure.resolveSpriteAsset(
1440
1550
  here, spriteAncestryRows(rows, here), factsForSubject(rows, here),
1441
- activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "roomkind-" + here },
1551
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1552
+ { rootFallback: "room", instanceKey: "roomkind-" + here },
1442
1553
  );
1443
1554
  }
1444
1555
 
1556
+ // ---- the doorways — one brass plate per written exit, in the slot that
1557
+ // exit actually points at, drawn from exitDoorways over the SAME snapshot
1558
+ // the room scene reads. A direction the world writes no exit for draws
1559
+ // nothing at all, so the ring never offers a "go" the engine would refuse.
1560
+ const STAIR_GLYPH = { up: "\\u2191", down: "\\u2193" };
1561
+ function renderDoorways(state, here, visitedRoomIds) {
1562
+ const bySlot = {};
1563
+ exitDoorways(state, here, visitedRoomIds).forEach((d) => { bySlot[d.direction] = d; });
1564
+ for (const direction of ["north", "east", "south", "west", "up", "down"]) {
1565
+ const slot = el("dir-" + direction);
1566
+ const doorway = bySlot[direction];
1567
+ if (!doorway) { slot.innerHTML = ""; continue; }
1568
+ const stair = direction === "up" || direction === "down";
1569
+ const label = "go " + direction + (doorway.walked ? "" : " (not walked yet)");
1570
+ slot.innerHTML = '<button type="button" class="dir-door' + (stair ? " stair" : "") + '"'
1571
+ + ' data-direction="' + esc(direction) + '" title="' + esc(label) + '" aria-label="' + esc(label) + '">'
1572
+ + (stair ? STAIR_GLYPH[direction] : esc(direction))
1573
+ + (doorway.walked ? "" : '<span class="unwalked" aria-hidden="true"></span>') + "</button>";
1574
+ }
1575
+ }
1576
+ // A door is not a suggestion to edit — clicking one walks through it.
1577
+ dirRingEl.addEventListener("click", (e) => {
1578
+ const btn = e.target.closest(".dir-door");
1579
+ if (!btn || !session) return;
1580
+ runTurn("go " + btn.getAttribute("data-direction"));
1581
+ });
1582
+
1445
1583
  function redraw(snap) {
1446
1584
  lastSnapshot = snap;
1447
1585
  const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
@@ -1460,27 +1598,34 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1460
1598
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
1461
1599
  turnLabelEl.textContent = "turn: " + snap.turn;
1462
1600
  renderPills(snap.rows, snap.state, snap.here);
1601
+ renderDoorways(snap.state, snap.here, snap.visitedRoomIds);
1463
1602
  renderCarrying(snap.rows, snap.state);
1464
1603
  renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
1465
1604
  renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
1466
1605
  schedulePersistSave();
1467
1606
  }
1468
1607
 
1469
- chatformEl.addEventListener("submit", (e) => {
1470
- e.preventDefault();
1471
- const q = chatqEl.value.trim();
1472
- if (!q || !session) return;
1473
- chatqEl.value = "";
1474
- // A manual command lands mid-tick otherwise — pause first, and stay
1475
- // paused until the visitor presses play again.
1608
+ // Every command a visitor issues by hand — typed, or walked through a
1609
+ // doorway — runs here, so the transcript, the pause and the redraw are the
1610
+ // same for both. A manual command lands mid-tick otherwise, so this pauses
1611
+ // first, and stays paused until the visitor presses play again.
1612
+ function runTurn(line) {
1476
1613
  ticker.pause();
1477
- addChatLine("u", esc(q));
1478
- withLock(async () => {
1479
- const result = await session.turn(q);
1614
+ addChatLine("u", esc(line));
1615
+ return withLock(async () => {
1616
+ const result = await session.turn(line);
1480
1617
  addChatLine("a", esc(result.answer).replace(/\\n/g, "<br>"));
1481
1618
  const snap = await session.snapshot();
1482
1619
  redraw(snap);
1483
1620
  });
1621
+ }
1622
+
1623
+ chatformEl.addEventListener("submit", (e) => {
1624
+ e.preventDefault();
1625
+ const q = chatqEl.value.trim();
1626
+ if (!q || !session) return;
1627
+ chatqEl.value = "";
1628
+ runTurn(q);
1484
1629
  });
1485
1630
 
1486
1631
  // ---- edit mode ---------------------------------------------------------
@@ -1522,12 +1667,20 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1522
1667
  // ---- legend — every known object/character class this world actually
1523
1668
  // uses, by its real icon. Scoped to worldOnlyRows so the background
1524
1669
  // corpus's own (unrelated) rdf:type vocabulary never floods the list.
1670
+ // Each class resolves through the SAME ancestry walk and kind-aware root
1671
+ // the room's own cards use, so a class an edited world invents climbs to
1672
+ // the nearest ancestor that has a picture instead of dropping straight to
1673
+ // a generic one.
1525
1674
  function renderLegend(rows) {
1526
1675
  const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
1527
1676
  const classes = new Set(["adventurer"]);
1528
1677
  subjects.forEach((s) => { if (s !== "player") classes.add(spriteClassForObject(rows, s)); });
1529
1678
  legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
1530
- const svg = tmctAdventure.resolveSpriteAsset(cls, [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "legend-" + cls });
1679
+ const svg = tmctAdventure.resolveSpriteAsset(
1680
+ cls, spriteAncestryRows(rows, cls), factsForSubject(rows, cls),
1681
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY,
1682
+ { rootFallback: rootFallbackFor(cls), instanceKey: "legend-" + cls },
1683
+ );
1531
1684
  return spriteCardHtml(cls, cls, svg);
1532
1685
  }).join("");
1533
1686
  }