@polycode-projects/the-mechanical-code-talker 2.9.0 → 2.9.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.
Files changed (37) hide show
  1. package/README.md +50 -28
  2. package/bin/tmct.mjs +176 -31
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/sprites/src/sprite-facts.jsonl +1033 -0
  5. package/corpus/worlds/manifest.json +9 -9
  6. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  7. package/corpus/worlds/shards/spider-fly.jsonl.gz +0 -0
  8. package/corpus/worlds/src/ashcombe-hall.jsonl +3 -0
  9. package/corpus/worlds/src/spider-fly.jsonl +17 -0
  10. package/package.json +37 -34
  11. package/src/adapters/corpus/wikipedia-live.mjs +145 -0
  12. package/src/adapters/memory/core.mjs +77 -12
  13. package/src/adapters/toml-config.mjs +6 -5
  14. package/src/domain/cli-verbs.mjs +4 -2
  15. package/src/domain/hanoi-lesson.mjs +10 -0
  16. package/src/domain/reference-pack.mjs +102 -0
  17. package/src/domain/spider-fly-world.mjs +54 -1
  18. package/src/domain/sprite-facts.mjs +0 -0
  19. package/src/services/adventure-viz.mjs +209 -67
  20. package/src/services/chat-page-viz.mjs +366 -37
  21. package/src/services/chat-session.mjs +38 -22
  22. package/src/services/chat.mjs +199 -20
  23. package/src/services/fold.mjs +28 -44
  24. package/src/services/import-file.mjs +7 -6
  25. package/src/services/init.mjs +10 -5
  26. package/src/services/ledger-viz.mjs +25 -34
  27. package/src/services/plan-viz.mjs +26 -26
  28. package/src/services/sessions.mjs +15 -3
  29. package/src/services/spider-fly-viz.mjs +51 -45
  30. package/src/services/sprite-catalog-viz.mjs +187 -3
  31. package/src/services/viz-theme.mjs +8 -0
  32. package/src/surfaces/web/adventure-browser-entry.mjs +23 -10
  33. package/src/surfaces/web/chat-browser-entry.mjs +17 -2
  34. package/src/surfaces/web/idb-persist.mjs +115 -0
  35. package/src/surfaces/web/memory-ask-browser.bundle.js +155 -25927
  36. package/src/surfaces/web/sprites-browser-entry.mjs +68 -0
  37. package/src/tools/memory-fallthrough.mjs +11 -4
@@ -75,7 +75,7 @@
75
75
  // an edit implies run through the browser bundle's own `session.applyEdit`
76
76
  // (adventure-browser-entry.mjs), never here — this module only renders and
77
77
  // reads.
78
- import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson } from "./viz-theme.mjs";
78
+ import { THEME_TOKENS_CSS, SERIF_STACK, MONO_STACK, escapeHtml, embedJson, embedScriptText } from "./viz-theme.mjs";
79
79
  import { createTicker } from "./viz-ticker.mjs";
80
80
  import { worldDigestRows, roomAffordances, foldWorldState } from "./adventure.mjs";
81
81
  import { exposedFacts } from "./adventure-autoplay.mjs";
@@ -344,19 +344,32 @@ export function roomCaptionText(rows, state, here) {
344
344
  * templates travel as JSON rather than as a bundled fs read. Defaults to
345
345
  * `[]` (every sprite falls back to the flat SPRITE_REGISTRY, unchanged from
346
346
  * before this module existed) so existing callers that don't pass one keep
347
- * working. `?preview=1` switches into the small, auto-playing, non-
348
- * interactive mode the home page's hero iframe embeds, matching
349
- * spider-fly.html's own dual-purpose file. */
347
+ * working. `largeSpriteTemplates` is the gradient-shaded 400px tier
348
+ * (data/sprites-large/*.toml, the same resolved array
349
+ * scripts/build-demo-sprites-pack.mjs writes as its whole manifest) —
350
+ * embedded at build time exactly like the icon tier, so the page makes no
351
+ * runtime fetch for it; empty, the icon tier simply stays active, the same
352
+ * fallback the old lazy fetch degraded to. `?preview=1` switches into the
353
+ * small, auto-playing, non-interactive mode the home page's hero iframe
354
+ * embeds, matching spider-fly.html's own dual-purpose file.
355
+ * `engineBundleJs` (the built adventure-browser bundle's own text) inlines
356
+ * the engine into the page instead of the sibling `<script src>`, for the
357
+ * CLI's standalone export — one downloadable file that runs from file://
358
+ * with no sibling assets. Default empty keeps the site build's sibling-file
359
+ * arrangement byte-identical. */
350
360
  export function renderAdventureHtml({
351
361
  title = DEFAULT_TITLE,
352
362
  worldPayload = { facts: [], rules: [], opening: "" },
353
363
  spriteTemplates = [],
364
+ largeSpriteTemplates = [],
365
+ engineBundleJs = "",
354
366
  } = {}) {
355
367
  const pageData = embedJson({
356
368
  world: worldPayload,
357
369
  previewMaxTicks: PREVIEW_MAX_TICKS,
358
370
  tickWaitMs: TICK_WAIT_MS,
359
371
  spriteTemplates,
372
+ largeSpriteTemplates,
360
373
  });
361
374
 
362
375
  return `<!doctype html>
@@ -367,22 +380,45 @@ export function renderAdventureHtml({
367
380
  <title>${escapeHtml(title)}</title>
368
381
  <style>
369
382
  ${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; }
383
+ /* Murder-mystery board-game chrome — layered over the shared neutrals/
384
+ accents above, never replacing them: taught/corpus/entail/alert stay the
385
+ SAME provenance palette every other tmct viz page uses, repurposed here
386
+ as the class-badge ring colors (hero/townsfolk/fixture/item) — a visitor
387
+ who already knows what green/blue/gold/red means from ledger.html reads
388
+ this page's "class" system for free. The page-local decorative tokens:
389
+ parchment/gilt (the case-file paper and brass), baize (the board-game
390
+ felt the manor map sits on), and wall/floor (the room scene's papered
391
+ wall and parquet). */
392
+ :root {
393
+ --parchment: #ECE1C8; --parchment-strong: #E1D2A6; --gilt: #93701F;
394
+ --baize: #4A6B52; --baize-line: #33503C; --board-path: #E7DAB8;
395
+ --wall: #E9DDBE; --wall-stripe: rgba(147, 112, 31, .10);
396
+ --floor-a: #D9C398; --floor-b: #CFB884;
397
+ }
398
+ @media (prefers-color-scheme: dark) { :root {
399
+ --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054;
400
+ --baize: #1E2C22; --baize-line: #4A6852; --board-path: #56604D;
401
+ --wall: #262013; --wall-stripe: rgba(192, 160, 84, .07);
402
+ --floor-a: #2B2314; --floor-b: #241E10;
403
+ } }
404
+ :root[data-theme="dark"] {
405
+ --parchment: #241E12; --parchment-strong: #2C2416; --gilt: #C0A054;
406
+ --baize: #1E2C22; --baize-line: #4A6852; --board-path: #56604D;
407
+ --wall: #262013; --wall-stripe: rgba(192, 160, 84, .07);
408
+ --floor-a: #2B2314; --floor-b: #241E10;
409
+ }
410
+ :root[data-theme="light"] {
411
+ --parchment: #ECE1C8; --parchment-strong: #E1D2A6; --gilt: #93701F;
412
+ --baize: #4A6B52; --baize-line: #33503C; --board-path: #E7DAB8;
413
+ --wall: #E9DDBE; --wall-stripe: rgba(147, 112, 31, .10);
414
+ --floor-a: #D9C398; --floor-b: #CFB884;
415
+ }
380
416
 
381
417
  html { background: var(--bg); }
382
418
  body { margin: 0; background: var(--bg); color: var(--ink); font-family: ${SERIF_STACK}; font-size: 16px; line-height: 1.5; }
383
419
  .mono { font-family: ${MONO_STACK}; }
384
420
  main { max-width: 920px; margin: 0 auto; padding: 1.4rem 1.2rem 2.2rem; }
385
- .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
421
+ .eyebrow { font-family: ${MONO_STACK}; font-size: .7rem; letter-spacing: .12em; text-transform: uppercase; color: var(--gilt); }
386
422
  .titlebar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin: .3rem 0 1rem; }
387
423
  h1 { font-size: 1.4rem; margin: 0; text-wrap: balance; }
388
424
  button { font: inherit; color: inherit; background: none; cursor: pointer; }
@@ -394,19 +430,37 @@ ${THEME_TOKENS_CSS}
394
430
  .stage { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 1rem; align-items: start; }
395
431
  @media (max-width: 760px) { .stage { grid-template-columns: 1fr; } }
396
432
 
397
- /* the portrait framean 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. Once shrunk to its own
401
- content it's shorter than the right column's own account/pills/digest/
402
- chat/satchel/map/quest stack, so it centers in the row instead of
403
- sitting top-anchored above a dead gap of raw background. */
404
- #playStage .room-frame { align-self: center; }
405
- .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; }
433
+ /* the room viewa 90s-RPG interior cutaway, all CSS gradients, no
434
+ external asset: a striped papered wall over a gilt dado rail over a
435
+ diagonal-plank floor, framed by the same ornate double rule as before.
436
+ The sprite tiles sit on the floor band (align-items flex-end), so
437
+ furniture and people read as standing IN the room, against its wall,
438
+ rather than floating in a chip strip. */
439
+ #playStage .room-frame { align-self: start; position: sticky; top: 1rem; }
440
+ .room-frame {
441
+ position: relative; min-height: 250px;
442
+ background:
443
+ repeating-linear-gradient(90deg, var(--wall-stripe) 0 4px, transparent 4px 30px) 0 0 / 100% 58% no-repeat,
444
+ linear-gradient(var(--wall), var(--wall)) 0 0 / 100% 58% no-repeat,
445
+ linear-gradient(var(--gilt), var(--gilt)) 0 58% / 100% 3px no-repeat,
446
+ repeating-linear-gradient(78deg, var(--floor-a) 0 26px, var(--floor-b) 26px 52px);
447
+ border: 1px solid var(--gilt); outline: 1px solid var(--line); outline-offset: 4px;
448
+ padding: 2.6rem 1.1rem .8rem; display: flex; flex-direction: column; justify-content: flex-end; gap: .6rem;
449
+ }
406
450
  .room-frame::before, .room-frame::after { content: "\\2766"; position: absolute; font-size: 1.1rem; color: var(--gilt); opacity: .85; line-height: 1; }
407
451
  .room-frame::before { top: -.6rem; left: -.35rem; }
408
452
  .room-frame::after { bottom: -.6rem; right: -.35rem; transform: rotate(180deg); }
409
- .sprite-row { display: flex; flex-wrap: wrap; gap: .9rem .7rem; align-items: flex-start; min-height: 2.5rem; }
453
+ /* the brass door plaque naming the room the scene is drawing filled
454
+ from the same snapshot the caption already reads, never a new fact. */
455
+ .room-plaque {
456
+ position: absolute; top: .55rem; left: 50%; transform: translateX(-50%);
457
+ font-size: .66rem; letter-spacing: .14em; text-transform: uppercase;
458
+ color: var(--gilt); background: var(--parchment); border: 1px solid var(--gilt);
459
+ box-shadow: inset 0 0 0 2px var(--parchment-strong);
460
+ padding: .22rem .9rem; white-space: nowrap;
461
+ }
462
+ .room-plaque:empty { display: none; }
463
+ .sprite-row { display: flex; flex-wrap: wrap; gap: .9rem .7rem; align-items: flex-end; min-height: 2.5rem; }
410
464
 
411
465
  /* the class-badge system — this design's signature element: every sprite
412
466
  gets a small ringed portrait frame plus a genre-flavored class word
@@ -414,8 +468,16 @@ ${THEME_TOKENS_CSS}
414
468
  the rest of the site already uses for provenance. The sprite LABEL
415
469
  underneath always names the real, specific thing (the cabinet, the
416
470
  butler) — chrome around honest content, never instead of it. */
417
- .sprite-card { display: flex; flex-direction: column; align-items: center; width: 62px; }
418
- .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; }
471
+ .sprite-card { display: flex; flex-direction: column; align-items: center; width: 70px; }
472
+ /* a squared inventory-slot tile (the 90s-RPG idiom) instead of the old
473
+ thin circular ring: a raised card with an inner mat, the class color on
474
+ the outer border, and a soft ground shadow so the tile sits ON the
475
+ floor rather than hovering over it. */
476
+ .sprite-frame {
477
+ width: 60px; height: 60px; border-radius: 5px; background: var(--card);
478
+ border: 2px solid var(--line); box-shadow: inset 0 0 0 3px var(--parchment), 0 2px 3px rgba(0, 0, 0, .22);
479
+ display: flex; align-items: center; justify-content: center; box-sizing: border-box; padding: 8px;
480
+ }
419
481
  .sprite-frame[data-cls="adventurer"] { border-color: var(--taught); }
420
482
  .sprite-frame[data-cls="person"] { border-color: var(--corpus); }
421
483
  .sprite-frame[data-cls="container"], .sprite-frame[data-cls="furniture"] { border-color: var(--entail); }
@@ -428,7 +490,9 @@ ${THEME_TOKENS_CSS}
428
490
  .sprite[data-cls="container"], .sprite[data-cls="furniture"] { color: var(--entail); }
429
491
  .sprite[data-cls="portable"] { color: var(--alert); }
430
492
  .sprite[data-cls="room"] { color: var(--muted); }
431
- .sprite-label { font-size: .74rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.15; }
493
+ /* the name plate under each tile gets its own parchment ground so it
494
+ stays legible over the parquet floor the row now sits on. */
495
+ .sprite-label { font-size: .74rem; text-align: center; color: var(--ink); margin-top: .3rem; line-height: 1.15; background: var(--parchment); border: 1px solid var(--line); border-radius: 2px; padding: .02rem .3rem; max-width: 100%; }
432
496
  .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); }
433
497
  .class-badge[data-cls="adventurer"] { border-color: var(--taught); color: var(--taught); }
434
498
  .class-badge[data-cls="person"] { border-color: var(--corpus); color: var(--corpus); }
@@ -436,11 +500,11 @@ ${THEME_TOKENS_CSS}
436
500
  .class-badge[data-cls="portable"] { border-color: var(--alert); color: var(--alert); }
437
501
 
438
502
  .side { display: flex; flex-direction: column; gap: .8rem; min-width: 0; }
439
- .chat, .panel { background: var(--card); border: 1px solid var(--line); border-top: 2px solid var(--gilt); padding: .65rem .75rem; }
503
+ .chat, .panel { background: var(--card); border: 1px solid var(--line); border-top: 5px double var(--gilt); padding: .65rem .75rem; }
440
504
  .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); }
441
505
  .empty-note { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); }
442
506
  .chips { display: flex; flex-wrap: wrap; gap: .35rem; }
443
- .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; }
507
+ .chip { font-family: ${MONO_STACK}; font-size: .72rem; padding: .25rem .6rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); border-radius: 3px; }
444
508
 
445
509
  /* the manor map — a FIXED-size viewport regardless of room count or
446
510
  layout shape (an operator report: an earlier version's container grew
@@ -451,17 +515,23 @@ ${THEME_TOKENS_CSS}
451
515
  itself — an svg's own height:100% resolves against ITS PARENT's
452
516
  height, and a percentage against an auto-height parent computes to
453
517
  "auto" per spec, silently undoing the fixed-viewport intent below. */
454
- .map-viewport { height: 190px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
518
+ /* the manor board this redesign's signature element: the visited-rooms
519
+ graph drawn as a Cluedo-style game board. Green baize under a brass
520
+ frame; each room a parchment rectangle with its name printed INSIDE;
521
+ the corridors parchment paths between rooms; the player's current room
522
+ ringed in the same taught-green every page uses for "this is live". */
523
+ .map-viewport { height: 190px; box-sizing: content-box; padding: 7px; background: var(--baize); border: 2px solid var(--gilt); box-shadow: inset 0 0 0 1px var(--baize-line); overflow: hidden; display: flex; align-items: center; justify-content: center; }
455
524
  .map-viewport > div { width: 100%; height: 100%; }
456
525
  .map-viewport svg { width: 100%; height: 100%; display: block; }
457
- .roommap .room-edge { stroke: var(--line); stroke-width: 1.5; }
458
- .roommap .room-hint { fill: var(--muted); opacity: .45; }
459
- .roommap .room-node circle { fill: var(--card); stroke: var(--line); stroke-width: 1.5; }
460
- .roommap .room-node.current circle { stroke: var(--taught); fill: var(--taught-soft); stroke-width: 2; }
461
- .roommap .room-node text { font-family: ${MONO_STACK}; font-size: 6.5px; fill: var(--ink); text-anchor: middle; }
526
+ .map-viewport .empty-note { color: var(--parchment); opacity: .8; }
527
+ .roommap .room-edge { stroke: var(--board-path); stroke-width: 7; }
528
+ .roommap .room-hint { fill: var(--board-path); opacity: .8; }
529
+ .roommap .room-node rect { fill: var(--parchment); stroke: var(--baize-line); stroke-width: 1.5; }
530
+ .roommap .room-node.current rect { stroke: var(--taught); stroke-width: 2.5; }
531
+ .roommap .room-node text { font-family: ${MONO_STACK}; font-size: 7px; fill: var(--ink); text-anchor: middle; }
462
532
  .roommap .room-node.clickable { cursor: pointer; }
463
- .roommap .room-node.clickable:hover circle { stroke: var(--gilt); stroke-width: 2.5; }
464
- .roommap .room-node.selected circle { stroke: var(--alert); stroke-width: 2.5; }
533
+ .roommap .room-node.clickable:hover rect { stroke: var(--gilt); stroke-width: 2.5; }
534
+ .roommap .room-node.selected rect { stroke: var(--alert); stroke-width: 2.5; }
465
535
 
466
536
  .goal-status { display: flex; flex-direction: column; gap: .4rem; }
467
537
  .goal-status .g { display: flex; align-items: baseline; gap: .4rem; font-size: .86rem; line-height: 1.35; }
@@ -477,7 +547,7 @@ ${THEME_TOKENS_CSS}
477
547
  .pills { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
478
548
  .pills:empty { display: none; margin-bottom: 0; }
479
549
  .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; }
480
- .pill:hover { border-color: var(--taught); }
550
+ .pill:hover { border-color: var(--gilt); background: var(--parchment); }
481
551
  /* the room's own prose, relocated (operator feedback) out of a bottom-of-
482
552
  page strip and into the flow between the history/pills and the input —
483
553
  a quoted manuscript line reporting what "look" would say right now. */
@@ -488,10 +558,10 @@ ${THEME_TOKENS_CSS}
488
558
  .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; }
489
559
  .chatask input:disabled { opacity: .5; }
490
560
  .controls-row { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; flex-wrap: wrap; }
491
- .controls-row button { font-family: ${MONO_STACK}; font-size: .78rem; padding: .3rem .7rem; border: 1px solid var(--line); background: var(--card); color: var(--ink); }
492
- .controls-row button:hover:not(:disabled) { border-color: var(--taught); }
561
+ .controls-row button { font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; padding: .38rem .85rem; border: 1px solid var(--gilt); background: var(--parchment); color: var(--ink); }
562
+ .controls-row button:hover:not(:disabled) { background: var(--parchment-strong); }
493
563
  .controls-row button:disabled { opacity: .4; cursor: default; }
494
- .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); font-variant-numeric: tabular-nums; }
564
+ .controls-row .turn { margin-left: auto; font-family: ${MONO_STACK}; font-size: .72rem; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); background: var(--parchment); border: 1px solid var(--gilt); padding: .3rem .6rem; font-variant-numeric: tabular-nums; }
495
565
  .goal-line { font-family: ${MONO_STACK}; font-size: .78rem; color: var(--muted); margin-top: .5rem; }
496
566
  .status { font-family: ${MONO_STACK}; font-size: .74rem; color: var(--muted); margin-top: .3rem; }
497
567
 
@@ -528,6 +598,7 @@ ${THEME_TOKENS_CSS}
528
598
  </div>
529
599
  <div class="stage" id="playStage">
530
600
  <div class="room-frame" id="roomFrame">
601
+ <div class="room-plaque mono" id="roomName"></div>
531
602
  <div class="sprite-row" id="spriteRow"></div>
532
603
  </div>
533
604
  <aside class="side" aria-label="The adventure's log and chat">
@@ -592,7 +663,7 @@ ${THEME_TOKENS_CSS}
592
663
  <script>
593
664
  const ADVENTURE = ${pageData};
594
665
  </script>
595
- <script src="./adventure-browser.bundle.js"></script>
666
+ ${engineBundleJs ? `<script>\n${embedScriptText(engineBundleJs)}\n</script>` : `<script src="./adventure-browser.bundle.js"></script>`}
596
667
  <script>
597
668
  (function () {
598
669
  "use strict";
@@ -610,6 +681,7 @@ const ADVENTURE = ${pageData};
610
681
  const esc = ${escapeHtml.toString()};
611
682
  const el = (id) => document.getElementById(id);
612
683
  const spriteRow = el("spriteRow");
684
+ const roomNameEl = el("roomName");
613
685
  const captionEl = el("caption");
614
686
  const goalLineEl = el("goalLine");
615
687
  const statusEl = el("status");
@@ -646,6 +718,51 @@ const ADVENTURE = ${pageData};
646
718
  let editState = { placements: new Map(), openness: new Map(), exits: new Map() };
647
719
  let allStoreRows = [];
648
720
 
721
+ // ---- persistence — the manor remembers your visit, on this device -------
722
+ // Best-effort IndexedDB (tmctAdventure.openPersistedStore): after any
723
+ // state-changing redraw, the whole Backend-B payload plus the visited-room
724
+ // exposure set snapshots under the "adventure" key, debounced. Restored on
725
+ // boot; reset clears it and starts the world over. The home page's preview
726
+ // iframe never persists — an auto-playing demo must not overwrite, or
727
+ // resurrect as, a visitor's own game.
728
+ let persist = null;
729
+ let persistSaveTimer = null;
730
+ window.tmctAdventureLastSave = null;
731
+
732
+ // The deploy's own version, read off the service worker file the build
733
+ // already stamps (its cache name embeds package.json's version) — the only
734
+ // same-origin place the number exists at runtime. Best-effort: "dev".
735
+ async function fetchSiteVersion() {
736
+ try {
737
+ const res = await fetch("./tmct-sw.js");
738
+ if (!res.ok) return "dev";
739
+ const found = /tmct-precache-v(\\d+\\.\\d+\\.\\d+)/.exec(await res.text());
740
+ return found ? found[1] : "dev";
741
+ } catch {
742
+ return "dev";
743
+ }
744
+ }
745
+
746
+ function schedulePersistSave() {
747
+ if (!persist || !session) return;
748
+ clearTimeout(persistSaveTimer);
749
+ persistSaveTimer = setTimeout(() => {
750
+ persistSaveTimer = null;
751
+ if (!session) return;
752
+ const started = performance.now();
753
+ let memoryPayload;
754
+ try {
755
+ memoryPayload = structuredClone(session.memoryDir.payload);
756
+ } catch {
757
+ try { memoryPayload = JSON.parse(JSON.stringify(session.memoryDir.payload)); } catch { return; }
758
+ }
759
+ const visitedRoomIds = lastSnapshot ? lastSnapshot.visitedRoomIds : [];
760
+ persist.save({ memoryPayload: memoryPayload, visitedRoomIds: visitedRoomIds }).then((saved) => {
761
+ if (saved) window.tmctAdventureLastSave = { at: Date.now(), ms: Math.round(performance.now() - started) };
762
+ });
763
+ }, 500);
764
+ }
765
+
649
766
  // ---- serialize every engine-touching call: the ticker, the chat dock and
650
767
  // the editor sync all share one in-memory store, and any overlapping pair
651
768
  // could race against the same write.
@@ -656,21 +773,16 @@ const ADVENTURE = ${pageData};
656
773
  return run;
657
774
  }
658
775
 
659
- // ---- large-sprite-tier wiring — fetches the gradient-shaded 400px tier
660
- // (public/sprites-pack/manifest.json, built by scripts/build-demo-sprites-
661
- // pack.mjs from data/sprites-large/*.toml) at page load, resolved through
662
- // the exact same sprite-templates.mjs resolver the build-time icon tier
663
- // already used. A failed fetch (offline preview, a stripped static host)
664
- // just leaves the build-time icon tier (ADVENTURE.spriteTemplates) as the
665
- // working fallback this page is never blank because one asset had a
666
- // hiccup, the same posture this project already takes elsewhere.
667
- let activeSpriteTemplates = ADVENTURE.spriteTemplates;
668
- fetch("./sprites-pack/manifest.json").then((r) => (r && r.ok ? r.json() : null)).then((data) => {
669
- if (!data || !Array.isArray(data.templates) || !data.templates.length) return;
670
- activeSpriteTemplates = data.templates;
671
- if (lastSnapshot) redraw(lastSnapshot);
672
- if (document.body.classList.contains("editing")) refreshEditPanels();
673
- }).catch(() => { /* ADVENTURE.spriteTemplates already covers this page */ });
776
+ // ---- large-sprite-tier wiring — the gradient-shaded 400px tier
777
+ // (data/sprites-large/*.toml) arrives embedded at build time as
778
+ // ADVENTURE.largeSpriteTemplates, resolved through the exact same
779
+ // sprite-templates.mjs resolver the icon tier already used — no runtime
780
+ // fetch. A build without the large tier just leaves the icon tier
781
+ // (ADVENTURE.spriteTemplates) as the working fallback this page is never
782
+ // blank because one tier is absent, the same posture this project already
783
+ // takes elsewhere.
784
+ const largeTier = ADVENTURE.largeSpriteTemplates;
785
+ const activeSpriteTemplates = Array.isArray(largeTier) && largeTier.length ? largeTier : ADVENTURE.spriteTemplates;
674
786
 
675
787
  // ---- the class-badge system — this redesign's signature element: every
676
788
  // sprite gets a small genre-flavored class word underneath its own real
@@ -731,10 +843,14 @@ const ADVENTURE = ${pageData};
731
843
  // cursor per node; play mode's own map stays purely informational.
732
844
  function roomMapSvg(graph, clickable) {
733
845
  if (!graph.nodes.length) return null;
734
- const cell = 56, radius = 15;
846
+ // Board-game footprints: each room a named rectangle (the name INSIDE
847
+ // it, the way a Cluedo board prints its rooms), corridors as wide paths
848
+ // between the footprints, direction-only hints as path stubs fading
849
+ // toward rooms not yet visited.
850
+ const cell = 64, roomW = 56, roomH = 26;
735
851
  const maxX = Math.max.apply(null, graph.nodes.map((n) => n.x));
736
852
  const maxY = Math.max.apply(null, graph.nodes.map((n) => n.y));
737
- const w = (maxX + 1) * cell, h = (maxY + 1) * cell + 14;
853
+ const w = (maxX + 1) * cell, h = (maxY + 1) * cell;
738
854
  const cx = (n) => (n.x + 0.5) * cell;
739
855
  const cy = (n) => (n.y + 0.5) * cell;
740
856
  const byRoom = new Map(graph.nodes.map((n) => [n.id, n]));
@@ -746,13 +862,13 @@ const ADVENTURE = ${pageData};
746
862
  const hintsSvg = graph.hints.map((hi) => {
747
863
  const from = byRoom.get(hi.from);
748
864
  const d = HINT_DELTA[hi.direction] || [0, 0];
749
- return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3"></circle>';
865
+ return '<circle class="room-hint" cx="' + (cx(from) + d[0] * cell * 0.42) + '" cy="' + (cy(from) + d[1] * cell * 0.42) + '" r="3.5"></circle>';
750
866
  }).join("");
751
867
  const nodesSvg = graph.nodes.map((n) => {
752
868
  const cls = "room-node" + (n.current ? " current" : "") + (clickable ? " clickable" : "") + (clickable && n.id === selectedRoomId ? " selected" : "");
753
869
  const attr = clickable ? ' data-room="' + esc(n.id) + '"' : "";
754
- return '<g class="' + cls + '"' + attr + '><circle cx="' + cx(n) + '" cy="' + cy(n) + '" r="' + radius + '"></circle>'
755
- + '<text x="' + cx(n) + '" y="' + (cy(n) + radius + 9) + '">' + esc(n.id) + "</text></g>";
870
+ return '<g class="' + cls + '"' + attr + '><rect x="' + (cx(n) - roomW / 2) + '" y="' + (cy(n) - roomH / 2) + '" width="' + roomW + '" height="' + roomH + '" rx="3"></rect>'
871
+ + '<text x="' + cx(n) + '" y="' + (cy(n) + 2.5) + '">' + esc(n.id) + "</text></g>";
756
872
  }).join("");
757
873
  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") + '">'
758
874
  + edgesSvg + hintsSvg + nodesSvg + "</svg>";
@@ -835,12 +951,14 @@ const ADVENTURE = ${pageData};
835
951
  const objects = roomSceneObjects(snap.rows, snap.state, snap.here);
836
952
  const sprites = [{ subject: "you", spriteClass: "adventurer" }, ...objects];
837
953
  spriteRow.innerHTML = sprites.map((s) => spriteCardHtml(s.subject, s.spriteClass, resolveObjectSprite(snap.rows, s))).join("");
954
+ roomNameEl.textContent = "the " + snap.here;
838
955
  captionEl.textContent = captionFor(snap.rows, snap.state, snap.here);
839
956
  turnLabelEl.textContent = "turn: " + snap.turn;
840
957
  renderPills(snap.rows, snap.state, snap.here);
841
958
  renderCarrying(snap.rows, snap.state);
842
959
  renderRoomMap(snap.rows, snap.state, snap.visitedRoomIds);
843
960
  renderGoals(snap.rows, snap.state, snap.visitedRoomIds);
961
+ schedulePersistSave();
844
962
  }
845
963
 
846
964
  chatformEl.addEventListener("submit", (e) => {
@@ -1011,8 +1129,14 @@ const ADVENTURE = ${pageData};
1011
1129
  editModeBtn.addEventListener("click", () => withLock(() =>
1012
1130
  (document.body.classList.contains("editing") ? exitEditMode() : enterEditMode())));
1013
1131
 
1014
- async function boot() {
1015
- session = await tmctAdventure.createAdventureSession(ADVENTURE.world);
1132
+ async function boot({ fresh = false } = {}) {
1133
+ const saved = !fresh && persist ? await persist.load() : null;
1134
+ session = await tmctAdventure.createAdventureSession(
1135
+ ADVENTURE.world,
1136
+ saved && saved.payload && saved.payload.memoryPayload
1137
+ ? { restoredPayload: saved.payload.memoryPayload, restoredVisitedRoomIds: saved.payload.visitedRoomIds }
1138
+ : {},
1139
+ );
1016
1140
  lastTicks = 0;
1017
1141
  const snap = await session.snapshot();
1018
1142
  redraw(snap);
@@ -1020,6 +1144,9 @@ const ADVENTURE = ${pageData};
1020
1144
  chatlogEl.innerHTML = "";
1021
1145
  statusEl.textContent = ADVENTURE.world.opening || "";
1022
1146
  addChatLine("t", esc(ADVENTURE.world.opening || "the adventure begins."));
1147
+ if (saved && snap.turn > 0) {
1148
+ addChatLine("t", "resumed where you left off (turn " + snap.turn + ") \\u2014 progress kept best-effort on this device; reset starts the manor over.");
1149
+ }
1023
1150
  chatqEl.disabled = false;
1024
1151
  resetBtn.disabled = false; playBtn.disabled = false; stepBtn.disabled = false; editModeBtn.disabled = false;
1025
1152
  }
@@ -1040,7 +1167,15 @@ const ADVENTURE = ${pageData};
1040
1167
  stepBtn.disabled = state.animating || state.playing;
1041
1168
  resetBtn.disabled = state.animating;
1042
1169
  },
1043
- onReset: () => withLock(boot),
1170
+ onReset: () => withLock(async () => {
1171
+ // Reset means "start the manor over", so the saved snapshot goes too —
1172
+ // without the clear, the next visit would restore the game reset just
1173
+ // threw away.
1174
+ clearTimeout(persistSaveTimer);
1175
+ persistSaveTimer = null;
1176
+ if (persist) await persist.clear();
1177
+ await boot({ fresh: true });
1178
+ }),
1044
1179
  hasNext: () => !preview || lastTicks < ADVENTURE.previewMaxTicks,
1045
1180
  waitMs: ADVENTURE.tickWaitMs,
1046
1181
  });
@@ -1048,7 +1183,14 @@ const ADVENTURE = ${pageData};
1048
1183
  stepBtn.addEventListener("click", () => ticker.stepOnce());
1049
1184
  resetBtn.addEventListener("click", () => ticker.reset());
1050
1185
 
1051
- boot().then(() => { if (preview) ticker.play(); });
1186
+ (async () => {
1187
+ if (!preview && tmctAdventure.openPersistedStore) {
1188
+ const siteVersion = await fetchSiteVersion();
1189
+ persist = tmctAdventure.openPersistedStore({ storeKey: "adventure", stamp: siteVersion + ":" + ADVENTURE.world.name });
1190
+ }
1191
+ await boot();
1192
+ if (preview) ticker.play();
1193
+ })();
1052
1194
  })();
1053
1195
  </script>
1054
1196
  </body>