@polycode-projects/the-mechanical-code-talker 2.8.1 → 2.8.4

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.
@@ -61,10 +61,27 @@
61
61
  // `visitedRoomIds`, the manual+auto-play exposure set
62
62
  // adventure-browser-entry.mjs threads forward (see that module's header for
63
63
  // why the two paths share one set rather than two).
64
+ //
65
+ // Edit mode (PLAN_GAMES_UPLIFT_V3.md Part C.4 item 4's operator addendum)
66
+ // adds: `allRoomIds` (every room the world defines, feeding the SAME
67
+ // `visitedRoomGraph` layout play mode's visited-only map already uses, just
68
+ // with the visitation filter dropped — a parameter, not a second map
69
+ // implementation), `suggestionsForTerm` (cursor-driven pills, calling
70
+ // adventure-editor.mjs's `wordBeforeCursor` plus skos-view.mjs's
71
+ // `relatedForTerm` and sprite-map.mjs's `classAncestorChain` — NOT spliced,
72
+ // for the same reason roomCaptionText/pillsForRoom aren't, see below), and
73
+ // `renderWorldEditorText`/`wordBeforeCursor` (adventure-editor.mjs's own
74
+ // splice-safe render/cursor helpers, spliced in directly). The store writes
75
+ // an edit implies run through the browser bundle's own `session.applyEdit`
76
+ // (adventure-browser-entry.mjs), never here — this module only renders and
77
+ // reads.
64
78
  import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
65
79
  import { createTicker } from "./viz-ticker.mjs";
66
80
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
67
81
  import { exposedFacts } from "./adventure-autoplay.mjs";
82
+ import { relatedForTerm } from "../domain/skos-view.mjs";
83
+ import { classAncestorChain } from "../domain/sprite-map.mjs";
84
+ import { renderWorldEditorText, wordBeforeCursor } from "./adventure-editor.mjs";
68
85
 
69
86
  const DEFAULT_TITLE = "tmct — the adventure";
70
87
  const PREVIEW_MAX_TICKS = 30;
@@ -220,6 +237,19 @@ export function visitedRoomGraph(state, visitedRoomIds) {
220
237
  return { nodes, edges, hints };
221
238
  }
222
239
 
240
+ /** Every room the world DEFINES at all (every subject the fact rows type as
241
+ * `room`), regardless of whether the current session has ever visited it —
242
+ * the edit mode's own "whole map" is exactly `visitedRoomGraph(state,
243
+ * allRoomIds(rows))`: the SAME directional-grid layout play mode's visited-
244
+ * only map uses, just fed every room instead of the exposure-filtered set,
245
+ * so a room with no visited neighbour still lays out (as a disconnected
246
+ * block) rather than vanishing. Pure. */
247
+ export function allRoomIds(rows) {
248
+ return [...new Set((rows || [])
249
+ .filter((r) => r.predicate === "rdf:type" && r.object === "room")
250
+ .map((r) => r.subject))];
251
+ }
252
+
223
253
  /** One status line per exposed `mgx:is-objective` fact, using the SAME
224
254
  * three-state logic adventure-autoplay.mjs's own goal inference already
225
255
  * distinguishes, restricted to what `visitedRoomIds` actually supports:
@@ -260,6 +290,33 @@ export function pillsForRoom(rows, state, here) {
260
290
  return roomAffordances(rows, state, here);
261
291
  }
262
292
 
293
+ /** Edit mode's cursor-driven suggestion pills for one typed `term`: the
294
+ * lateral SKOS neighbourhood (`relatedForTerm`'s own synonyms/related
295
+ * concepts) plus the vertical rdfs:subClassOf ancestor chain
296
+ * (`classAncestorChain`, ancestors only — the term itself is dropped),
297
+ * deduplicated and capped so a well-connected term never floods the pill
298
+ * row. `[]` on an honest miss (neither lookup returns anything) — never a
299
+ * fabricated suggestion. NOT .toString()-splice-safe (it calls two other
300
+ * modules' exports) — the in-page script mirrors this same combination
301
+ * against the browser bundle's own `tmctAdventure.relatedForTerm`/
302
+ * `tmctAdventure.classAncestorChain`, the same reach-through-the-global
303
+ * pattern `pillsFor`/`captionFor` already use for their own adventure.mjs
304
+ * calls (see this module's header). */
305
+ export function suggestionsForTerm(rows, term) {
306
+ const t = String(term || "").trim().toLowerCase();
307
+ if (!t) return [];
308
+ const out = [];
309
+ const seen = new Set([t]);
310
+ const push = (label) => { if (label && !seen.has(label)) { seen.add(label); out.push(label); } };
311
+ const related = relatedForTerm(rows, t);
312
+ if (related) {
313
+ for (const syn of related.synonyms) push(syn);
314
+ for (const rel of related.related) push(rel.prefLabel);
315
+ }
316
+ for (const ancestor of classAncestorChain(t, rows).slice(1)) push(ancestor);
317
+ return out.slice(0, 8);
318
+ }
319
+
263
320
  /** A short caption for `here`, built ONLY from the rows worldDigestRows
264
321
  * itself already produces (the exact same view the chat reply's own digest
265
322
  * reads) — every row already reads as a plain sentence
@@ -310,45 +367,104 @@ export function renderAdventureHtml({
310
367
  <title>${escapeHtml(title)}</title>
311
368
  <style>
312
369
  ${THEME_TOKENS_CSS}
370
+ /* RPG chrome tokens — layered over the shared neutrals/accents above,
371
+ never replacing them: taught/corpus/entail/alert stay the SAME
372
+ provenance palette every other tmct viz page uses, repurposed here as
373
+ the class-badge ring colors (hero/townsfolk/fixture/item) — a visitor
374
+ who already knows what green/blue/gold/red mean from ledger.html reads
375
+ this page's "class" system for free. */
376
+ :root { --parchment: #ECE1C8; --parchment-strong: #E1D2A6; --gilt: #93701F; }
377
+ @media (prefers-color-scheme: dark) { :root { --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054; } }
378
+ :root[data-theme="dark"] { --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054; }
379
+ :root[data-theme="light"] { --parchment: #ECE1C8; --parchment-strong: #E1D2A6; --gilt: #93701F; }
380
+
313
381
  html { background: var(--bg); }
314
382
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
315
383
  .mono { font-family: ${MONO_STACK}; }
316
- main { max-width: 860px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
384
+ main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
317
385
  .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
318
- h1 { font-size: 1.4rem; margin: .3rem 0 .9rem; text-wrap: balance; }
386
+ .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
387
+ h1 { font-size: 1.4rem; margin: 0; text-wrap: balance; }
319
388
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
320
389
  button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
321
- .stage { display: grid; grid-template-columns: minmax(0, 1fr) 260px; gap: 1rem; align-items: start; }
390
+ .mode-toggle { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .04em; text-transform: uppercase; padding: .4rem .8rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); white-space: nowrap; }
391
+ .mode-toggle:hover:not(:disabled) { background: var(--parchment-strong); }
392
+ .mode-toggle:disabled { opacity: .5; cursor: default; }
393
+
394
+ .stage { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1rem; align-items: start; }
322
395
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
323
- .room-frame { position: relative; min-height: 210px; background: var(--taught-soft); border: 1px solid var(--line); padding: 1rem; display: flex; flex-direction: column; gap: .8rem; justify-content: flex-end; }
324
- .sprite-row { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end; }
325
- .sprite { width: 44px; height: 44px; }
396
+
397
+ /* the portrait frame — an ornate double-rule border with two small corner
398
+ fleurons (real glyphs, no external asset), sized to its OWN content
399
+ (the sprite row) rather than a fixed tall box, per operator feedback on
400
+ an earlier, mostly-empty version of this panel. */
401
+ .room-frame { position: relative; background: var(--parchment); border: 1px solid var(--gilt); outline: 1px solid var(--line); outline-offset: 4px; padding: 1.1rem; display: flex; flex-direction: column; gap: .6rem; }
402
+ .room-frame::before, .room-frame::after { content: "\\2766"; position: absolute; font-size: 1.1rem; color: var(--gilt); opacity: .85; line-height: 1; }
403
+ .room-frame::before { top: -.6rem; left: -.35rem; }
404
+ .room-frame::after { bottom: -.6rem; right: -.35rem; transform: rotate(180deg); }
405
+ .sprite-row { display: flex; flex-wrap: wrap; gap: .9rem .7rem; align-items: flex-start; min-height: 2.5rem; }
406
+
407
+ /* the class-badge system — this design's signature element: every sprite
408
+ gets a small ringed portrait frame plus a genre-flavored class word
409
+ (hero/townsfolk/fixture/item), colored through the SAME data-cls tokens
410
+ the rest of the site already uses for provenance. The sprite LABEL
411
+ underneath always names the real, specific thing (the cabinet, the
412
+ butler) — chrome around honest content, never instead of it. */
413
+ .sprite-card { display: flex; flex-direction: column; align-items: center; width: 62px; }
414
+ .sprite-frame { width: 52px; height: 52px; border-radius: 50%; background: var(--card); border: 2px solid var(--line); display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 7px; }
415
+ .sprite-frame[data-cls="adventurer"] { border-color: var(--taught); }
416
+ .sprite-frame[data-cls="person"] { border-color: var(--corpus); }
417
+ .sprite-frame[data-cls="container"], .sprite-frame[data-cls="furniture"] { border-color: var(--entail); }
418
+ .sprite-frame[data-cls="portable"] { border-color: var(--alert); }
419
+ .sprite-frame[data-cls="room"] { border-color: var(--muted); }
420
+ .sprite { width: 100%; height: 100%; }
326
421
  .sprite svg { width: 100%; height: 100%; display: block; }
327
422
  .sprite[data-cls="adventurer"] { color: var(--taught); }
328
423
  .sprite[data-cls="person"] { color: var(--corpus); }
329
424
  .sprite[data-cls="container"], .sprite[data-cls="furniture"] { color: var(--entail); }
330
425
  .sprite[data-cls="portable"] { color: var(--alert); }
331
426
  .sprite[data-cls="room"] { color: var(--muted); }
332
- .sprite-label { font-family: ${MONO_STACK}; font-size: .62rem; text-align: center; color: var(--muted); margin-top: .15rem; }
333
- .caption { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; font-size: .9rem; }
427
+ .sprite-label { font-size: .74rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.15; }
428
+ .class-badge { font-family: ${MONO_STACK}; font-size: .55rem; letter-spacing: .06em; text-transform: uppercase; margin-top: .15rem; padding: .04rem .4rem; border-radius: 999px; border: 1px solid var(--muted); color: var(--muted); }
429
+ .class-badge[data-cls="adventurer"] { border-color: var(--taught); color: var(--taught); }
430
+ .class-badge[data-cls="person"] { border-color: var(--corpus); color: var(--corpus); }
431
+ .class-badge[data-cls="container"], .class-badge[data-cls="furniture"] { border-color: var(--entail); color: var(--entail); }
432
+ .class-badge[data-cls="portable"] { border-color: var(--alert); color: var(--alert); }
433
+
334
434
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
335
- .chat, .panel { background: var(--card); border: 1px solid var(--line); padding: .6rem .75rem; }
336
- .chat h2, .panel h2 { font-family: ${MONO_STACK}; font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); font-weight: 400; margin: 0 0 .5rem; }
435
+ .chat, .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--gilt); padding: .65rem .75rem; }
436
+ .chat h2, .panel h2 { font-family: ${SERIF_STACK}; font-variant: small-caps; font-size: .8rem; letter-spacing: .06em; color: var(--gilt); font-weight: 600; margin: 0 0 .5rem; padding-bottom: .3rem; border-bottom: 1px solid var(--line); }
337
437
  .empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
338
438
  .chips { display: flex; flex-wrap: wrap; gap: .35rem; }
339
439
  .chip { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 999px; }
340
- .roommap svg { width: 100%; height: auto; display: block; }
440
+
441
+ /* the manor map — a FIXED-size viewport regardless of room count or
442
+ layout shape (an operator report: an earlier version's container grew
443
+ and shrank with the graph, shoving the panels below it around as the
444
+ game progressed). The svg scales to fit via preserveAspectRatio, same
445
+ graph, same nodes, just letterboxed into a stable box. */
446
+ /* the wrapper div gets an explicit height too, not just .map-viewport
447
+ itself — an svg's own height:100% resolves against ITS PARENT's
448
+ height, and a percentage against an auto-height parent computes to
449
+ "auto" per spec, silently undoing the fixed-viewport intent below. */
450
+ .map-viewport { height: 190px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
451
+ .map-viewport > div { width: 100%; height: 100%; }
452
+ .map-viewport svg { width: 100%; height: 100%; display: block; }
341
453
  .roommap .room-edge { stroke: var(--line); stroke-width: 1.5; }
342
454
  .roommap .room-hint { fill: var(--muted); opacity: .45; }
343
455
  .roommap .room-node circle { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
344
456
  .roommap .room-node.current circle { stroke: var(--taught); fill: var(--taught-soft); stroke-width: 2; }
345
457
  .roommap .room-node text { font-family: ${MONO_STACK}; font-size: 6.5px; fill: var(--ink); text-anchor: middle; }
458
+ .roommap .room-node.clickable { cursor: pointer; }
459
+ .roommap .room-node.clickable:hover circle { stroke: var(--gilt); stroke-width: 2.5; }
460
+ .roommap .room-node.selected circle { stroke: var(--alert); stroke-width: 2.5; }
461
+
346
462
  .goal-status { display: flex; flex-direction: column; gap: .4rem; }
347
463
  .goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
348
464
  .goal-status .dot { width: .5rem; height: .5rem; border-radius: 50%; flex: none; background: var(--muted); }
349
465
  .goal-status .g.known .dot { background: var(--corpus); }
350
466
  .goal-status .g.carried .dot { background: var(--taught); }
351
- .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 320px; overflow-y: auto; margin-bottom: .5rem; }
467
+ .chatlog { display: flex; flex-direction: column; gap: .4rem; max-height: 260px; overflow-y: auto; margin-bottom: .5rem; }
352
468
  .chatlog .u { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); }
353
469
  .chatlog .u::before { content: "tmct> "; color: var(--taught); }
354
470
  .chatlog .a { font-size: .88rem; line-height: 1.4; white-space: pre-wrap; }
@@ -358,6 +474,11 @@ ${THEME_TOKENS_CSS}
358
474
  .pills:empty { display: none; margin-bottom: 0; }
359
475
  .pill { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 999px; }
360
476
  .pill:hover { border-color: var(--taught); }
477
+ /* the room's own prose, relocated (operator feedback) out of a bottom-of-
478
+ page strip and into the flow between the history/pills and the input —
479
+ a quoted manuscript line reporting what "look" would say right now. */
480
+ .caption { background: var(--parchment); border-left: 3px solid var(--gilt); padding: .55rem .7rem; font-size: .86rem; font-style: italic; margin: 0 0 .5rem; }
481
+ .caption:empty { display: none; margin: 0; }
361
482
  .chatask { display: flex; align-items: center; gap: .5rem; border-top: 1px solid var(--line); padding-top: .5rem; }
362
483
  .chatask .prompt { color: var(--taught); font-size: .78rem; font-family: ${MONO_STACK}; }
363
484
  .chatask input { flex: 1; font-family: ${MONO_STACK}; font-size: .78rem; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .32rem .55rem; min-width: 0; }
@@ -369,47 +490,87 @@ ${THEME_TOKENS_CSS}
369
490
  .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
370
491
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
371
492
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
493
+
494
+ /* edit mode */
495
+ body:not(.editing) #editStage { display: none; }
496
+ body.editing #playStage, body.editing #playControls { display: none; }
497
+ .editor-stage { margin-top: .5rem; }
498
+ .edittext textarea { width: 100%; box-sizing: border-box; min-height: 380px; font-family: ${MONO_STACK}; font-size: .82rem; line-height: 1.55; background: var(--bg); color: var(--ink); border: 1px solid var(--line); padding: .6rem; resize: vertical; }
499
+ .edit-status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .4rem; min-height: 1.1em; }
500
+ .edit-status.pending { color: var(--entail); }
501
+ .edit-status.ok { color: var(--taught); }
502
+ .roomdetail .sprite-row { min-height: 3.2rem; }
503
+ #legendList { display: flex; flex-wrap: wrap; gap: .5rem .3rem; }
504
+
372
505
  body.preview .side, body.preview .controls-row, body.preview .status { display: none; }
373
506
  body.preview main { padding: 0; max-width: none; }
374
507
  body.preview .stage { display: block; }
375
- body.preview .eyebrow, body.preview h1 { display: none; }
508
+ body.preview .eyebrow, body.preview h1, body.preview .mode-toggle, body.preview #editStage { display: none; }
376
509
  </style>
377
510
  </head>
378
511
  <body>
379
512
  <main>
380
513
  <div class="eyebrow">tmct &middot; the adventure</div>
381
- <h1>A room, drawn from exactly what the text already says is there</h1>
382
- <div class="stage">
514
+ <div class="titlebar">
515
+ <h1>A room, drawn from exactly what the text already says is there</h1>
516
+ <button id="editModeBtn" type="button" class="mode-toggle" disabled>edit the world</button>
517
+ </div>
518
+ <div class="stage" id="playStage">
383
519
  <div class="room-frame" id="roomFrame">
384
520
  <div class="sprite-row" id="spriteRow"></div>
385
521
  </div>
386
522
  <aside class="side" aria-label="The adventure's log and chat">
387
523
  <div class="chat">
388
- <h2>what's happened, and what you can do</h2>
524
+ <h2>the manor's own account</h2>
389
525
  <div class="chatlog" id="chatlog" aria-live="polite"></div>
390
526
  <div class="pills" id="pills"></div>
527
+ <div class="caption" id="caption"></div>
391
528
  <form class="chatask" id="chatform">
392
529
  <span class="prompt mono">tmct&gt;</span>
393
530
  <input id="chatq" type="text" placeholder="go north" aria-label="Type a command, or ask a question" disabled>
394
531
  </form>
395
532
  </div>
396
533
  <div class="panel carrying">
397
- <h2>carrying</h2>
534
+ <h2>satchel</h2>
398
535
  <div class="chips" id="carryList"></div>
399
536
  </div>
400
537
  <div class="panel roommap">
401
- <h2>rooms visited</h2>
402
- <div id="mapWrap"></div>
538
+ <h2>the manor, so far</h2>
539
+ <div class="map-viewport"><div id="mapWrap"></div></div>
403
540
  </div>
404
541
  <div class="panel goals">
405
- <h2>goal</h2>
542
+ <h2>quest</h2>
406
543
  <div id="goalList"></div>
407
544
  </div>
408
545
  </aside>
409
546
  </div>
410
- <div class="caption" id="caption"></div>
547
+
548
+ <div class="stage editor-stage" id="editStage" aria-label="The world editor">
549
+ <div class="panel edittext">
550
+ <h2>the world, in plain sentences</h2>
551
+ <textarea id="editorText" spellcheck="false" aria-label="The world's own facts as plain sentences, one per line — edit one and it flows back into the running world"></textarea>
552
+ <div class="pills" id="editorPills" aria-label="Ontologically related terms for the word before the cursor"></div>
553
+ <div class="edit-status" id="editorStatus"></div>
554
+ </div>
555
+ <aside class="side" aria-label="The whole manor, a clicked room, and the legend">
556
+ <div class="panel roommap">
557
+ <h2>the whole manor</h2>
558
+ <div class="map-viewport"><div id="editMapWrap"></div></div>
559
+ </div>
560
+ <div class="panel roomdetail">
561
+ <h2 id="roomDetailTitle">click a room</h2>
562
+ <div class="sprite-row" id="roomDetailSprites"></div>
563
+ <div class="caption" id="roomDetailCaption"></div>
564
+ </div>
565
+ <div class="panel legend">
566
+ <h2>legend</h2>
567
+ <div id="legendList"></div>
568
+ </div>
569
+ </aside>
570
+ </div>
571
+
411
572
  <div class="goal-line" id="goalLine"></div>
412
- <div class="controls-row">
573
+ <div class="controls-row" id="playControls">
413
574
  <button id="resetBtn" type="button" disabled>reset</button>
414
575
  <button id="playBtn" type="button" disabled>&#9654; play</button>
415
576
  <button id="stepBtn" type="button" disabled>step</button>
@@ -430,8 +591,11 @@ const ADVENTURE = ${pageData};
430
591
  const roomSceneObjects = ${roomSceneObjects.toString()};
431
592
  const carriedItems = ${carriedItems.toString()};
432
593
  const visitedRoomGraph = ${visitedRoomGraph.toString()};
594
+ const allRoomIds = ${allRoomIds.toString()};
433
595
  const spriteAncestryRows = ${spriteAncestryRows.toString()};
434
596
  const factsForSubject = ${factsForSubject.toString()};
597
+ const renderWorldEditorText = ${renderWorldEditorText.toString()};
598
+ const wordBeforeCursor = ${wordBeforeCursor.toString()};
435
599
  const esc = ${escapeHtml.toString()};
436
600
  const el = (id) => document.getElementById(id);
437
601
  const spriteRow = el("spriteRow");
@@ -449,6 +613,15 @@ const ADVENTURE = ${pageData};
449
613
  const carryListEl = el("carryList");
450
614
  const mapWrapEl = el("mapWrap");
451
615
  const goalListEl = el("goalList");
616
+ const editModeBtn = el("editModeBtn");
617
+ const editorTextEl = el("editorText");
618
+ const editorPillsEl = el("editorPills");
619
+ const editorStatusEl = el("editorStatus");
620
+ const editMapWrapEl = el("editMapWrap");
621
+ const roomDetailTitleEl = el("roomDetailTitle");
622
+ const roomDetailSpritesEl = el("roomDetailSprites");
623
+ const roomDetailCaptionEl = el("roomDetailCaption");
624
+ const legendListEl = el("legendList");
452
625
 
453
626
  const params = new URLSearchParams(location.search);
454
627
  const preview = params.get("preview") === "1";
@@ -456,6 +629,51 @@ const ADVENTURE = ${pageData};
456
629
 
457
630
  let session = null;
458
631
  let lastTicks = 0;
632
+ let lastSnapshot = null;
633
+ let selectedRoomId = null;
634
+ let editRows = [];
635
+ let editState = { placements: new Map(), openness: new Map(), exits: new Map() };
636
+ let allStoreRows = [];
637
+
638
+ // ---- serialize every engine-touching call: the ticker, the chat dock and
639
+ // the editor sync all share one in-memory store, and any overlapping pair
640
+ // could race against the same write.
641
+ let lock = Promise.resolve();
642
+ function withLock(fn) {
643
+ const run = lock.then(fn, fn);
644
+ lock = run.catch(() => {});
645
+ return run;
646
+ }
647
+
648
+ // ---- large-sprite-tier wiring — fetches the gradient-shaded 400px tier
649
+ // (public/sprites-pack/manifest.json, built by scripts/build-demo-sprites-
650
+ // pack.mjs from data/sprites-large/*.toml) at page load, resolved through
651
+ // the exact same sprite-templates.mjs resolver the build-time icon tier
652
+ // already used. A failed fetch (offline preview, a stripped static host)
653
+ // just leaves the build-time icon tier (ADVENTURE.spriteTemplates) as the
654
+ // working fallback — this page is never blank because one asset had a
655
+ // hiccup, the same posture this project already takes elsewhere.
656
+ let activeSpriteTemplates = ADVENTURE.spriteTemplates;
657
+ fetch("./sprites-pack/manifest.json").then((r) => (r && r.ok ? r.json() : null)).then((data) => {
658
+ if (!data || !Array.isArray(data.templates) || !data.templates.length) return;
659
+ activeSpriteTemplates = data.templates;
660
+ if (lastSnapshot) redraw(lastSnapshot);
661
+ if (document.body.classList.contains("editing")) refreshEditPanels();
662
+ }).catch(() => { /* ADVENTURE.spriteTemplates already covers this page */ });
663
+
664
+ // ---- the class-badge system — this redesign's signature element: every
665
+ // sprite gets a small genre-flavored class word underneath its own real
666
+ // name, colored through the same data-cls tokens the rest of the site
667
+ // already uses for provenance (see the CSS block's own comment). Chrome
668
+ // wraps the content; the content itself — the sprite's real subject name,
669
+ // the caption text — is never replaced by it.
670
+ const CLASS_BADGE = { adventurer: "hero", person: "townsfolk", container: "fixture", furniture: "fixture", portable: "item", room: "room" };
671
+ const badgeFor = (cls) => CLASS_BADGE[cls] || cls;
672
+ function spriteCardHtml(label, cls, svg) {
673
+ return '<div class="sprite-card"><div class="sprite-frame" data-cls="' + esc(cls) + '"><div class="sprite" data-cls="' + esc(cls) + '">' + svg + '</div></div>'
674
+ + '<div class="sprite-label">' + esc(label) + '</div>'
675
+ + '<div class="class-badge" data-cls="' + esc(cls) + '">' + esc(badgeFor(cls)) + "</div></div>";
676
+ }
459
677
 
460
678
  function captionFor(rows, state, here) {
461
679
  const hereCased = here.charAt(0).toUpperCase() + here.slice(1);
@@ -494,13 +712,14 @@ const ADVENTURE = ${pageData};
494
712
  : '<span class="empty-note">nothing yet</span>';
495
713
  }
496
714
 
497
- // ---- visited-room map — an SVG laid out directly from visitedRoomGraph's
498
- // own directional grid, never a second layout. Edges only ever join two
499
- // ALREADY-visited rooms; hints mark a known exit's direction from a
500
- // visited room without naming or drawing the unvisited room itself.
501
- function renderRoomMap(rows, state, visitedRoomIds) {
502
- const graph = visitedRoomGraph(state, visitedRoomIds);
503
- if (!graph.nodes.length) { mapWrapEl.innerHTML = '<span class="empty-note">nowhere yet</span>'; return; }
715
+ // ---- room map — ONE svg-building routine shared by play mode's visited-
716
+ // only map and edit mode's whole-map (visitedRoomGraph fed allRoomIds
717
+ // instead of the exposure set — a parameter, not a second layout), so the
718
+ // fixed-size-viewport CSS treatment and the node layout can never drift
719
+ // between the two. clickable adds a data-room attribute and a pointer
720
+ // cursor per node; play mode's own map stays purely informational.
721
+ function roomMapSvg(graph, clickable) {
722
+ if (!graph.nodes.length) return null;
504
723
  const cell = 56, radius = 15;
505
724
  const maxX = Math.max.apply(null, graph.nodes.map((n) => n.x));
506
725
  const maxY = Math.max.apply(null, graph.nodes.map((n) => n.y));
@@ -519,13 +738,27 @@ const ADVENTURE = ${pageData};
519
738
  return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3"></circle>';
520
739
  }).join("");
521
740
  const nodesSvg = graph.nodes.map((n) => {
522
- const cls = "room-node" + (n.current ? " current" : "");
523
- return '<g class="' + cls + '"><circle cx="' + cx(n) + '" cy="' + cy(n) + '" r="' + radius + '"></circle>'
741
+ const cls = "room-node" + (n.current ? " current" : "") + (clickable ? " clickable" : "") + (clickable && n.id === selectedRoomId ? " selected" : "");
742
+ const attr = clickable ? ' data-room="' + esc(n.id) + '"' : "";
743
+ return '<g class="' + cls + '"' + attr + '><circle cx="' + cx(n) + '" cy="' + cy(n) + '" r="' + radius + '"></circle>'
524
744
  + '<text x="' + cx(n) + '" y="' + (cy(n) + radius + 9) + '">' + esc(n.id) + "</text></g>";
525
745
  }).join("");
526
- mapWrapEl.innerHTML = '<svg viewBox="0 0 ' + w + " " + h + '" role="img" aria-label="the rooms visited so far">'
746
+ return '<svg viewBox="0 0 ' + w + " " + h + '" preserveAspectRatio="xMidYMid meet" role="img" aria-label="' + (clickable ? "the whole manor \\u2014 click a room to inspect it" : "the rooms visited so far") + '">'
527
747
  + edgesSvg + hintsSvg + nodesSvg + "</svg>";
528
748
  }
749
+ function renderRoomMap(rows, state, visitedRoomIds) {
750
+ mapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, visitedRoomIds), false) || '<span class="empty-note">nowhere yet</span>';
751
+ }
752
+ function renderEditMap(rows, state) {
753
+ editMapWrapEl.innerHTML = roomMapSvg(visitedRoomGraph(state, allRoomIds(rows)), true) || '<span class="empty-note">this world defines no rooms</span>';
754
+ }
755
+ editMapWrapEl.addEventListener("click", (e) => {
756
+ const g = e.target.closest("[data-room]");
757
+ if (!g) return;
758
+ selectedRoomId = g.getAttribute("data-room");
759
+ renderEditMap(editRows, editState);
760
+ renderRoomDetail();
761
+ });
529
762
 
530
763
  // ---- goal panel — one line per exposed objective, a colored dot carrying
531
764
  // the same three-state read goalStatusLinesFor's own status field names.
@@ -572,28 +805,25 @@ const ADVENTURE = ${pageData};
572
805
  // ---- sprite resolution — property/instance-aware (sprite-templates.mjs's
573
806
  // resolveSpriteAsset), keyed on the OBJECT'S OWN NAME (via
574
807
  // spriteAncestryRows' synthetic subClassOf edge to its declared class) so
575
- // a named object (cabinet, butler) can carry its own data/sprites/*.toml
808
+ // a named object (cabinet, butler) can carry its own data/sprites*.toml
576
809
  // template while the data-cls attribute still reflects the DECLARED class
577
810
  // (container/furniture/portable/person/room) — the CSS accent-color rules
578
811
  // stay keyed on that declared class unchanged. The player's own "you" row
579
812
  // has no backing fact row, so it resolves directly by its fixed
580
813
  // "adventurer" class with no ancestry/property rows to check.
581
814
  function resolveObjectSprite(rows, s) {
582
- if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], ADVENTURE.spriteTemplates, tmctAdventure.SPRITE_REGISTRY);
815
+ if (s.subject === "you") return tmctAdventure.resolveSpriteAsset("adventurer", [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "you" });
583
816
  return tmctAdventure.resolveSpriteAsset(
584
817
  s.subject, spriteAncestryRows(rows, s.subject), factsForSubject(rows, s.subject),
585
- ADVENTURE.spriteTemplates, tmctAdventure.SPRITE_REGISTRY,
818
+ activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: s.subject },
586
819
  );
587
820
  }
588
821
 
589
822
  function redraw(snap) {
823
+ lastSnapshot = snap;
590
824
  const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
591
825
  const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
592
- spriteRow.innerHTML = sprites.map((s) => {
593
- const svg = resolveObjectSprite(snap.rows, s);
594
- return '<div><div class="sprite" data-cls="' + esc(s.spriteClass) + '">' + svg + '</div>'
595
- + '<div class="sprite-label">' + esc(s.subject) + "</div></div>";
596
- }).join("");
826
+ spriteRow.innerHTML = sprites.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
597
827
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
598
828
  turnLabelEl.textContent = "turn: " + snap.turn;
599
829
  renderPills(snap.rows, snap.state, snap.here);
@@ -602,16 +832,6 @@ const ADVENTURE = ${pageData};
602
832
  renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
603
833
  }
604
834
 
605
- // ---- serialize every engine-touching call: the ticker and the chat dock
606
- // share one in-memory store, and an overlapping tick()/turn() pair could
607
- // race against the same @turnN write.
608
- let lock = Promise.resolve();
609
- function withLock(fn) {
610
- const run = lock.then(fn, fn);
611
- lock = run.catch(() => {});
612
- return run;
613
- }
614
-
615
835
  chatformEl.addEventListener("submit", (e) => {
616
836
  e.preventDefault();
617
837
  const q = chatqEl.value.trim();
@@ -629,6 +849,157 @@ const ADVENTURE = ${pageData};
629
849
  });
630
850
  });
631
851
 
852
+ // ---- edit mode ---------------------------------------------------------
853
+ //
854
+ // The textarea is seeded from renderWorldEditorText over the world's OWN
855
+ // facts (session.applyEdit's own return already scopes writes to the
856
+ // world's provenance tag; here we scope READS the same way — the live
857
+ // store also carries the default persona's background corpus, and this
858
+ // page must never show, or risk retracting, a fact that isn't part of
859
+ // THIS world). Typing debounces two independent things at different
860
+ // paces: cursor-suggestion pills (fast, ~180ms — cheap, in-memory lookups
861
+ // per the investigation behind this feature) and the actual store sync
862
+ // (slower, ~400ms — so a mid-word keystroke state is never what gets
863
+ // read as "the user's intent"). session.applyEdit does the real parse +
864
+ // diff + write; this page only ever reads its result back.
865
+
866
+ function worldOnlyRows(rows) {
867
+ const prefix = "world:" + ADVENTURE.world.name;
868
+ return (rows || []).filter((r) => typeof r.provenance === "string" && r.provenance.indexOf(prefix) === 0);
869
+ }
870
+
871
+ function renderRoomDetail() {
872
+ if (!selectedRoomId) {
873
+ roomDetailTitleEl.textContent = "click a room";
874
+ roomDetailSpritesEl.innerHTML = "";
875
+ roomDetailCaptionEl.textContent = "";
876
+ return;
877
+ }
878
+ roomDetailTitleEl.textContent = selectedRoomId;
879
+ const objects = roomSceneObjects(editRows, editState, selectedRoomId);
880
+ roomDetailSpritesEl.innerHTML = objects.length
881
+ ? objects.map((o) => spriteCardHtml(o.subject, o.spriteClass, resolveObjectSprite(editRows, o))).join("")
882
+ : '<span class="empty-note">nothing placed here yet</span>';
883
+ roomDetailCaptionEl.textContent = captionFor(editRows, editState, selectedRoomId);
884
+ }
885
+
886
+ // ---- legend — every known object/character class this world actually
887
+ // uses, by its real icon. Scoped to worldOnlyRows so the background
888
+ // corpus's own (unrelated) rdf:type vocabulary never floods the list.
889
+ function renderLegend(rows) {
890
+ const subjects = new Set(rows.filter((r) => r.predicate === "rdf:type").map((r) => r.subject));
891
+ const classes = new Set(["adventurer"]);
892
+ subjects.forEach((s) => { if (s !== "player") classes.add(spriteClassForObject(rows, s)); });
893
+ legendListEl.innerHTML = Array.from(classes).sort().map((cls) => {
894
+ const svg = tmctAdventure.resolveSpriteAsset(cls, [], [], activeSpriteTemplates, tmctAdventure.SPRITE_REGISTRY, { instanceKey: "legend-" + cls });
895
+ return spriteCardHtml(cls, cls, svg);
896
+ }).join("");
897
+ }
898
+
899
+ function refreshEditPanels() {
900
+ renderEditMap(editRows, editState);
901
+ renderLegend(editRows);
902
+ renderRoomDetail();
903
+ }
904
+
905
+ function wordRangeBeforeCursor(text, cursorPos) {
906
+ const word = wordBeforeCursor(text, cursorPos);
907
+ return [cursorPos - word.length, cursorPos];
908
+ }
909
+
910
+ // ---- cursor-driven suggestion pills — the lateral SKOS neighbourhood
911
+ // (tmctAdventure.relatedForTerm) plus the vertical is-a ancestor chain
912
+ // (tmctAdventure.classAncestorChain), mirroring adventure-viz.mjs's own
913
+ // suggestionsForTerm against the browser bundle's global (the same
914
+ // reach-through-the-global pattern captionFor/pillsFor already use).
915
+ // Reads the FULL store (allStoreRows), not worldOnlyRows — a term's
916
+ // synonym/related-concept facts mostly live in the default background
917
+ // corpus, not in Ashcombe Hall's own vocabulary. Empty on an honest miss.
918
+ function renderSuggestionPills() {
919
+ const term = wordBeforeCursor(editorTextEl.value, editorTextEl.selectionStart);
920
+ if (!term) { editorPillsEl.innerHTML = ""; return; }
921
+ const related = tmctAdventure.relatedForTerm(allStoreRows, term);
922
+ const chain = tmctAdventure.classAncestorChain(term, allStoreRows);
923
+ const seen = new Set([term]);
924
+ const out = [];
925
+ const push = (label) => { if (label && !seen.has(label)) { seen.add(label); out.push(label); } };
926
+ if (related) { related.synonyms.forEach(push); related.related.forEach((r) => push(r.prefLabel)); }
927
+ chain.slice(1).forEach(push);
928
+ editorPillsEl.innerHTML = out.slice(0, 8)
929
+ .map((s) => '<button type="button" class="pill" data-insert="' + esc(s) + '">' + esc(s) + "</button>").join("");
930
+ }
931
+ editorPillsEl.addEventListener("click", (e) => {
932
+ const btn = e.target.closest(".pill");
933
+ if (!btn) return;
934
+ const pos = editorTextEl.selectionStart;
935
+ const range = wordRangeBeforeCursor(editorTextEl.value, pos);
936
+ const value = editorTextEl.value;
937
+ const insert = btn.getAttribute("data-insert");
938
+ editorTextEl.value = value.slice(0, range[0]) + insert + value.slice(pos);
939
+ const newPos = range[0] + insert.length;
940
+ editorTextEl.setSelectionRange(newPos, newPos);
941
+ editorTextEl.focus();
942
+ onEditorChanged();
943
+ });
944
+
945
+ async function applyEditorText() {
946
+ if (!session) return;
947
+ editorStatusEl.className = "edit-status pending";
948
+ editorStatusEl.textContent = "reading the manor\\u2019s ledger\\u2026";
949
+ const result = await session.applyEdit(editorTextEl.value);
950
+ const snap = await session.snapshot();
951
+ allStoreRows = snap.rows;
952
+ editRows = worldOnlyRows(snap.rows);
953
+ editState = tmctAdventure.foldWorldState(editRows);
954
+ if (result.unrecognized.length) {
955
+ const lines = result.unrecognized.map((u) => u.line).join(", ");
956
+ editorStatusEl.className = "edit-status pending";
957
+ editorStatusEl.textContent = result.unrecognized.length + " line" + (result.unrecognized.length === 1 ? "" : "s")
958
+ + " not understood yet (line " + lines + ") \\u2014 left untouched until fixed.";
959
+ } else {
960
+ editorStatusEl.className = "edit-status ok";
961
+ editorStatusEl.textContent = (result.added || result.removed)
962
+ ? "synced \\u2014 " + result.added + " fact(s) added, " + result.removed + " retracted."
963
+ : "synced \\u2014 no change.";
964
+ }
965
+ refreshEditPanels();
966
+ }
967
+
968
+ let suggestTimer = null;
969
+ let syncTimer = null;
970
+ function scheduleSuggestions() { clearTimeout(suggestTimer); suggestTimer = setTimeout(renderSuggestionPills, 180); }
971
+ function scheduleSync() { clearTimeout(syncTimer); syncTimer = setTimeout(() => withLock(applyEditorText), 400); }
972
+ function onEditorChanged() { scheduleSuggestions(); scheduleSync(); }
973
+ editorTextEl.addEventListener("input", onEditorChanged);
974
+ editorTextEl.addEventListener("click", scheduleSuggestions);
975
+ editorTextEl.addEventListener("keyup", (e) => {
976
+ if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].indexOf(e.key) !== -1) scheduleSuggestions();
977
+ });
978
+
979
+ async function enterEditMode() {
980
+ ticker.pause();
981
+ const snap = await session.snapshot();
982
+ allStoreRows = snap.rows;
983
+ editRows = worldOnlyRows(snap.rows);
984
+ editState = tmctAdventure.foldWorldState(editRows);
985
+ editorTextEl.value = renderWorldEditorText(editRows, editState);
986
+ editorStatusEl.className = "edit-status";
987
+ editorStatusEl.textContent = "";
988
+ editorPillsEl.innerHTML = "";
989
+ selectedRoomId = null;
990
+ document.body.classList.add("editing");
991
+ refreshEditPanels();
992
+ editModeBtn.textContent = "back to playing";
993
+ }
994
+ async function exitEditMode() {
995
+ document.body.classList.remove("editing");
996
+ editModeBtn.textContent = "edit the world";
997
+ const snap = await session.snapshot();
998
+ redraw(snap);
999
+ }
1000
+ editModeBtn.addEventListener("click", () => withLock(() =>
1001
+ (document.body.classList.contains("editing") ? exitEditMode() : enterEditMode())));
1002
+
632
1003
  async function boot() {
633
1004
  session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
634
1005
  lastTicks = 0;
@@ -639,7 +1010,7 @@ const ADVENTURE = ${pageData};
639
1010
  statusEl.textContent = ADVENTURE.world.opening || "";
640
1011
  addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
641
1012
  chatqEl.disabled = false;
642
- resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false;
1013
+ resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
643
1014
  }
644
1015
 
645
1016
  const ticker = createTicker({