@polycode-projects/the-mechanical-code-talker 2.10.5 → 2.11.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 (40) hide show
  1. package/README.md +2 -2
  2. package/corpus/sprites/src/sprite-facts.jsonl +18 -0
  3. package/corpus/worlds/manifest.json +5 -5
  4. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  5. package/corpus/worlds/src/ashcombe-hall.jsonl +27 -0
  6. package/data/sprites/book-icon.toml +12 -0
  7. package/data/sprites/cellar-icon.toml +12 -0
  8. package/data/sprites/drawing-room-icon.toml +13 -0
  9. package/data/sprites/garden-icon.toml +12 -0
  10. package/data/sprites/kitchen-icon.toml +13 -0
  11. package/data/sprites/library-icon.toml +12 -0
  12. package/data/sprites/pan-icon.toml +11 -0
  13. package/data/sprites/study-icon.toml +12 -0
  14. package/package.json +5 -2
  15. package/src/adapters/corpus/wikipedia-live.mjs +182 -26
  16. package/src/adapters/corpus/worlds-pack.mjs +8 -2
  17. package/src/adapters/toml-config.mjs +6 -0
  18. package/src/domain/memory/trust.mjs +11 -0
  19. package/src/domain/worlds-pack.mjs +50 -0
  20. package/src/services/adventure-autoplay.mjs +5 -2
  21. package/src/services/adventure-viz.mjs +301 -33
  22. package/src/services/adventure.mjs +162 -14
  23. package/src/services/chat-page-viz.mjs +265 -189
  24. package/src/services/chat-session.mjs +15 -5
  25. package/src/services/chat.mjs +286 -43
  26. package/src/services/code-explorer-viz.mjs +183 -75
  27. package/src/services/extract-facts.mjs +118 -28
  28. package/src/services/ingest-viz.mjs +328 -79
  29. package/src/services/ledger-viz.mjs +99 -0
  30. package/src/services/memory-panel-viz.mjs +159 -0
  31. package/src/services/research.mjs +266 -0
  32. package/src/services/sentences.mjs +19 -0
  33. package/src/services/spider-fly-viz.mjs +21 -5
  34. package/src/surfaces/web/adventure-browser-entry.mjs +9 -5
  35. package/src/surfaces/web/chat-browser-entry.mjs +28 -11
  36. package/src/surfaces/web/code-explorer-browser-entry.mjs +27 -11
  37. package/src/surfaces/web/ingest-browser-entry.mjs +123 -41
  38. package/src/surfaces/web/ledger-browser-entry.mjs +10 -4
  39. package/src/surfaces/web/memory-ask-browser.bundle.js +112 -112
  40. package/src/surfaces/web/memory-stats.mjs +53 -0
@@ -19,7 +19,7 @@
19
19
  // already takes with its own precomputed memory payload, just applied to a
20
20
  // second kind of build-time data.
21
21
  //
22
- // Five pure, `.toString()`-splice-safe pieces are exported as real functions
22
+ // Eight pure, `.toString()`-splice-safe pieces are exported as real functions
23
23
  // (not raw inline-script text) so they can be pinned directly by tests, the
24
24
  // same discipline spider-fly-viz.mjs holds classOfAgentId/
25
25
  // threadCellsForSpiderPlan to: `spriteClassForObject` (an object's sprite
@@ -28,6 +28,11 @@
28
28
  // adventure.mjs's own private `visibleRoomOf` the same way
29
29
  // adventure-autoplay.mjs's own `roomOfSubject` already has to), `roomSceneObjects`
30
30
  // (every subject actually visible in a room, built over `visibleRoomOf`),
31
+ // `scenePlacement` (an object's rendering plane and, if stacked, what it
32
+ // rests on — instance on-top-of/on-plane facts, then a class-default
33
+ // ancestor walk, then floor), `roomSceneLayout` (the room split into a wall
34
+ // band and floor stacks, over `roomSceneObjects` and `scenePlacement`),
35
+ // `roomKindForRoom` (a room's border treatment, from its own rdf:type),
31
36
  // `carriedItems` (every object placed with the player), and `visitedRoomGraph`
32
37
  // (a directions-only layout of the rooms a session has actually visited —
33
38
  // see its own header for the exposure discipline). None of these import
@@ -55,9 +60,10 @@
55
60
  // tick. `#caption`/`#goalLine` keep their existing job as the current-state
56
61
  // summary; the chat log is the persistent addition.
57
62
  //
58
- // Three further panels sit alongside the chat dock in `.side`: carrying
59
- // (`carriedItems`), the visited-room map (`visitedRoomGraph`), and goal
60
- // status (`goalStatusLines`) all three read `session.snapshot()`'s own
63
+ // The chat dock is the whole of `.side`; carrying (`carriedItems`), the
64
+ // visited-room map (`visitedRoomGraph`) and goal status (`goalStatusLines`)
65
+ // render into their own panels in `.stage-left` instead, alongside the room
66
+ // view and its controls — all three read `session.snapshot()`'s own
61
67
  // `visitedRoomIds`, the manual+auto-play exposure set
62
68
  // adventure-browser-entry.mjs threads forward (see that module's header for
63
69
  // why the two paths share one set rather than two).
@@ -169,6 +175,138 @@ export function roomSceneObjects(rows, state, here) {
169
175
  return out;
170
176
  }
171
177
 
178
+ /** Where `subject` renders in its room's scene: `{plane, stackedOn}`, plane
179
+ * one of "floor"/"surface"/"wall"/"ceiling". Resolution order: a CURRENT
180
+ * `mgx:on-top-of` row (plane "surface", `stackedOn` the object rested on) —
181
+ * "current" meaning its own newest turn is at least as new as `subject`'s
182
+ * own newest PLACEMENT turn (`state.placements`), so `take lamp` off a desk
183
+ * auto-invalidates a stale `lamp mgx:on-top-of desk` row with no extra
184
+ * write, the same staleness rule `foldWorldState` already applies within
185
+ * its own placement map; then a current `mgx:on-plane` row (its own object
186
+ * taken as the plane verbatim); then a class default, found by a breadth-
187
+ * first walk up `rdfs:subClassOf` edges FIRST (a directly-taxonomized
188
+ * individual such as "portrait rdfs:subClassOf painting", to its own
189
+ * superclass) for the first `mgx:default-plane` fact, falling back to a
190
+ * second walk that also follows `rdf:type` edges (an individual to its
191
+ * generic rendering class) only if the first finds nothing — a specific
192
+ * taxonomic fact about the individual must outrank its own coarser
193
+ * `rdf:type`, or which ancestor's default wins becomes an accident of fact
194
+ * order rather than a taxonomy decision; a DIFFERENT ancestor walk from
195
+ * sprite-map.mjs's own `classAncestorChain` (subClassOf-only, and an import
196
+ * this function can't take and stay `.toString()`-splice-safe); then the
197
+ * floor. Pure, fully self-contained in its own function body (no
198
+ * reference to any other name in this module) — reads `rows` fresh rather
199
+ * than a precomputed `positions` map, so it needs nothing beyond what
200
+ * `foldWorldState` already exposes on `state.placements`. */
201
+ export function scenePlacement(rows, state, subject) {
202
+ const snapshotRe = /^(.+)@turn(\d+)$/;
203
+ function newestRowFor(predicate) {
204
+ let best = null;
205
+ for (const row of rows || []) {
206
+ if (row.predicate !== predicate) continue;
207
+ const m = snapshotRe.exec(row.subject);
208
+ const base = m ? m[1] : row.subject;
209
+ if (base !== subject) continue;
210
+ const turn = m ? Number(m[2]) : 0;
211
+ if (!best || turn >= best.turn) best = { object: row.object, turn };
212
+ }
213
+ return best;
214
+ }
215
+ // Two passes, not one BFS over both edge kinds at once: an individual
216
+ // directly `rdfs:subClassOf` a class (e.g. "portrait rdfs:subClassOf
217
+ // painting") is a deliberate, specific taxonomic fact and must win over
218
+ // its own, more generic `rdf:type` (e.g. "portrait rdf:type furniture",
219
+ // the coarse sprite/rendering category) whenever BOTH ancestors carry
220
+ // their own `mgx:default-plane` — a single combined-edge walk would
221
+ // instead return whichever ancestor happens to appear earlier in the
222
+ // fact rows, an accident of file order rather than a taxonomy decision.
223
+ function walkForDefaultPlane(edgePredicates) {
224
+ const seen = new Set([subject]);
225
+ const queue = [subject];
226
+ while (queue.length) {
227
+ const node = queue.shift();
228
+ const defaultRow = (rows || []).find((r) => r.subject === node && r.predicate === "mgx:default-plane");
229
+ if (defaultRow) return defaultRow.object;
230
+ for (const row of rows || []) {
231
+ if (row.subject !== node || !edgePredicates.includes(row.predicate)) continue;
232
+ if (!seen.has(row.object)) { seen.add(row.object); queue.push(row.object); }
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+ function classDefaultPlane() {
238
+ return walkForDefaultPlane(["rdfs:subClassOf"]) ?? walkForDefaultPlane(["rdf:type", "rdfs:subClassOf"]);
239
+ }
240
+ const placementTurn = state?.placements?.get(subject)?.turn ?? 0;
241
+ const onTopOf = newestRowFor("mgx:on-top-of");
242
+ if (onTopOf && onTopOf.turn >= placementTurn) return { plane: "surface", stackedOn: onTopOf.object };
243
+ const onPlane = newestRowFor("mgx:on-plane");
244
+ if (onPlane && onPlane.turn >= placementTurn) return { plane: onPlane.object, stackedOn: null };
245
+ const defaultPlane = classDefaultPlane();
246
+ if (defaultPlane) return { plane: defaultPlane, stackedOn: null };
247
+ return { plane: "floor", stackedOn: null };
248
+ }
249
+
250
+ /** The room scene laid out for drawing: `{wall, floor}`. `wall` is every
251
+ * wall- or ceiling-mounted visible object (deterministic, subject order).
252
+ * `floor` is one entry per floor-standing stack, `{items: [top..base]}` —
253
+ * a `mgx:on-top-of` chain is resolved transitively (an item on an item on a
254
+ * floor base becomes one three-level stack), and an item whose own
255
+ * `stackedOn` target is not itself VISIBLE in this room (or is itself
256
+ * wall/ceiling-mounted, so has no floor footprint to stack onto) demotes to
257
+ * its own single-item floor stack rather than vanishing. Built over
258
+ * `roomSceneObjects` and `scenePlacement`, so it can never draw an object
259
+ * the flat room view wouldn't already show. Pure. */
260
+ export function roomSceneLayout(rows, state, here) {
261
+ const objects = roomSceneObjects(rows, state, here);
262
+ const bySubject = new Map(objects.map((o) => [o.subject, o]));
263
+ const placementOf = new Map(objects.map((o) => [o.subject, scenePlacement(rows, state, o.subject)]));
264
+
265
+ const wall = [];
266
+ const stackedOnBy = new Map(); // base subject -> [subjects resting directly on it]
267
+ const bases = [];
268
+
269
+ for (const o of objects) {
270
+ const placement = placementOf.get(o.subject);
271
+ if (placement.plane === "wall" || placement.plane === "ceiling") { wall.push(o); continue; }
272
+ const targetPlacement = placement.stackedOn ? placementOf.get(placement.stackedOn) : null;
273
+ const targetHasFloorFootprint = targetPlacement && targetPlacement.plane !== "wall" && targetPlacement.plane !== "ceiling";
274
+ if (placement.plane === "surface" && placement.stackedOn !== o.subject && targetHasFloorFootprint) {
275
+ if (!stackedOnBy.has(placement.stackedOn)) stackedOnBy.set(placement.stackedOn, []);
276
+ stackedOnBy.get(placement.stackedOn).push(o.subject);
277
+ continue;
278
+ }
279
+ bases.push(o.subject);
280
+ }
281
+
282
+ wall.sort((a, b) => a.subject.localeCompare(b.subject));
283
+ bases.sort((a, b) => a.localeCompare(b));
284
+ for (const list of stackedOnBy.values()) list.sort((a, b) => a.localeCompare(b));
285
+
286
+ const visitedForStack = new Set();
287
+ function stackFrom(base) {
288
+ if (visitedForStack.has(base)) return [];
289
+ visitedForStack.add(base);
290
+ const items = [];
291
+ for (const child of stackedOnBy.get(base) || []) items.push(...stackFrom(child));
292
+ items.push(bySubject.get(base));
293
+ return items;
294
+ }
295
+
296
+ return { wall, floor: bases.map((base) => ({ items: stackFrom(base) })) };
297
+ }
298
+
299
+ /** A room's kind for its border treatment: "outdoor" (`rdf:type
300
+ * outdoor-space`), "underground" (`rdf:type underground-space`), else
301
+ * "indoor" — the default, covering every room the current shipped world
302
+ * defines. Pure, self-contained. */
303
+ export function roomKindForRoom(rows, roomId) {
304
+ const isRoomTyped = (kind) => (rows || []).some((r) => r.subject === roomId && r.predicate === "rdf:type" && r.object === kind);
305
+ if (isRoomTyped("outdoor-space")) return "outdoor";
306
+ if (isRoomTyped("underground-space")) return "underground";
307
+ return "indoor";
308
+ }
309
+
172
310
  /** Every object currently `mgx:located-in` "player", sorted, each with its
173
311
  * sprite class — the exact placement `worldDigestRows`'/`inventoryAnswer`'s
174
312
  * own "carries the" branch already reads, just returned as a plain list
@@ -394,24 +532,36 @@ ${THEME_TOKENS_CSS}
394
532
  --baize: #4A6B52; --baize-line: #33503C; --board-path: #E7DAB8;
395
533
  --wall: #E9DDBE; --wall-stripe: rgba(147, 112, 31, .10);
396
534
  --floor-a: #D9C398; --floor-b: #CFB884;
535
+ --sky: #BFE0EE; --hedge: #3F6B45;
536
+ --stone: #6E6A63; --stone-b: rgba(110, 106, 99, .18);
537
+ --stone-floor-a: #504C46; --stone-floor-b: #464340;
397
538
  }
398
539
  @media (prefers-color-scheme: dark) { :root {
399
540
  --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054;
400
541
  --baize: #1E2C22; --baize-line: #4A6852; --board-path: #56604D;
401
542
  --wall: #262013; --wall-stripe: rgba(192, 160, 84, .07);
402
543
  --floor-a: #2B2314; --floor-b: #241E10;
544
+ --sky: #16232B; --hedge: #2E4A33;
545
+ --stone: #3A3834; --stone-b: rgba(255, 255, 255, .05);
546
+ --stone-floor-a: #17150F; --stone-floor-b: #100E0A;
403
547
  } }
404
548
  :root[data-theme="dark"] {
405
549
  --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054;
406
550
  --baize: #1E2C22; --baize-line: #4A6852; --board-path: #56604D;
407
551
  --wall: #262013; --wall-stripe: rgba(192, 160, 84, .07);
408
552
  --floor-a: #2B2314; --floor-b: #241E10;
553
+ --sky: #16232B; --hedge: #2E4A33;
554
+ --stone: #3A3834; --stone-b: rgba(255, 255, 255, .05);
555
+ --stone-floor-a: #17150F; --stone-floor-b: #100E0A;
409
556
  }
410
557
  :root[data-theme="light"] {
411
558
  --parchment: #ECE1C8; --parchment-strong: #E1D2A6; --gilt: #93701F;
412
559
  --baize: #4A6B52; --baize-line: #33503C; --board-path: #E7DAB8;
413
560
  --wall: #E9DDBE; --wall-stripe: rgba(147, 112, 31, .10);
414
561
  --floor-a: #D9C398; --floor-b: #CFB884;
562
+ --sky: #BFE0EE; --hedge: #3F6B45;
563
+ --stone: #6E6A63; --stone-b: rgba(110, 106, 99, .18);
564
+ --stone-floor-a: #504C46; --stone-floor-b: #464340;
415
565
  }
416
566
 
417
567
  html { background: var(--bg); }
@@ -427,14 +577,23 @@ ${THEME_TOKENS_CSS}
427
577
  .mode-toggle:hover:not(:disabled) { background: var(--parchment-strong); }
428
578
  .mode-toggle:disabled { opacity: .5; cursor: default; }
429
579
 
430
- .stage { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1rem; align-items: start; }
580
+ /* the room-view + map column runs roughly two-thirds width, the manor's
581
+ own account (chat log + pills + caption) a third. */
582
+ .stage { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 1rem; align-items: start; }
431
583
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
432
584
 
433
- /* the left column: quest and satchel as full-width strips above the room,
434
- the room itself, then the reset/play/step/turn strip pinned directly
435
- below it everything in this column shares the room's own width, so
436
- the column's total height tracks the side column instead of leaving a
437
- gap under a lone, short room panel. */
585
+ /* the room's own opening prose, relocated (operator feedback) out of a
586
+ bottom-of-page strip and into a spider-fly-style note directly under the
587
+ titlebarthe same "sight-read the world before you touch anything"
588
+ framing spider-fly.html's own header paragraph already gives its board. */
589
+ .page-note { background: var(--parchment); border-left: 3px solid var(--gilt); padding: .6rem .85rem; font-size: .9rem; font-style: italic; margin: 0 0 1rem; }
590
+ .page-note:empty { display: none; margin: 0; }
591
+
592
+ /* the left column: quest, the room itself, the reset/play/step/turn strip,
593
+ the goal/status lines, satchel, the command box and the manor map, all
594
+ as full-width strips sharing the room's own width — the column's total
595
+ height tracks the side column instead of leaving a gap under a lone,
596
+ short room panel. */
438
597
  .stage-left { display: flex; flex-direction: column; gap: 1rem; min-width: 0; }
439
598
 
440
599
  /* the room view — a 90s-RPG interior cutaway, all CSS gradients, no
@@ -456,6 +615,24 @@ ${THEME_TOKENS_CSS}
456
615
  .room-frame::before, .room-frame::after { content: "\\2766"; position: absolute; font-size: 1.1rem; color: var(--gilt); opacity: .85; line-height: 1; }
457
616
  .room-frame::before { top: -.6rem; left: -.35rem; }
458
617
  .room-frame::after { bottom: -.6rem; right: -.35rem; transform: rotate(180deg); }
618
+ /* outdoor: a sky band over a hedge-green divider, no gilt — cellar and
619
+ study/library etc. stay on the indoor gradient above (the default), so
620
+ only a room actually typed outdoor-space/underground-space repaints. */
621
+ .room-frame[data-room-kind="outdoor"] {
622
+ background:
623
+ linear-gradient(var(--sky), var(--sky)) 0 0 / 100% 58% no-repeat,
624
+ linear-gradient(var(--hedge), var(--hedge)) 0 58% / 100% 3px no-repeat,
625
+ repeating-linear-gradient(78deg, var(--floor-a) 0 26px, var(--floor-b) 26px 52px);
626
+ border-color: var(--hedge);
627
+ }
628
+ /* underground: stone-grey stripes over a darker floor, no gilt rail. */
629
+ .room-frame[data-room-kind="underground"] {
630
+ background:
631
+ repeating-linear-gradient(90deg, var(--stone-b) 0 4px, transparent 4px 30px) 0 0 / 100% 58% no-repeat,
632
+ linear-gradient(var(--stone), var(--stone)) 0 0 / 100% 58% no-repeat,
633
+ repeating-linear-gradient(78deg, var(--stone-floor-a) 0 26px, var(--stone-floor-b) 26px 52px);
634
+ border-color: var(--stone);
635
+ }
459
636
  /* the brass door plaque naming the room the scene is drawing — filled
460
637
  from the same snapshot the caption already reads, never a new fact. */
461
638
  .room-plaque {
@@ -466,7 +643,35 @@ ${THEME_TOKENS_CSS}
466
643
  padding: .22rem .9rem; white-space: nowrap;
467
644
  }
468
645
  .room-plaque:empty { display: none; }
469
- .sprite-row { display: flex; flex-wrap: wrap; gap: .9rem .7rem; align-items: flex-end; min-height: 2.5rem; }
646
+ /* the room-kind icon filled through the exact same property-aware
647
+ sprite resolver the room's own object cards use, keyed on the room's own
648
+ name (so library/kitchen/garden reach their large TOMLs, everything else
649
+ falls back through room's own ancestor chain to the generic room icon). */
650
+ .room-kind-icon { position: absolute; top: .55rem; right: .6rem; width: 28px; height: 28px; opacity: .92; }
651
+ .room-kind-icon svg { width: 100%; height: 100%; display: block; }
652
+ .room-kind-icon:empty { display: none; }
653
+ .sprite-row { display: flex; align-items: flex-end; gap: .9rem .7rem; min-height: 2.5rem; }
654
+ /* the floor band: every non-wall-mounted stack, wrapping and pinned to the
655
+ left edge of the room, as the flat sprite row always has. */
656
+ .floor-row { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .9rem .7rem; }
657
+ /* the adventurer's own card, pinned to the right edge of the room view —
658
+ margin-left: auto inside the shared flex row pushes it there regardless
659
+ of how many floor stacks sit to its left. */
660
+ .you-slot { margin-left: auto; display: flex; align-items: flex-end; flex: none; }
661
+ /* one on-top-of chain, drawn as a column: the topmost item first, the
662
+ floor-standing base last — shrink-wrapped so the stack's own width is
663
+ whichever level is widest, never the room's full width. */
664
+ .sprite-stack { display: flex; flex-direction: column; align-items: center; gap: .3rem; width: max-content; }
665
+ /* the wall band: centered, absolutely laid over the papered-wall portion
666
+ of the room frame (roughly its top 8%-52% — the gilt dado sits at 58%),
667
+ items bottom-aligned so a hung painting's own frame reads as flush
668
+ against the dado rail beneath it. */
669
+ .wall-row {
670
+ position: absolute; left: 1.1rem; right: 1.1rem; top: 8%; height: 44%;
671
+ display: flex; justify-content: center; align-items: flex-end; gap: .9rem .7rem;
672
+ flex-wrap: wrap; pointer-events: none;
673
+ }
674
+ .wall-row:empty { display: none; }
470
675
 
471
676
  /* the class-badge system — this design's signature element: every sprite
472
677
  gets a small ringed portrait frame plus a genre-flavored class word
@@ -587,12 +792,14 @@ ${THEME_TOKENS_CSS}
587
792
  .edit-status.pending { color: var(--entail); }
588
793
  .edit-status.ok { color: var(--taught); }
589
794
  .roomdetail .sprite-row { min-height: 3.2rem; }
795
+ .roomdetail[data-room-kind="outdoor"] { border-top-color: var(--hedge); }
796
+ .roomdetail[data-room-kind="underground"] { border-top-color: var(--stone); }
590
797
  #legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
591
798
 
592
799
  body.preview .side, body.preview .stage-left > .panel, body.preview .controls-row, body.preview .status { display: none; }
593
800
  body.preview main { padding: 0; max-width: none; }
594
801
  body.preview .stage { display: block; }
595
- body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage { display: none; }
802
+ body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage, body.preview .page-note { display: none; }
596
803
  </style>
597
804
  </head>
598
805
  <body>
@@ -602,19 +809,21 @@ ${THEME_TOKENS_CSS}
602
809
  <h1>A room, drawn from exactly what the text already says is there</h1>
603
810
  <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
604
811
  </div>
812
+ <p class="page-note">${escapeHtml(worldPayload.opening)}</p>
605
813
  <div class="stage" id="playStage">
606
814
  <div class="stage-left">
607
815
  <div class="panel goals">
608
816
  <h2>quest</h2>
609
817
  <div id="goalList"></div>
610
818
  </div>
611
- <div class="panel carrying">
612
- <h2>satchel</h2>
613
- <div class="chips" id="carryList"></div>
614
- </div>
615
819
  <div class="room-frame" id="roomFrame">
616
820
  <div class="room-plaque mono" id="roomName"></div>
617
- <div class="sprite-row" id="spriteRow"></div>
821
+ <div class="room-kind-icon" id="roomKindIcon"></div>
822
+ <div class="wall-row" id="wallRow"></div>
823
+ <div class="sprite-row" id="spriteRow">
824
+ <div class="floor-row" id="floorRow"></div>
825
+ <div class="you-slot" id="youSlot"></div>
826
+ </div>
618
827
  </div>
619
828
  <div class="controls-row" id="playControls">
620
829
  <button id="resetBtn" type="button" disabled>reset</button>
@@ -622,13 +831,14 @@ ${THEME_TOKENS_CSS}
622
831
  <button id="stepBtn" type="button" disabled>step</button>
623
832
  <span class="turn mono" id="turnLabel">turn: 0</span>
624
833
  </div>
625
- </div>
626
- <aside class="side" aria-label="The adventure's log and chat">
627
- <div class="chat">
628
- <h2>the manor's own account</h2>
629
- <div class="chatlog" id="chatlog" aria-live="polite"></div>
630
- <div class="pills" id="pills"></div>
631
- <div class="caption" id="caption"></div>
834
+ <div class="goal-line" id="goalLine"></div>
835
+ <div class="status" id="status">loading the engine&hellip;</div>
836
+ <div class="panel carrying">
837
+ <h2>satchel</h2>
838
+ <div class="chips" id="carryList"></div>
839
+ </div>
840
+ <div class="panel command">
841
+ <h2>speak to the manor</h2>
632
842
  <form class="chatask" id="chatform">
633
843
  <span class="prompt mono">tmct&gt;</span>
634
844
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
@@ -638,6 +848,14 @@ ${THEME_TOKENS_CSS}
638
848
  <h2>the manor, so far</h2>
639
849
  <div class="map-viewport"><div id="mapWrap"></div></div>
640
850
  </div>
851
+ </div>
852
+ <aside class="side" aria-label="The adventure's log and chat">
853
+ <div class="chat">
854
+ <h2>the manor's own account</h2>
855
+ <div class="chatlog" id="chatlog" aria-live="polite"></div>
856
+ <div class="pills" id="pills"></div>
857
+ <div class="caption" id="caption"></div>
858
+ </div>
641
859
  </aside>
642
860
  </div>
643
861
 
@@ -653,7 +871,7 @@ ${THEME_TOKENS_CSS}
653
871
  <h2>the whole manor</h2>
654
872
  <div class="map-viewport"><div id="editMapWrap"></div></div>
655
873
  </div>
656
- <div class="panel roomdetail">
874
+ <div class="panel roomdetail" id="roomDetailPanel">
657
875
  <h2 id="roomDetailTitle">click a room</h2>
658
876
  <div class="sprite-row" id="roomDetailSprites"></div>
659
877
  <div class="caption" id="roomDetailCaption"></div>
@@ -664,9 +882,6 @@ ${THEME_TOKENS_CSS}
664
882
  </div>
665
883
  </aside>
666
884
  </div>
667
-
668
- <div class="goal-line" id="goalLine"></div>
669
- <div class="status" id="status">loading the engine&hellip;</div>
670
885
  </main>
671
886
  <script>
672
887
  const ADVENTURE = ${pageData};
@@ -679,6 +894,9 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
679
894
  const spriteClassForObject = ${spriteClassForObject.toString()};
680
895
  const visibleRoomOf = ${visibleRoomOf.toString()};
681
896
  const roomSceneObjects = ${roomSceneObjects.toString()};
897
+ const scenePlacement = ${scenePlacement.toString()};
898
+ const roomSceneLayout = ${roomSceneLayout.toString()};
899
+ const roomKindForRoom = ${roomKindForRoom.toString()};
682
900
  const carriedItems = ${carriedItems.toString()};
683
901
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
684
902
  const allRoomIds = ${allRoomIds.toString()};
@@ -688,8 +906,12 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
688
906
  const wordBeforeCursor = ${wordBeforeCursor.toString()};
689
907
  const esc = ${escapeHtml.toString()};
690
908
  const el = (id) => document.getElementById(id);
691
- const spriteRow = el("spriteRow");
909
+ const roomFrameEl = el("roomFrame");
692
910
  const roomNameEl = el("roomName");
911
+ const roomKindIconEl = el("roomKindIcon");
912
+ const wallRowEl = el("wallRow");
913
+ const floorRowEl = el("floorRow");
914
+ const youSlotEl = el("youSlot");
693
915
  const captionEl = el("caption");
694
916
  const goalLineEl = el("goalLine");
695
917
  const statusEl = el("status");
@@ -709,6 +931,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
709
931
  const editorPillsEl = el("editorPills");
710
932
  const editorStatusEl = el("editorStatus");
711
933
  const editMapWrapEl = el("editMapWrap");
934
+ const roomDetailPanelEl = el("roomDetailPanel");
712
935
  const roomDetailTitleEl = el("roomDetailTitle");
713
936
  const roomDetailSpritesEl = el("roomDetailSprites");
714
937
  const roomDetailCaptionEl = el("roomDetailCaption");
@@ -806,6 +1029,19 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
806
1029
  + '<div class="class-badge" data-cls="' + esc(cls) + '">' + esc(badgeFor(cls)) + "</div></div>";
807
1030
  }
808
1031
 
1032
+ // A stacked item ABOVE the floor base: the frame only, no label/badge
1033
+ // block underneath — stacking full cards (each carrying its own name +
1034
+ // class badge under the icon) put that text between every pair of icons,
1035
+ // reading as a large floating gap even though the CSS gap between the
1036
+ // card boxes themselves was always the intended small one. The base
1037
+ // item (bottom of the stack, standing on the floor) keeps its full
1038
+ // spriteCardHtml identification; every item resting on something else
1039
+ // shows an aria-label in its place, so the name is still available to
1040
+ // assistive tech even though the sighted layout stays compact.
1041
+ function stackedSpriteCardHtml(label, cls, svg) {
1042
+ return '<div class="sprite-card" aria-label="' + esc(label) + '"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + "</div></div></div>";
1043
+ }
1044
+
809
1045
  function captionFor(rows, state, here) {
810
1046
  const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
811
1047
  const lines = tmctAdventure.worldDigestRows(rows, state)
@@ -936,6 +1172,15 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
936
1172
  chatqEl.value = btn.textContent;
937
1173
  chatqEl.focus();
938
1174
  });
1175
+ // A double-click runs the pill's own command immediately — the single
1176
+ // click above still only fills the input, so a visitor who meant to edit
1177
+ // it first loses nothing.
1178
+ pillsEl.addEventListener("dblclick", (e) => {
1179
+ const btn = e.target.closest(".pill");
1180
+ if (!btn || !chatqEl || !chatformEl) return;
1181
+ chatqEl.value = btn.textContent;
1182
+ chatformEl.requestSubmit();
1183
+ });
939
1184
 
940
1185
  // ---- sprite resolution — property/instance-aware (sprite-templates.mjs's
941
1186
  // resolveSpriteAsset), keyed on the OBJECT'S OWN NAME (via
@@ -954,12 +1199,33 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
954
1199
  );
955
1200
  }
956
1201
 
1202
+ // ---- the room-kind icon — filled through the SAME property-aware
1203
+ // resolver the sprite cards use, keyed on the room's own name, so a room
1204
+ // with its own large-tier template (library, kitchen, garden) shows it and
1205
+ // everything else falls back through room's own ancestor chain to the
1206
+ // generic room icon.
1207
+ function roomKindIconSvg(rows, here) {
1208
+ return tmctAdventure.resolveSpriteAsset(
1209
+ here, spriteAncestryRows(rows, here), factsForSubject(rows, here),
1210
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "roomkind-" + here },
1211
+ );
1212
+ }
1213
+
957
1214
  function redraw(snap) {
958
1215
  lastSnapshot = snap;
959
- const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
960
- const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
961
- spriteRow.innerHTML = sprites.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
1216
+ const layout = roomSceneLayout(snap.rows, snap.state, snap.here);
1217
+ wallRowEl.innerHTML = layout.wall.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
1218
+ floorRowEl.innerHTML = layout.floor.map((stack) => {
1219
+ const baseIndex = stack.items.length - 1;
1220
+ return '<div class="sprite-stack">' + stack.items.map((s, i) => {
1221
+ const svg = resolveObjectSprite(snap.rows, s);
1222
+ return i === baseIndex ? spriteCardHtml(s.subject, s.spriteClass, svg) : stackedSpriteCardHtml(s.subject, s.spriteClass, svg);
1223
+ }).join("") + "</div>";
1224
+ }).join("");
1225
+ youSlotEl.innerHTML = spriteCardHtml("you", "adventurer", resolveObjectSprite(snap.rows, { subject: "you", spriteClass: "adventurer" }));
962
1226
  roomNameEl.textContent = "the " + snap.here;
1227
+ roomFrameEl.setAttribute("data-room-kind", roomKindForRoom(snap.rows, snap.here));
1228
+ roomKindIconEl.innerHTML = roomKindIconSvg(snap.rows, snap.here);
963
1229
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
964
1230
  turnLabelEl.textContent = "turn: " + snap.turn;
965
1231
  renderPills(snap.rows, snap.state, snap.here);
@@ -1007,11 +1273,13 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1007
1273
 
1008
1274
  function renderRoomDetail() {
1009
1275
  if (!selectedRoomId) {
1276
+ roomDetailPanelEl.setAttribute("data-room-kind", "");
1010
1277
  roomDetailTitleEl.textContent = "click a room";
1011
1278
  roomDetailSpritesEl.innerHTML = "";
1012
1279
  roomDetailCaptionEl.textContent = "";
1013
1280
  return;
1014
1281
  }
1282
+ roomDetailPanelEl.setAttribute("data-room-kind", roomKindForRoom(editRows, selectedRoomId));
1015
1283
  roomDetailTitleEl.textContent = selectedRoomId;
1016
1284
  const objects = roomSceneObjects(editRows, editState, selectedRoomId);
1017
1285
  roomDetailSpritesEl.innerHTML = objects.length
@@ -1150,7 +1418,7 @@ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `
1150
1418
  redraw(snap);
1151
1419
  goalLineEl.textContent = "";
1152
1420
  chatlogEl.innerHTML = "";
1153
- statusEl.textContent = ADVENTURE.world.opening || "";
1421
+ statusEl.textContent = "";
1154
1422
  addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
1155
1423
  if (saved && snap.turn > 0) {
1156
1424
  addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");