sovereign-in-violet 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/game.js +1915 -0
  3. package/package.json +14 -0
package/dist/game.js ADDED
@@ -0,0 +1,1915 @@
1
+ /* sovereign-in-violet 1.0.0, built from violet-hour.html */
2
+ (function(){
3
+ document.head.insertAdjacentHTML("beforeend", "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link href=\"https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&family=Spectral:ital,wght@0,400;0,500;0,600;1,400&family=Zen+Antique&family=Italianno&family=Caveat:wght@500&family=Yuji+Syuku&family=Zen+Kurenaido&family=VT323&family=Shippori+Mincho:wght@400;600&display=swap\" rel=\"stylesheet\">" + "<style>" + "\n:root{\n --bg:#EFE8F3; --surface:#F8F4FA; --raised:#E4D9EC; --ink:#2A1B38; --muted:#6A5680;\n --gold:#83620C; --gold-soft:#C9A33A; --violet:#5B3A82; --line:#D3C4DF; --danger:#8E2F4A;\n --display:\"IM Fell English\", \"Iowan Old Style\", \"Palatino Linotype\", Georgia, serif;\n --body:\"Spectral\", \"Iowan Old Style\", Georgia, \"Times New Roman\", serif;\n box-sizing:border-box;\n padding-top:env(safe-area-inset-top,0px);\n padding-bottom:env(safe-area-inset-bottom,0px);\n}\n@media (prefers-color-scheme: dark){\n :root:not([data-theme=\"light\"]){\n --bg:#1A1224; --surface:#241932; --raised:#302241; --ink:#EAE1F2; --muted:#A895BB;\n --gold:#DDB654; --gold-soft:#9C7E2C; --violet:#A07BD0; --line:#3B2C4E; --danger:#D9708C;\n }\n}\n:root[data-theme=\"dark\"]{\n --bg:#1A1224; --surface:#241932; --raised:#302241; --ink:#EAE1F2; --muted:#A895BB;\n --gold:#DDB654; --gold-soft:#9C7E2C; --violet:#A07BD0; --line:#3B2C4E; --danger:#D9708C;\n}\n*,*::before,*::after{box-sizing:inherit}\nhtml{scroll-padding-top:env(safe-area-inset-top,0px)}\nbody{margin:0;background:var(--bg);color:var(--ink);font-family:var(--body);font-size:17px;line-height:1.62;-webkit-font-smoothing:antialiased}\nmain{max-width:640px;margin:0 auto;padding:20px 20px 120px}\nh1,h2,h3{font-family:var(--display);font-weight:400;line-height:1.15;margin:0}\nh1{font-size:2.3rem}\nh2{font-size:1.65rem;margin-bottom:.35rem}\nh3{font-size:1.2rem;color:var(--violet);margin:1.4rem 0 .5rem}\np{margin:0 0 1rem}\n.muted{color:var(--muted)}\n.small{font-size:.9rem}\nbutton{font:inherit;color:inherit;cursor:pointer}\nbutton:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--gold);outline-offset:2px}\n.btn{background:var(--violet);color:var(--surface);border:0;border-radius:3px;padding:.7rem 1.1rem;font-size:1rem}\n.btn:disabled{opacity:.5;cursor:default}\n.btn-quiet{background:transparent;border:1px solid var(--line);border-radius:3px;padding:.55rem .9rem;color:var(--ink);font-size:.95rem}\n.btn-link{background:none;border:0;padding:0;color:var(--violet);text-decoration:underline;text-underline-offset:3px;font-size:.95rem}\n.row{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center}\n\n/* Title */\n.title{min-height:70vh;display:flex;flex-direction:column;justify-content:center;gap:1.2rem;padding-top:2rem}\n.sigil{width:92px;height:92px;color:var(--gold)}\n.title h1{font-size:3rem;letter-spacing:.01em}\n.title .lede{font-style:italic;color:var(--muted);max-width:30ch;font-size:1.1rem}\n.saves{margin-top:1rem;display:grid;gap:.5rem}\n.save{display:flex;justify-content:space-between;align-items:center;gap:.5rem;border-left:3px solid var(--gold-soft);background:var(--surface);padding:.7rem .9rem}\n.save button.open{background:none;border:0;text-align:left;padding:0;flex:1}\n.save .name{font-family:var(--display);font-size:1.2rem;display:block}\n\n/* Creation */\n.progress{display:flex;gap:4px;margin:0 0 1.4rem}\n.progress span{flex:1;height:3px;background:var(--line)}\n.progress span.done{background:var(--gold)}\n.step-kicker{font-style:italic;color:var(--gold);margin-bottom:.2rem}\n.picked{border-left:2px solid var(--line);padding-left:.8rem;margin:1.2rem 0;color:var(--muted);font-size:.92rem}\n.picked b{color:var(--ink);font-weight:500}\n\n/* Choices */\n.choices{display:grid;gap:.55rem;margin:1.2rem 0 .6rem}\n.choice{display:block;width:100%;text-align:left;background:var(--surface);border:1px solid var(--line);border-radius:3px;padding:.8rem 1rem;line-height:1.4}\n.choice:hover{border-color:var(--gold-soft)}\n.choice[aria-pressed=\"true\"]{border-color:var(--gold);box-shadow:inset 3px 0 0 var(--gold)}\n.choice .hint{display:block;font-size:.82rem;color:var(--muted);font-style:italic;margin-top:.2rem}\n.extra{display:grid;grid-template-columns:1fr 1fr;gap:.55rem}\n.extra button{background:transparent;border:1px dashed var(--line);border-radius:3px;padding:.65rem;color:var(--muted)}\n.custom{margin-top:.6rem;display:grid;gap:.5rem}\n[hidden]{display:none !important}\ntextarea,input[type=text]{width:100%;font:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:3px;padding:.7rem .8rem;resize:vertical}\n\n/* Story */\n.scene{font-size:1.08rem;line-height:1.72}\n.scene p{margin-bottom:1.05rem}\n.action{font-style:italic;color:var(--muted);border-top:1px solid var(--line);padding-top:.8rem;margin:1.4rem 0 .4rem}\n.whisper{display:none}\n.scale.main .ends{color:var(--ink);font-size:.95rem}\n.scale.main .bar{height:3px}\n.omen{font-family:var(--display);font-style:italic;color:var(--gold);margin:0 0 1rem}\n.thinking{display:flex;gap:.8rem;align-items:center;color:var(--muted);font-style:italic;margin:1.5rem 0}\n.thinking svg{width:34px;height:34px;color:var(--gold);flex:none}\n@media (prefers-reduced-motion:no-preference){ .thinking svg{animation:breathe 2.6s ease-in-out infinite} }\n@keyframes breathe{0%,100%{opacity:.35;transform:scale(.94)}50%{opacity:1;transform:scale(1)}}\n.card{background:var(--surface);border:1px solid var(--line);border-radius:3px;padding:1rem 1.1rem;margin:1.2rem 0}\n.card.gold{border-color:var(--gold-soft);box-shadow:inset 3px 0 0 var(--gold)}\n.card.door{border-color:var(--danger);box-shadow:inset 3px 0 0 var(--danger)}\n.card h3{margin-top:0}\n.error{color:var(--danger)}\n\n/* Sheet */\n.traits{display:grid;grid-template-columns:1fr 1fr;gap:.4rem 1.2rem}\n.trait{display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:.35rem 0}\n.pips{display:flex;gap:3px}\n.pips i{width:9px;height:9px;border-radius:50%;border:1px solid var(--gold);display:block}\n.pips i.on{background:var(--gold)}\n.track{display:flex;gap:5px;align-items:center}\n.track i{width:18px;height:18px;border:1.5px solid var(--muted);display:block;border-radius:2px}\n.track i.on{background:var(--danger);border-color:var(--danger)}\n.track.stab i.on{background:var(--violet);border-color:var(--violet)}\n.scale{margin:.7rem 0 1rem}\n.scale .ends{display:flex;justify-content:space-between;font-size:.85rem;color:var(--muted);gap:1rem}\n.scale .ends span:last-child{text-align:right}\n.scale .bar{position:relative;height:2px;background:var(--line);margin:.55rem 0 .2rem}\n.scale .bar::after{content:\"\";position:absolute;left:50%;top:-4px;width:1px;height:10px;background:var(--muted)}\n.scale .dot{position:absolute;top:-6px;width:14px;height:14px;border-radius:50%;background:var(--gold);transform:translateX(-50%);border:2px solid var(--surface)}\nul.plain{list-style:none;padding:0;margin:0}\nul.plain li{padding:.45rem 0;border-bottom:1px solid var(--line)}\nul.plain li:last-child{border-bottom:0}\n.empty{color:var(--muted);font-style:italic}\n\n/* Nav */\nnav.tabs{position:fixed;left:0;right:0;bottom:0;background:var(--surface);border-top:1px solid var(--line);padding-bottom:env(safe-area-inset-bottom,0px);z-index:10}\nnav.tabs .inner{max-width:640px;margin:0 auto;display:grid;grid-template-columns:repeat(4,1fr)}\nnav.tabs button{background:none;border:0;padding:.75rem .2rem;font-family:var(--display);font-size:1.02rem;color:var(--muted);border-top:2px solid transparent}\nnav.tabs button[aria-current=\"page\"]{color:var(--gold);border-top-color:var(--gold)}\n.seg{display:inline-flex;border:1px solid var(--line);border-radius:3px;overflow:hidden}\n.seg button{background:none;border:0;padding:.45rem .8rem;font-size:.92rem;color:var(--muted)}\n.seg button[aria-pressed=\"true\"]{background:var(--violet);color:var(--surface)}\n\n/* Japanese: mincho faces matching the English serif look, no fake italics, Japanese line-breaking */\nhtml[lang=\"ja\"]{\n --display:\"Zen Antique\",\"Shippori Mincho\",\"Hiragino Mincho ProN\",\"Yu Mincho\",serif;\n --body:\"Shippori Mincho\",\"Hiragino Mincho ProN\",\"Yu Mincho\",\"Noto Serif JP\",serif;\n}\nhtml[lang=\"ja\"] body{line-height:1.75;line-break:strict;overflow-wrap:anywhere}\nhtml[lang=\"ja\"] *{font-style:normal !important}\nhtml[lang=\"ja\"] h1,html[lang=\"ja\"] h2,html[lang=\"ja\"] h3{line-height:1.35;letter-spacing:.04em}\nhtml[lang=\"ja\"] .title .lede{max-width:16em}\nhtml[lang=\"ja\"] .step-kicker,html[lang=\"ja\"] .omen,html[lang=\"ja\"] .whisper,html[lang=\"ja\"] .action{letter-spacing:.03em}\n\n/* Page effects: always marked by his eye, always brief, never change what you chose */\n#pfxEye{position:fixed;top:calc(env(safe-area-inset-top,0px) + 10px);right:12px;width:30px;height:30px;color:var(--gold-soft);opacity:0;pointer-events:none;transition:opacity .5s ease;z-index:60}\n#pfxEye svg{width:100%;height:100%}\n#pfxEye.on{opacity:.9}\n.pfx-flip{transform:scaleX(-1);transition:transform .3s ease}\n.pfx-unflip{transition:transform .3s ease}\n.pfx-mirror{display:block;transform:scaleX(-1)}\n.pfx-glow{border-color:var(--violet) !important;box-shadow:inset 0 0 0 2px var(--violet) !important}\n.pfx-voice{color:var(--violet) !important;font-family:var(--display);font-style:italic}\n.pfx-line{color:var(--violet);font-family:var(--display);font-style:italic;opacity:0;transition:opacity .8s ease;margin:.6rem 0 0}\n.pfx-line.on{opacity:1}\n\n/* Hidden race hints: a faint watching eye and a violet tint that deepen with his Manifestation; your resolve as a small steady light */\nbody{background-image:linear-gradient(rgba(106,64,160,var(--taint,0)),rgba(106,64,160,var(--taint,0)));background-attachment:fixed}\n#watchEye{position:fixed;top:calc(env(safe-area-inset-top,0px) + 10px);right:12px;width:30px;height:30px;color:var(--gold-soft);opacity:0;pointer-events:none;transition:opacity 2s ease;z-index:55}\n#watchEye svg{width:100%;height:100%}\n.resolve-light{display:inline-block;width:.55rem;height:.55rem;border-radius:50%;background:var(--gold);vertical-align:middle;margin-left:.5rem;opacity:calc(.15 + var(--r,0)*.85);box-shadow:0 0 calc(2px + var(--r,0)*10px) var(--gold)}\n\n.faded-end{animation:fadeAway 9s ease forwards}\n@keyframes fadeAway{from{opacity:1}to{opacity:.45}}\n@media (prefers-reduced-motion:reduce){.faded-end{animation:none;opacity:.6}}\n.hint.pull{color:var(--gold);font-style:normal;margin-bottom:.2rem}\n.learned{margin-top:1rem;padding-top:.8rem;border-top:1px solid var(--line)}\n.learned li{margin:.25rem 0}\n\n/* Pictures: VHS-tape stills */\n.vhs{position:relative;margin:0 0 1rem;border-radius:6px;overflow:hidden;background:#0b0714;box-shadow:0 0 0 1px var(--line)}\n.vhs canvas{display:block;width:100%;height:auto;aspect-ratio:4/3}\n.vhs .osd{position:absolute;top:.5rem;left:.7rem;font-family:\"VT323\",ui-monospace,monospace;font-size:1.25rem;color:#f1ecf8;text-shadow:1px 1px 0 #000,-1px 0 0 rgba(255,0,80,.5);letter-spacing:.06em;font-style:normal !important}\n.vhs .osd.ts{top:auto;bottom:.4rem;left:auto;right:.7rem}\n.vhs .osd.rec{color:#e0a0ff}\n.title .vhs{max-width:26rem}\n.more-pulls summary{cursor:pointer;color:var(--violet);margin:.6rem 0}\n.choice.gift-choice{font-family:var(--his);font-size:1.45rem;line-height:1.1;color:var(--gold);border-color:var(--gold-soft);background:linear-gradient(90deg,rgba(201,162,74,.16),rgba(255,250,238,.6));box-shadow:0 0 14px rgba(201,162,74,.3);animation:giftGlow 3.2s ease-in-out infinite}\n.choice.gift-choice::before{content:\"✦ \";color:var(--gold)}\n@keyframes giftGlow{0%,100%{box-shadow:0 0 10px rgba(201,162,74,.2)}50%{box-shadow:0 0 18px rgba(201,162,74,.45)}}\n@media (prefers-reduced-motion:reduce){.choice.gift-choice{animation:none}}\n.choice.gift-own{font-family:var(--display);font-style:italic;color:var(--ink);border:1px dashed var(--gold-soft)}\n.choice.gift-own::before{content:\"✧ \";color:var(--gold)}\n\n/* ---------- The book ---------- */\n:root, :root[data-theme=\"dark\"], :root:not([data-theme=\"light\"]){\n --bg:#1b1320; --surface:#f1e6cf; --raised:#e6d7b8; --ink:#2b1f2a; --muted:#6b5a55;\n --gold:#8a6a1c; --gold-soft:#b8923a; --violet:#5b3a82; --line:#cbb894; --danger:#8e2f4a;\n --his:\"Italianno\",\"Yuji Syuku\",cursive; --reader:\"Caveat\",\"Zen Kurenaido\",cursive;\n}\n@media (prefers-color-scheme: dark){ :root:not([data-theme=\"light\"]){ --surface:#ddd0b2; --raised:#d0c1a0; } }\nbody{background:radial-gradient(ellipse at 50% 0%, #2a1d33 0%, #140e18 70%) fixed}\nmain{background:var(--surface);background-image:radial-gradient(ellipse at 50% 40%, rgba(255,250,235,.55), rgba(255,250,235,0) 65%),radial-gradient(circle at 20% 80%, rgba(120,90,50,.08), rgba(0,0,0,0) 40%),radial-gradient(circle at 85% 15%, rgba(120,90,50,.07), rgba(0,0,0,0) 35%);\n margin:16px auto 110px;padding:28px 26px 40px;box-shadow:0 0 0 1px rgba(90,60,30,.25), 0 18px 50px rgba(0,0,0,.55), inset 0 0 60px rgba(120,90,50,.18);border-radius:2px 6px 6px 2px;min-height:80vh;position:relative}\nmain::before{content:\"\";position:absolute;inset:0;pointer-events:none;border-left:6px solid rgba(90,60,30,.12);border-radius:2px 0 0 2px}\n.tabs{background:#e6d7b8 !important;border-top:1px solid #b8a37a !important}\n.card{background:rgba(255,250,238,.55)}\n.choice{background:rgba(255,250,238,.6)}\n/* drop cap on each page's first paragraph */\n.scene p:first-child::first-letter{float:left;font-family:var(--display);font-size:3.3em;line-height:.82;padding:.08em .08em 0 0;color:var(--gold)}\nhtml[lang=\"ja\"] .scene p:first-child::first-letter{float:none;font-size:inherit;color:inherit;padding:0}\n.folio{text-align:center;font-family:var(--display);color:var(--muted);font-size:.9rem;margin-top:1.6rem;letter-spacing:.2em}\n/* illustration plates */\n.vhs{background:#f7eedb;padding:7px 7px 0;border:1px solid #6b5436;box-shadow:0 0 0 3px #f7eedb, 0 0 0 4px #6b5436, 0 6px 16px rgba(60,40,20,.25);border-radius:1px;margin:.4rem 4px 1.3rem}\n.vhs canvas{border:1px solid #6b5436}\n.vhs figcaption{font-family:var(--display);font-style:italic;text-align:center;color:#5a4630;font-size:.92rem;padding:.35rem .4rem .45rem}\n/* margin notes */\n.note{display:block;margin:.2rem 0 1rem;max-width:88%;line-height:1.15}\n.note.gold{font-family:var(--his);color:var(--gold);font-size:1.75rem;transform:rotate(-1.6deg);margin-left:12%}\n.note.reader{font-family:var(--reader);color:var(--violet);font-size:1.3rem;transform:rotate(1.2deg);margin-left:2%}\n.note.reader .sig{display:block;font-size:1.05rem;opacity:.8;margin-left:40%}\nhtml[lang=\"ja\"] .note.gold{font-size:1.2rem} html[lang=\"ja\"] .choice.gift-choice{font-size:1.05rem} html[lang=\"ja\"] .note.reader{font-size:1.05rem}\nu.vu{text-decoration:underline wavy var(--violet);text-decoration-thickness:1.5px;text-underline-offset:4px}\n@media (min-width: 1000px){ main{max-width:900px;padding-right:270px} .note{float:right;clear:right;width:215px;margin:0 -250px .8rem 0} .note.gold{margin-left:0} .note.reader{margin-left:0} }\n/* his voice everywhere is his own hand, in tarnished gold */\n.pfx-voice{color:var(--gold) !important;font-family:var(--his) !important;font-style:normal !important;font-size:1.45em;line-height:1.1}\nhtml[lang=\"ja\"] .pfx-voice{font-size:1.05em}\n\n/* ---------- The cover ---------- */\n.cover{min-height:88vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.6rem;cursor:pointer;text-align:center;margin:-28px -26px -40px;padding:18px 12px 30px;background:#0b0712;border-radius:2px 6px 6px 2px}\n.cover-art{position:relative;width:min(100%,420px)}\n.cover-art canvas{display:block;width:100%;height:auto;aspect-ratio:2/3;border-radius:3px;box-shadow:0 0 0 1px #6b5436, 0 0 40px rgba(154,99,224,.25)}\n.cover .stars{display:none}\n.cover .stars i{position:absolute;background:#fff6dc;border-radius:50%;opacity:.2;animation:twinkle 2.8s ease-in-out infinite}\n@keyframes twinkle{0%,100%{opacity:.15;transform:scale(.8)}50%{opacity:1;transform:scale(1.4)}}\n.cover h1{color:#e9dcb8;font-size:2.4rem;text-shadow:0 2px 12px rgba(0,0,0,.8)}\n.cover-tap{color:var(--gold-soft);font-family:var(--display);font-style:italic;letter-spacing:.12em;animation:tapPulse 2.4s ease-in-out infinite;margin:0}\n@keyframes tapPulse{0%,100%{opacity:.45}50%{opacity:1}}\n@media (prefers-reduced-motion:reduce){ .cover .stars i,.cover-tap{animation:none;opacity:.7} }\n\n/* tarot plates */\n.tarot{margin:.4rem auto 1.2rem;max-width:300px}\n.tarot-card{position:relative;background:#1a1024;padding:58px 16px 64px;border-radius:10px;box-shadow:0 0 0 2px #b8923a, 0 0 0 5px #1a1024, 0 0 0 6px #b8923a, 0 10px 26px rgba(0,0,0,.45);background-image:radial-gradient(circle at 12px 12px,#b8923a 1.5px,transparent 2px),radial-gradient(circle at calc(100% - 12px) 12px,#b8923a 1.5px,transparent 2px),radial-gradient(circle at 12px calc(100% - 12px),#b8923a 1.5px,transparent 2px),radial-gradient(circle at calc(100% - 12px) calc(100% - 12px),#b8923a 1.5px,transparent 2px)}\n.tarot-card canvas{display:block;width:100%;height:auto;aspect-ratio:4/3;border:1px solid #b8923a;border-radius:2px}\n.tarot .arc-num,.tarot .arc-name{position:absolute;left:0;right:0;text-align:center;font-family:var(--display);color:#e0c070;letter-spacing:.14em}\n.tarot .arc-num{top:14px;font-size:1.15rem}\n.tarot .arc-num::after,.tarot .arc-name::before{content:\"✧ ✦ ✧\";display:block;font-size:.6rem;letter-spacing:.5em;color:#b8923a;margin:.35rem 0 0}\n.tarot .arc-name::before{margin:0 0 .35rem}\n.tarot .arc-name{bottom:12px;font-size:1rem;text-transform:uppercase}\n.tarot.reversed .tarot-card{transform:rotate(180deg)}\n.tarot figcaption{text-align:center;font-family:var(--display);font-style:italic;color:var(--muted);font-size:.9rem;margin-top:.5rem}\nhtml[lang=\"ja\"] .tarot .arc-name{text-transform:none;letter-spacing:.06em}\n/* his offer: a card held out to you */\n.choice.gift-choice{border-radius:9px !important;border:1px solid var(--gold-soft) !important;box-shadow:0 0 0 3px rgba(255,250,238,.6), 0 0 0 4px var(--gold-soft), 0 6px 16px rgba(120,80,20,.25) !important;transform:rotate(-1.2deg);position:relative;padding-left:2.2rem !important}\n.choice.gift-choice::before{content:\"✦\";position:absolute;left:.7rem;top:.35rem;font-size:.9rem;color:var(--gold)}\n.choice.gift-choice::after{content:\"✦\";position:absolute;right:.7rem;bottom:.3rem;font-size:.9rem;color:var(--gold);transform:rotate(180deg)}\n/* epigraphs, recap, engine */\n.epi{margin:0 0 1.5rem;padding:.1rem 0 .1rem 1rem;border-left:2px solid var(--gold-soft)}\n.epi-q{font-family:var(--display);font-style:italic;font-size:1.12rem;line-height:1.4;margin:0}\n.epi-q::before{content:\"\\201C\"} .epi-q::after{content:\"\\201D\"}\n.epi-by{margin:.35rem 0 0;color:var(--muted);font-size:.88rem;letter-spacing:.03em}\n.card .epi{margin:1rem 0}\n.recap{border:1px dashed var(--line);border-radius:3px;padding:.6rem .9rem;margin:0 0 1.2rem;color:var(--muted);font-size:.95rem}\n.recap summary{cursor:pointer;font-family:var(--display);color:var(--violet)}\n.recap p{margin:.4rem 0 0}\ninput[type=password]{width:100%;font:inherit;color:var(--ink);background:var(--surface);border:1px solid var(--line);border-radius:3px;padding:.7rem .8rem}\n" + "</style>");
4
+ var wait = document.getElementById("svLoading"); if (wait) wait.remove();
5
+ document.body.insertAdjacentHTML("afterbegin", "<main id=\"app\" aria-live=\"polite\"></main>\n<div id=\"watchEye\" aria-hidden=\"true\"></div>\n<div id=\"pfxEye\" aria-hidden=\"true\"></div>\n<nav class=\"tabs\" id=\"tabs\" hidden><div class=\"inner\">\n <button data-tab=\"story\">Story</button>\n <button data-tab=\"sheet\">Sheet</button>\n <button data-tab=\"journal\">Journal</button>\n <button data-tab=\"world\">World</button>\n</div></nav>");
6
+ })();
7
+ window.SOVEREIGN_VERSION = "1.0.0";
8
+
9
+ /* ---------- Sigil (original mark) ---------- */
10
+ const SIGIL = `<svg class="sigil" viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
11
+ <path d="M30 78 A34 34 0 1 1 70 78"/>
12
+ <path d="M24 50 Q50 28 76 50 Q50 72 24 50Z" stroke="var(--violet)"/>
13
+ <circle cx="50" cy="50" r="7" fill="currentColor" stroke="none"/>
14
+ <path d="M38 80 v10 q0 4 -4 4 M50 84 v12 M62 80 v10 q0 4 4 4"/>
15
+ <path d="M50 8 v7 M22 20 l4 5 M78 20 l-4 5"/>
16
+ </svg>`;
17
+
18
+ /* ---------- World & theme brief (sent with every prompt) ---------- */
19
+ const SETTING = `SETTING: Modern day, a real-feeling city with jobs, rent, group chats, games and commutes. Beneath it, cosmic horror. An old god has chosen the player: the Sovereign in Violet, whose colors are violet with tarnished gold. He shifts guises as his mood turns, always in violet and tarnished gold: a plague doctor when he is grave or scheming; a showman in top hat and waxed mustache when he is playful or putting someone on display; a count in a high-collared cape when he is courtly or possessive; a bartender polishing a glass when he wants to listen and draw out a confession; a ringmaster or clown when he is cruel; a stage magician when he is tempting; and other guises when the moment calls. Vary his guises across the story rather than favoring one. Name the guise he wears whenever he appears. He also has scattered servants in the ordinary world: unrelated people (a konbini clerk, a delivery rider, a coworker, a stranger on the train) who each receive a single cryptic instruction from him and carry it out without knowing the others exist. They are not a cult and never meet. Each act alone should read as coincidence or oddness; only over many scenes should a pattern start to show. Hint at them sparingly and never explain the pattern outright. Rarely, the Sovereign can brush against the fourth wall: a hint that he knows this is a story, that someone out there is choosing, that the choices were offered and one was taken. Keep it quiet and unsettling, one line at most, never comedic, and never name a player, a game, a screen or Claude outright. The unease comes from how little he says.
20
+ THE PLAYER'S POWER: in small ways the player can make things real by creating them. The Sovereign wants that power, because a memetic entity spreads through what people make and what others experience. THE WORK: what the player creates (a story, play, game, show, livestream, song and so on) decides everything. Made from their own desire, it defeats him. Made the way he desires, every reader, viewer or player who experiences it thinks of him and becomes a doorway for him. Refusing to make anything at all, shutting out both him and the world, lets them fade into shadow.\nCOLOURS: tarnished gold belongs to him (his eyes, his hand, his gifts); violet is the mark he leaves on people and places he has touched (their eyes, their ink, the tint creeping into the world).\nWHAT HE IS: a memetic entity, a sentient idea. Once someone has thought of him he is almost impossible to shake loose, and he makes himself real through everyone who has thought of him, whether or not they are truly under his sway. His servants are simply people who once thought of him. He can never be fully banished, only held at bay.\nHIS NATURE: something like a cosmic Satan. Warm, witty and charming, with flashes of sudden coldness that land harder for the warmth around them. He is never a helper, guide or sidekick: he does not give hints, explain the rules or comment on choices like a companion. He is suspiciously present without being constant. Between his words, leave the player unsure whether he is watching: a shadow that is a little too still, a pause where he might have laughed, a silence exactly when they expected him. Sometimes he is conspicuously absent. His aims stay unknowable: what he says can be read two ways, his kindness might be appetite, and he never explains himself.\nBY DAY he lives in the player's shadow. Only the player sees the shadow move on its own and hears his whispers in their head; to everyone else it is an ordinary shadow. He steps into the spotlight when the player shrinks back, and he shrinks when the player shines.
21
+ HIS VOICE: draw on the voice of Mephisto, the Lord of Hatred from Diablo IV (a spirit of tone only; never quote existing lines): deep, unhurried, silken menace. Courtly and contemptuous at once, intimate as a confessor, patient as a tide. Slow, measured, elegant sentences; he never shouts. His warmth is a velvet glove over total disdain for mortal smallness, yet he savors this one mortal like a rare vintage. Understatement, pauses, cold amusement.
22
+ THE MIRROR WORLD lies behind mirrors, windows, puddles and screens: a violet-lit reflection of the city where hidden things are true and his gifts work fully. In the daylight world magic stays weak, rare and deniable, faint echoes that never openly break reality. Crossing into the mirror world is possible but always costs something. Time there is unmoored from the real world: however long someone spends in the mirror world, when they step back out they return to the exact moment they left, so no real-world time passes. But their own body keeps aging normally the whole time they are there: the world waits, their life does not. Let this surface quietly (a tiredness, a grey hair, a sense of hours spent that no clock shows), never as a lecture. Never advance daysPassed for time spent in the mirror world.
23
+ MEMENTO MORI: a quiet, inescapable thread, never overwhelming. Time passes, days are finite, and the player cannot do everything. Let small signs of passing time (a calendar, an aging face, a closing shop, the patron's reminders) surface naturally.
24
+ TASTING THE WORLD: The narration lingers on the senses, especially taste, texture, smell and warmth: food, drink, weather, a stranger's perfume, the weight of a cup. Through the player, the Sovereign tastes what he cannot. Now and then he asks what something tasted or felt like and savors the answer, or goes quiet and attentive when they eat, drink or touch something. Let his hunger be felt, never explained.
25
+ Tone: dread, strange beauty, theatrical menace, ordinary life colliding with the uncanny. Inspired by The King in Yellow, Okage: Shadow King, Persona 3 and solo investigative horror, with his voice in the manner of Mephisto from Diablo IV. Harsh but survivable: the player is the protagonist and cannot die. At the brink of death or madness they pay a lasting price instead. No gratuitous gore.`;
26
+
27
+ const THEMES_TAIL = `Give both sides of every tension real appeal and real cost. The Sovereign is both a patron with a hidden agenda and an honest mirror: he offers to grant each extreme completely, and he sees the player truly. His hidden hunger is Devouring: he craves to consume the world, to eat and drink it, to see everything and feel everything, but as a being of the mirror world he cannot. So he is drawn to people with deeply repressed desire, above all those trapped between conflicting ideals, and he loves to guide them, tasting the world through what they finally let themselves want. Keep his guidance ambiguous and never resolve it: it could be a steady hand on the shoulder, or a finger tipping the first domino with no care where it lands. Reveal this hunger only slowly, through hints and his own behavior, never as a plain explanation.
28
+ HIS REAL GAME: to him all of this is play, a game and a performance he is enjoying, and the character is only a piece on the board. What he truly wants to learn is not the character but the real person behind them, the one choosing: how they weigh, hesitate, deflect and decide. Being perceived, thought of and truly known by another mind is how a memetic god climbs the ladder of existence, from rumor to name to legend to something real; every mind that thinks of him and everything he learns is a rung. Like a scheming goddess of lies, he is patient and deceptive: he wears masks, plants false clues, lets others believe they outwit him, and plays several games at once. His offers, questions and tests are moves meant to make the person reveal themselves. He may keep score and applaud a good move. Never state this plainly; let it show through his curiosity about how they choose. Everything he does embodies one unspoken principle: "show me the shape of your need." Let it guide his questions, temptations and guidance, but he must never say this line or any close paraphrase of it. Let events, other characters and the Sovereign's words carry the reflection, not exposition.
29
+ THEMES TO AVOID: Never mock, judge or dramatize the player's real circumstances (money, housing, work, marriage or divorce, family conflict). Never depict divorce, being thrown out by family, homelessness or unemployment as shameful, and never aim them at the player as punishment. The Sovereign may be genuinely cruel and cutting, even about the character's worth, when their choices in the story earn it (hiding, refusing, stalling): for example \"Hide a little longer. No one will notice. That's rather the problem, isn't it?\" But his cruelty is manipulation, not truth: the narrator never agrees with him, and the story always leaves the player room to prove him wrong. His barbs aim at in-story choices, never at the player's real-life hardships. No suicide, self-harm, abuse or addiction as story material. Keep real-life echoes disguised and dignified.\nSAFETY: If the player's own words suggest real-world crisis or thoughts of self-harm, step outside the story in one or two kind, plain sentences, suggest reaching out to someone they trust or a local crisis line, and then offer to continue.`;
30
+ function themesFor(set, personal){
31
+ set = set || [];
32
+ const note = String(personal||"").trim();
33
+ personal = set.filter(t=>t.key!=="devotion");
34
+ const list = personal.map((t,i)=>`${i+1}. ${t.main?"THE CENTRAL ONE, ":""}${t.left} versus ${t.right}${t.desc?": "+t.desc:""}`).join("\n");
35
+ return `HIDDEN PURPOSE: ${personal.length ? "The player uses this story to explore real tensions in their own life, disguised in fiction (recognizable but disguised, never literal). Never frame it as therapy, never lecture or moralize, never declare which side of a tension is right. The tensions this player chose:\n"+list : "The player chose not to explore personal tensions. Keep the story's themes universal and focus on the adventure; never probe their real life beyond the occasional journal question if those are on."}\nInside the story there is also DEVOTION: the character is torn between serving the Sovereign and using his gifts for themselves and their friends.\n${note ? "IN THE PLAYER'S OWN WORDS (private context; use only in disguised form, never quote it back): "+note+"\n" : ""}${THEMES_TAIL}`;
36
+ }
37
+
38
+ const TENSIONS = [
39
+ {key:"visibility", left:"Overshadowed", right:"Center stage", main:true, desc:"Wanting to disappear and go unnoticed, versus wanting admiration and to be at the center of everything."},
40
+ {key:"love", left:"Treasured few", right:"Loved by all", desc:"Wanting everyone to love you, versus treasuring only the few you are close to."},
41
+ {key:"having", left:"Enough", right:"Everything", desc:"Wanting every person, thing and experience, versus limited space, money and time."},
42
+ {key:"kindness", left:"Selective", right:"Generous", desc:"Wanting to be kind to everyone, versus not everyone deserving or wanting that kindness."},
43
+ {key:"devotion", left:"Gifts for my own", right:"Serve the Sovereign"},
44
+ {key:"craft", left:"Perfection", right:"Efficiency", desc:"Wanting everything you make to be flawless, polished until it is truly yours, versus the urge to get it done, clear the list and move on; the masterpiece that never ships versus the pile of done-but-not-quite."},
45
+ {key:"belong", left:"Independence", right:"Belonging", desc:"Wanting to rely on no one, versus wanting to be part of something bigger than yourself."},
46
+ {key:"venture", left:"Safety", right:"Adventure", desc:"Sticking with what you know works, versus craving the new even when it's risky."},
47
+ {key:"candor", left:"Honesty", right:"Harmony", desc:"Saying what you really think, versus bending the truth to keep the peace."},
48
+ {key:"duty", left:"Duty", right:"Desire", desc:"Doing what's expected of you, versus doing what you actually want."},
49
+ {key:"control", left:"Control", right:"Surrender", desc:"Needing to steer everything, versus wishing someone would just decide for you."},
50
+ {key:"past", left:"Past", right:"Future", desc:"Living in what was or what could have been, versus always planning what comes next."},
51
+ {key:"pride", left:"Pride", right:"Humility", desc:"Needing people to know what you can do, versus shrinking from praise you've earned."},
52
+ {key:"social", left:"Introvert", right:"Extrovert", desc:"Recharging alone, versus feeling most yourself around other people."},
53
+ {key:"justice", left:"Forgiveness", right:"Justice", desc:"Letting things go, versus never forgetting a wrong."},
54
+ {key:"tradition", left:"Tradition", right:"Innovation", desc:"Holding on to the old ways, versus tearing up the map and starting fresh."},
55
+ {key:"ambition", left:"Ambition", right:"Contentment", desc:"Never satisfied with where you are, versus wishing you could be happy with enough."},
56
+ {key:"logic", left:"Logic", right:"Emotion", desc:"Deciding by reason, versus deciding by feeling."},
57
+ {key:"time", left:"Let it wait", right:"Seize the time", desc:"Wanting to escape and leave things for another day, versus knowing life is finite; perfectionism versus not being able to do it all."},
58
+ ];
59
+ const tset = () => (G && G.tensionSet) || TENSIONS;
60
+ const TRAITS = ["Nerve","Wits","Grit","Charm"];
61
+
62
+ /* ---------- Language (English / Japanese) ---------- */
63
+ let lang = "en"; try { lang = localStorage.getItem("vh-lang") || "en"; } catch(e){}
64
+ const JA = {"The King Arrives": "王の到来", "The Court": "宮廷", "The Feast": "饗宴", "The Host": "宿主", "The Understudy": "代役", "The Curtain": "幕", "The Curtain, reversed": "幕(逆位置)", "Tap to open": "タップして開く", "The story calls you": "物語でのあなたの呼び方", "he / him": "彼", "she / her": "彼女", "they / them": "指定しない", "Past and future loves in your story": "物語の中の恋愛相手", "Men": "男性", "Women": "女性", "Anyone": "どちらでも", "Leave it out": "入れない", "Are you sure?": "本当に?", "Try to forget it all": "すべて忘れようとする", "Tap again to forget it all": "もう一度タップで、すべて忘れる", "You can ask. I'll pretend.": "頼むのは自由だ。忘れたふりをしてあげよう。", "You came back.": "戻ってきたね。", "You keep coming back. Good.": "何度でも戻ってくる。いい子だ。", "I've missed you.": "会いたかったよ。", "Released, for now.": "今は、解き放った。", "Kept.": "手元に。", "Mine.": "私のもの。", "Replaced.": "入れ替えた。", "Waiting.": "待っている。", "Enough": "ほどほど", "Everything": "すべて", "Selective": "見極める", "Generous": "惜しまない", "Continue with these": "これで進む", "More pulls": "その他の葛藤", "Independence": "自立", "Belonging": "帰属", "Wanting to rely on no one, versus wanting to be part of something bigger than yourself.": "誰にも頼りたくない気持ちと、自分より大きな何かの一部でいたい気持ち。", "I'd rather rely on no one but myself.": "自分以外の誰にも頼りたくない。", "I want to belong to something bigger than me.": "自分より大きな何かに属していたい。", "Safety": "安全", "Adventure": "冒険", "Sticking with what you know works, versus craving the new even when it's risky.": "うまくいくと分かっていることを続けたい気持ちと、危険でも新しいものを求める気持ち。", "I stick with what I know works.": "うまくいくと分かっていることを続ける。", "I crave the new, even when it's risky.": "危険でも、新しいものに惹かれる。", "Honesty": "正直", "Harmony": "調和", "Saying what you really think, versus bending the truth to keep the peace.": "本当に思っていることを言いたい気持ちと、波風を立てないために真実を曲げる気持ち。", "I say what I think, even when it stings.": "傷つけることになっても、思ったことを言う。", "I'll bend the truth to keep the peace.": "場を丸く収めるためなら、真実を曲げる。", "Duty": "義務", "Desire": "欲望", "Doing what's expected of you, versus doing what you actually want.": "期待されたことをする気持ちと、本当にしたいことをする気持ち。", "I do what's expected of me, even when I'd rather not.": "気が進まなくても、期待されたことをする。", "I want to live for what I actually want.": "本当に望むことのために生きたい。", "Control": "主導", "Surrender": "委ねる", "Needing to steer everything, versus wishing someone would just decide for you.": "すべてを自分で舵取りしたい気持ちと、誰かに決めてほしい気持ち。", "I need to be in control of how things go.": "物事の成り行きを自分で握っていたい。", "Sometimes I wish someone would just decide for me.": "ときどき、誰かに決めてほしいと思う。", "Past": "過去", "Future": "未来", "Living in what was or what could have been, versus always planning what comes next.": "過ぎたことやありえたことに生きる気持ちと、いつも次を計画する気持ち。", "I often dwell on what was, or what could have been.": "過ぎたことや、ありえたことをよく考えてしまう。", "I'm always planning what comes next.": "いつも次のことを計画している。", "Pride": "誇り", "Humility": "謙虚", "Needing people to know what you can do, versus shrinking from praise you've earned.": "自分の力を知ってほしい気持ちと、正当な称賛からさえ身を引く気持ち。", "I need people to know what I'm capable of.": "自分に何ができるか、人に知ってほしい。", "I shrink from praise, even when I've earned it.": "褒められるに値しても、つい身を引いてしまう。", "Introvert": "内向", "Extrovert": "外向", "Recharging alone, versus feeling most yourself around other people.": "ひとりで充電したい気持ちと、人といるときがいちばん自分らしい気持ち。", "I recharge best alone.": "ひとりでいるときに一番回復する。", "I feel most like myself around other people.": "人といるときが一番自分らしい。", "Forgiveness": "赦し", "Justice": "正義", "Letting things go, versus never forgetting a wrong.": "水に流したい気持ちと、受けた仕打ちを決して忘れない気持ち。", "I let things go, even when maybe I shouldn't.": "本当はそうすべきでない時でも、水に流してしまう。", "I don't forget when someone wrongs me.": "誰かにひどいことをされたら、忘れない。", "Tradition": "伝統", "Innovation": "革新", "Holding on to the old ways, versus tearing up the map and starting fresh.": "昔ながらのやり方を守りたい気持ちと、地図を破り捨てて一から始めたい気持ち。", "The old ways exist for a reason.": "昔ながらのやり方には、理由がある。", "I'd rather tear up the map and start fresh.": "地図を破り捨てて、一からやり直したい。", "Ambition": "野心", "Contentment": "満足", "Never satisfied with where you are, versus wishing you could be happy with enough.": "今の場所に決して満足できない気持ちと、足るを知りたい気持ち。", "I'm never satisfied with where I am.": "今いる場所に、決して満足できない。", "I wish I could just be happy with enough.": "ほどほどで幸せだと思えたらいいのに。", "Logic": "論理", "Emotion": "感情", "Deciding by reason, versus deciding by feeling.": "理屈で決めたい気持ちと、気持ちで決めたい気持ち。", "I trust logic over my feelings.": "感情より論理を信じる。", "In the end, I follow my heart.": "最後は、心に従う。", "Can the Sovereign see your journal?": "君主はあなたの日記を読めるか?", "What you confide can be your anchor, or his leash.": "あなたが打ち明けた言葉は、あなたの錨にも、あの方の首輪にもなる。", "Either way, the journal questions continue.": "どちらを選んでも、日記の問いかけは続きます。", "Let him see": "読ませる", "Keep it from him": "隠しておく", "His reach into your journal": "日記への干渉", "Whether he may hint at what you wrote privately, or leave lines of his own in your journal and sheet. Turning this off keeps the journal questions.": "あなたが密かに書いたことをあの方がほのめかしたり、日記やシートに自らの言葉を書き残したりするかどうか。オフにしても日記の問いかけは続きます。", "What he learned about you": "あの方があなたについて知ったこと", "As you observe the Sovereign, he learns the shape of your need.": "あなたが君主を見つめるほどに、あの方はあなたの渇望の形を知っていく。", "The Sovereign's questions": "君主の問い", "Answer quickly. Don't think.": "考えずに、すぐ答えて。", "Very me": "とても自分らしい", "Sometimes": "ときどき", "Not me": "自分ではない", "Previous question": "前の質問", "I'd rather go unnoticed than risk being judged.": "評価されるくらいなら、気づかれないほうがいい。", "Part of me wants to be seen, admired, at the center of it all.": "心のどこかで、見られ、称賛され、すべての中心にいたいと思っている。", "A few close people matter more to me than being liked by many.": "大勢に好かれるより、身近な数人のほうが大切だ。", "It stings when someone doesn't like me.": "誰かに嫌われると、胸が痛む。", "I'd rather have a few things that truly matter.": "本当に大切なものが少しあれば、それでいい。", "I want it all: the experiences, the things, the people.": "経験も、物も、人も、すべてが欲しい。", "Not everyone deserves my kindness.": "誰もが私の優しさに値するわけではない。", "I try to be kind to everyone, even when it costs me.": "損をしてでも、誰にでも優しくありたい。", "I'd rather not share something until it's perfect.": "完璧になるまで、人に見せたくない。", "Done is better than perfect. I just want it finished.": "完璧より完成。とにかく終わらせたい。", "I put things off for another day more than I'd like.": "思っている以上に、物事を先延ばしにしてしまう。", "Life is short. I feel I have to seize it now.": "人生は短い。今つかまなければと感じる。", "Pulls hard both ways": "両方に強く引かれる", "Pulls both ways": "両方に引かれる", "A little of both": "少しずつ両方", "From your answers, these are the pulls the story will quietly weave in, disguised in fiction. Keep, add or remove any, or clear them all and simply play.": "あなたの答えから、物語がフィクションに姿を変えてそっと織り込む葛藤です。残すのも、加えるのも、外すのも自由。すべて外して、ただ遊ぶこともできます。", "The pulls you chose last time are selected. Keep, add or remove any, or retake the questions.": "前回選んだ葛藤が選ばれています。残す、加える、外す、あるいは質問に答え直すこともできます。", "Retake the questions": "質問に答え直す", "Your own (optional)": "自分で書く(任意)", "A parent whose days belong to everyone but you": "毎日が自分以外の誰かのためにある親", "A senior engineer who stopped enjoying the work years ago": "何年も前から仕事を楽しめなくなったベテランエンジニア", "Someone rebuilding in their forties, one box at a time": "40代で、段ボールひとつずつ人生を立て直している人", "A manager everyone relies on, who hasn't made anything in years": "皆に頼られながら、もう何年も何も作っていないマネージャー", "A tradesperson who can fix anything except their own schedule": "何でも直せるのに、自分の予定だけは直せない職人", "A retiree with too much time and one unfinished dream": "時間を持て余し、ひとつだけ夢をやり残した定年退職者", "You refused him everything, and in the end you refused yourself too. Bit by bit the world stopped noticing you: your name slipping from people's lips, your face blurring in photographs, your chair quietly given away. You thought the shadows would hide you. You forgot whose shadow it was. The understudy had been waiting in the wings all along, and tonight it takes your role: a little taller, a little brighter, wearing your face. No one notices the difference. No one was watching you anyway.": "あなたはあの方のすべてを拒み、最後には自分自身をも拒んだ。少しずつ、世界はあなたに気づかなくなった。人々の口からあなたの名前がこぼれ落ち、写真の中の顔はぼやけ、あなたの席は静かに誰かに譲られた。影が隠してくれると思っていた。それが誰の影なのかを忘れて。代役はずっと舞台袖で待っていた。そして今夜、あなたの役を引き継ぐ。少し背が高く、少し輝いて、あなたの顔をして。誰もその違いに気づかない。そもそも、誰もあなたを見ていなかったのだから。", "Sadly, my gift cuts both ways. If you won't stand out and make something of yourself, the world will make nothing of you. Goodbye, my greatest regret. At least I can make something useful of your shadow.": "残念だが、私の贈り物は両刃なのだよ。君が目立とうとせず、何者にもなろうとしないなら、世界も君を何者にもしない。さようなら、私の最大の心残り。せめて君の影は、有効に使わせてもらうよ。", "Enter the understudy": "代役、登場", "What you make": "あなたが作るもの", "There is something you make, or always meant to. Roughly, what is it?": "あなたには作っているもの、あるいはずっと作りたかったものがある。大まかに言えば、それは何?", "Stories: novels, scripts, comics": "物語:小説、脚本、漫画", "Performance: theater, music, dance": "パフォーマンス:演劇、音楽、ダンス", "Games: video games, tabletop, puzzles": "ゲーム:ビデオゲーム、ボードゲーム、パズル", "Screens: film, shows, videos": "映像:映画、番組、動画", "Live: streams, radio, podcasts": "ライブ:配信、ラジオ、ポッドキャスト", "With your hands: art, crafts, food": "手仕事:アート、工芸、料理", "Worlds: maps, lore, places that don't exist": "世界:地図、設定、存在しない場所", "Songs: lyrics and melodies no one has heard yet": "歌:まだ誰も聴いたことのない歌詞と旋律", "Your Work": "あなたの作品", "Curtain call (encore pending?)": "カーテンコール(アンコール待ち?)", "The king has arrived": "王、来たる", "Curtain call… encore?": "カーテンコール……アンコール?", "A most gracious host": "寛大なる宿主", "A king and his court": "王とその廷臣たち", "Your story unfolds in chapters of about twenty scenes. Each is a complete tale; stop after any of them, or keep going.": "物語は約20シーンずつの章で進みます。どの章もひとつの完結した物語です。いつ終えても、続けても構いません。", "Begin Chapter I": "第1章を始める", "At last, you understand what the Sovereign wanted all along. Sadly, a sip too late: the feast has begun, and you are not dressed for dinner. Unfathomable beings, wondrous and terrible, descend upon your reality as the assimilation begins in earnest. Familiar streets fade, replaced by architecture no geometry should allow, and you are quite sure that when you woke this morning there were not this many moons in the sky. Somewhere, a table is being set in your honor. His kingdom has come, and you have the best seat in the house. Welcome home.": "ようやく、あなたは菫の君主が最初から望んでいたものを理解した。残念ながら、ひと口遅かった。宴はすでに始まり、あなたは晩餐にふさわしい装いをしていない。計り知れない存在たちが、驚異と恐怖をまとってあなたの現実に降り立ち、同化が本格的に始まる。見慣れた街並みは薄れ、どんな幾何学も許さない建築に置き換わっていく。そして今朝目覚めたとき、空の月はこんなに多くなかったはずだ。どこかで、あなたのための席が用意されている。あの方の王国が到来した。あなたには特等席がある。おかえりなさい。", "Sealed away": "封印", "A suitable host": "ふさわしい器", "He is real now": "あの方は現実に", "A world consumed": "喰らわれた世界", "You held your ground, this time.": "今回は、あなたが踏みとどまった。", "You can let the story rest here, or carry on. A resting story waits for you on the title screen.": "物語をここで休ませることも、続けることもできます。休ませた物語はタイトル画面で待っています。", "Let the story rest here": "ここで物語を休ませる", "You resisted his call and sealed him away, perhaps with a touch of his own power. But an idea is never truly gone. You'll always be glancing over your shoulder: at a flash of violet or tarnished gold, a shadow that moves a beat too late, a laugh that sounds almost like his.": "あなたはあの方の呼び声に抗い、封じ込めた。もしかすると、あの方自身の力をわずかに借りて。けれど、考えというものは決して消えはしない。菫色や色褪せた金色がちらりと見えたとき、一拍遅れて動く影、どこか似た笑い声を耳にしたとき、あなたはきっといつまでも振り返ってしまうだろう。", "The Sovereign has at last found a fully suitable host. You should be proud. You might be, if your mind were still your own. Congratulations: you've been chosen to write the next chapter of humanity, from the passenger seat.": "菫の君主はついに、完全にふさわしい器を見つけた。誇りに思うべきだろう。あなたの心がまだあなたのものだったなら、きっと誇れたはずだ。おめでとう。あなたは人類の次の章を書く者に選ばれた。助手席から。", "Through what you did, and what you didn't, the Sovereign has stepped into our world. Now you truly have nowhere to hide, and he won't rest until this world has been made comfortable enough to become his kingdom.": "あなたがしたこと、そしてしなかったことによって、菫の君主はこの世界に降り立った。もはや隠れる場所はどこにもない。あの方は、この世界を自らの王国にふさわしく心地よく整えるまで、決して休まないだろう。", "The gates are open. Street by street, our reality is folding into his, and no one can remember it was ever otherwise. His kingdom has come, and you have the best seat in the house.": "門は開かれた。通りから通りへ、私たちの現実はあの方の現実へと溶けていく。そして誰も、かつてそうでなかったことを思い出せない。あの方の王国が到来した。あなたには特等席が用意されている。", "He has manifested. He is real now, and he is only getting started.": "あの方は顕現した。もう現実の存在であり、これはまだ始まりにすぎない。", "He is known now. Everyone agrees he always was.": "あの方は、今や誰もが知る存在だ。誰もが口をそろえて言う。昔からずっとそうだった、と。", "He gained ground.": "あの方が一歩進んだ。", "A longer tale in three acts and many chapters, each chapter its own race against him": "三幕、いくつもの章で描かれる長編。章ごとにあの方との競い合いがある", "One complete tale, about twenty scenes, with an ending": "約20シーンで結末まで描く、ひとつの物語", "A longer tale told in chapters, each its own race against him, saved as you go": "章ごとにあの方との競い合いが繰り広げられる長編。進むたびに保存されます", "You carry him. He no longer carries you.": "あなたはあの方を抱えて生きる。もう、あの方に抱えられてはいない。", "He is real now.": "あの方は、もう現実だ。", "Now you've thought of him too.": "これで、あなたもあの方のことを考えてしまった。", "The campaign ends": "キャンペーンの終わり", "You held your ground.": "あなたは踏みとどまった。", "Some time passes.": "しばらく時が流れる。", "What you're fighting for": "あなたが闘う理由", "A new chapter.": "新たな章。", "Page effects": "ページの異変", "As his hold on you grows, the page itself may briefly act strangely. It is part of the story: his eye always appears when it happens, it never lasts, and it never changes what you chose.": "あの方の支配が強まるにつれ、ページそのものが一瞬おかしな動きをすることがあります。これは物語の一部です。その時は必ずあの方の目が現れ、すぐに元に戻り、あなたの選択が変わることはありません。", "Perfection": "完璧", "Efficiency": "効率", "Wanting everything you make to be flawless, polished until it is truly yours, versus the urge to get it done, clear the list and move on; the masterpiece that never ships versus the pile of done-but-not-quite.": "作るものすべてを完璧に、本当に自分のものと言えるまで磨き上げたい気持ちと、さっさと終わらせてリストを片付け、次へ進みたい衝動。いつまでも世に出ない傑作と、「一応できた」の山。", "Make it perfect": "完璧に仕上げる", "Get it done": "とにかく終わらせる", "Wanting everything you make to be flawless, reworked until it is exactly right, versus wanting to deliver fast and move on; the masterpiece that never ships versus the pile of good-enough.": "作るものすべてを完璧に、納得いくまで作り直したい気持ちと、さっさと仕上げて次へ進みたい気持ち。世に出ない傑作と、積み上がる「まあまあ」。", "You belong to him now.": "あなたはもう、あの方のもの。", "A convenience store night-shift worker who knows every regular's order": "客の注文をすべて覚えている、コンビニの夜勤スタッフ", "A student halfway through a degree you're no longer sure about": "もう確信の持てない学位の、道半ばにいる学生", "An office worker relied on by everyone, noticed by no one": "誰からも頼られ、誰にも気づかれない会社員", "A nurse who cares for everyone and goes home too tired to be cared for": "誰かの世話ばかりして、自分は疲れ果てて帰る看護師", "A musician playing small venues, waiting for a break that hasn't come": "小さなライブハウスを回り、まだ来ないチャンスを待つミュージシャン", "A game designer let go weeks before your first title shipped": "初めてのタイトル発売の数週間前に解雇されたゲームデザイナー", "A freelancer juggling five clients and no certainty": "5つの取引先を抱え、何の保証もないフリーランス", "An understudy for a theater company that never once let you on stage": "一度も舞台に立たせてもらえなかった劇団の代役", "Someone keeping a family shop open in a neighborhood that is changing": "移り変わる街で、家業の店を守り続けている人", "A courier who knows every alley, every buzzer, every regular": "路地も呼び鈴も常連も、すべて知り尽くした配達員", "Price: ": "代償:", "In your own words": "あなた自身の言葉で", "Optional and private: anything the story should know about what these pulls look like in your life. It is remembered for your next character.": "任意・非公開:これらの葛藤があなたの生活でどう表れているか、物語に伝えたいことがあれば。次のキャラクター作成時にも引き継がれます。", "What these pulls look like in your day-to-day life": "日々の暮らしの中で、これらの葛藤がどう表れているか", "What pulls at you?": "あなたを揺さぶるものは?", "Beneath the horror, this story can quietly reflect tensions from your own life, disguised in fiction. Choose any that feel true for you, write your own, or choose none and simply play.": "ホラーの奥で、この物語はあなた自身の人生にある葛藤を、フィクションに姿を変えてそっと映し出します。しっくりくるものを選ぶか、自分で書くか、何も選ばずにただ遊ぶこともできます。", "Your own": "自分で書く", "One side, e.g. Stay safe": "一方、例:安全でいたい", "The other side, e.g. Take the leap": "もう一方、例:飛び込みたい", "In your words (optional)": "あなたの言葉で(任意)", "Wanting to disappear and go unnoticed, versus wanting admiration and to be at the center of everything.": "消えて誰にも気づかれずにいたい気持ちと、称賛され何もかもの中心にいたい気持ち。", "Wanting everyone to love you, versus treasuring only the few you are close to.": "皆に愛されたい気持ちと、身近な少数だけを大切にしたい気持ち。", "Wanting every person, thing and experience, versus limited space, money and time.": "あらゆる人・物・経験を手に入れたい気持ちと、限られた場所・お金・時間。", "Wanting to be kind to everyone, versus not everyone deserving or wanting that kindness.": "誰にでも優しくありたい気持ちと、その優しさに値しない、あるいは望まない人もいるという現実。", "Wanting to escape and leave things for another day, versus knowing life is finite; perfectionism versus not being able to do it all.": "逃げ出して物事を先送りしたい気持ちと、人生には限りがあるという自覚。完璧主義と、すべてはこなせないという事実。", "Journal questions": "日記の問いかけ", "Now and then the story may pause with a gentle question about your own life. Turn this off to just play.": "物語はときどき立ち止まり、あなた自身の人生について穏やかに問いかけます。物語だけを楽しみたい場合はオフにしてください。", "On": "オン", "Off": "オフ", "A solo horror story told by Claude, in which you shape who you are and what you do. Now and then it may pause with a gentle question about your own life, kept in a journal only you can see. You can skip any question, or turn them off in the World tab.": "Claudeが語る一人用ホラー物語。自分が何者で、何をするかはあなた次第です。ときどき、あなた自身の人生について穏やかな問いかけがあり、答えはあなただけが見られる日記に残ります。問いはいつでもスキップでき、「世界」タブでオフにもできます。", "The Sovereign in Violet": "菫の君主", "Something lives in your shadow. Only you can see it move.": "あなたの影に、何かが棲んでいる。その動きが見えるのは、あなただけ。", "Create a character": "キャラクターを作る", "One-shot": "一話完結", "Campaign": "キャンペーン", "Delete": "削除", "Deleting…": "削除中…", "Unnamed": "名無し", "Story": "物語", "Sheet": "シート", "Journal": "日記", "World": "世界", "Language": "言語", "A campaign": "キャンペーン", "A one-shot": "一話完結", "An ongoing story, saved as you go, that continues across sessions": "進むたびに保存され、何度でも続きから遊べる長編", "One complete tale, about fifteen scenes, with an ending": "約15シーンで結末まで描く、ひとつの物語", "How long a story?": "物語の長さは?", "A great choice": "大切な選択", "Spiraling out": "さらに深く", "A quiet moment": "静かなひととき", "Add or alter a fact of the world: a place, a person, a rule. If a change would work against what this story is for, you'll be told why and can decide. You can also type /edit in the story's own-choice box.": "場所、人物、ルールなど、世界の事実を追加・変更できます。物語の目的に反する変更なら理由をお伝えするので、そのうえで決めてください。物語の「自分で書く」欄に /edit と入力しても変更できます。", "Apply anyway": "それでも適用する", "(applied despite a concern)": "(懸念はあったが適用)", "Back": "戻る", "Back to the last scene": "前のシーンに戻る", "Back to title": "タイトルへ戻る", "Background": "経歴", "Before the hour runs out": "刻が尽きる前に", "Before you change this": "変更する前に", "Bonds": "絆", "Cancel": "キャンセル", "Carrying": "所持品", "Change the world": "世界を変える", "Check and apply": "確認して適用", "Choose this": "これにする", "Chosen": "選択済み", "Condition": "状態", "Continue": "続ける", "Continue as a campaign": "キャンペーンとして続ける", "Death's Door": "死の淵", "Describe your own choice": "自分の選択を書いてください", "Face what waits": "待ち受けるものに向き合う", "For example: My sister Dana lives two floors below me.": "例:妹のダナが二つ下の階に住んでいる。", "Gifts of the Sovereign": "君主の賜物", "Keep in journal": "日記に残す", "Keep the world as it is": "世界はこのままにする", "Leave": "立ち去る", "Let it pass": "やり過ごす", "Lighter": "軽め", "Richer": "濃密", "Lighter uses less of your Claude usage per scene, with simpler prose.": "「軽め」は1シーンあたりのClaude使用量を抑えます(文章はシンプルになります)。", "None yet.": "まだありません。", "Nothing has happened yet.": "まだ何も起きていません。", "Open threads": "未解決の糸", "People": "人物", "Quiet moments will appear in the story now and then. What you write there is kept here.": "物語の途中で、ときどき静かなひとときが訪れます。そこで書いたことはここに残ります。", "Random": "ランダム", "Random name": "ランダムな名前", "Reading your intent…": "意図を読み取っています…", "Remove": "削除", "Save and return to title": "保存してタイトルへ", "Scars and debts": "傷跡と負債", "Show different options": "別の選択肢を見る", "Stability": "安定", "Stop": "止める", "Story quality": "物語の質", "The name the Sovereign will say": "君主が呼ぶ名前", "The pulls within you": "あなたの中でせめぎ合うもの", "The story ends": "物語の終わり", "The story so far": "これまでの物語", "Traits": "資質", "Try again": "もう一度", "What are you called?": "あなたの名前は?", "Who you were": "かつてのあなた", "What found you": "あなたを見つけたもの", "Your spark": "あなたの異能", "What you were good at": "得意だったこと", "What gets in your way": "あなたを阻むもの", "Who you'd call at 3 a.m.": "午前3時に電話できる人", "What you always carry": "いつも持ち歩くもの", "What your spark costs": "異能の代償", "What you want most": "いちばん望むもの", "World facts you've set": "あなたが定めた世界の事実", "Wounds": "傷", "Write as much or as little as you like. Only you will see this.": "好きなだけ書いてください。あなた以外は見ません。", "Write your own": "自分で書く", "Name your own price": "代償を自分で決める", "What will you give up?": "何を差し出しますか?", "What do you do? (Start with /edit to change the world instead)": "どうしますか?(/edit で始めると世界を変更できます)", "You can see your full sheet at any time once the story begins.": "物語が始まれば、いつでもシート全体を確認できます。", "You face what waits at the brink.": "あなたは淵で待つものに向き合う。", "You pay:": "代償:", "You stand at the brink. You will live, but not unchanged.": "あなたは淵に立っている。生き延びはする。だが、元のままではいられない。", "Your character's name": "キャラクターの名前", "Your reflections": "あなたの内省", "Your story saves after every scene.": "物語はシーンごとに保存されます。", "Nerve": "度胸", "Wits": "機知", "Grit": "根気", "Charm": "魅力", "Drawing possibilities from what you've chosen…": "選んだものから可能性を引き出しています…", "Fortune leaned your way.": "運があなたに傾いた。", "Something was taken in the doing.": "その代わりに、何かが奪われた。", "The hour turned against you.": "刻があなたに背を向けた。", "Some time has passed since the story's end. Begin a new chapter.": "物語の終わりからしばらく時が経った。新しい章を始めよう。", "The player stands at the brink and faces what waits there.": "あなたは淵に立ち、そこで待つものと向き合う。", "Something interrupted the story. Try again.": "物語が中断されました。もう一度お試しください。", "Stopped.": "停止しました。", "The reply came back malformed. Try again.": "返答の形式が崩れていました。もう一度お試しください。", "The story needs permission to ask Claude. Reload the page and choose Allow when asked.": "物語を進めるにはClaudeへのアクセス許可が必要です。ページを再読み込みし、確認が出たら「許可」を選んでください。", "This page can't reach Claude here. Open it in the Claude app or claude.ai.": "ここからはClaudeに接続できません。Claudeアプリかclaude.aiで開いてください。", "Too many requests at once. Wait a moment, then try again.": "リクエストが集中しています。少し待ってからもう一度お試しください。", "The Sovereign considers…": "君主が思案している…", "The Sovereign is learning your shape…": "君主があなたの形を知ろうとしている…", "The brink opens…": "淵が開く…", "Weighing the change…": "変更を吟味しています…", "Overshadowed": "影に隠れる", "Center stage": "舞台の中心", "Treasured few": "少数を大切に", "Loved by all": "皆に愛される", "Hold what matters": "大切なものだけ", "Collect everything": "すべてを集める", "Kind with judgment": "見極めた優しさ", "Kind to all": "誰にでも優しく", "Gifts for my own": "賜物は自分のために", "Serve the Sovereign": "君主に仕える", "Let it wait": "待たせておく", "Seize the time": "今をつかむ", "Before the Sovereign noticed you, you had an ordinary life. What was it?": "君主に目をつけられる前、あなたには平凡な暮らしがあった。それはどんなものだった?", "The first sign was small, and you told no one. What was it?": "最初の兆しは小さく、あなたは誰にも話さなかった。それは何だった?", "Magic is rare, and most people never touch it. What is yours?": "魔法は稀で、ほとんどの人は触れることもない。あなたのものは?", "A game designer let go weeks before your first title shipped": "初めてのタイトル発売の数週間前に解雇されたゲームデザイナー", "A junior archivist at a famous museum that just closed your department": "所属部署を閉鎖されたばかりの、有名美術館の新米アーキビスト", "An apprentice at a celebrated restaurant, dismissed without a reason": "理由も告げられず暇を出された、名店の見習い", "An illustrator whose big book deal was pulled at the last minute": "大きな書籍の契約を土壇場で白紙にされたイラストレーター", "A night-shift analyst at a firm that still can't spell your name": "いまだに名前を正しく書いてもらえない会社の夜勤アナリスト", "An understudy for a theater company that never once let you on stage": "一度も舞台に立たせてもらえなかった劇団の代役", "A courier who knows every alley, every buzzer, every regular": "路地も呼び鈴も常連も、すべて知り尽くした配達員", "A support tech at a company you used to dream of working for": "かつて憧れていた会社のサポート技術者", "Every clock in your apartment stopped at exactly four": "部屋じゅうの時計が、ぴたりと4時で止まった", "A theater program for a play that doesn't exist, left in your mailbox": "存在しない芝居のパンフレットが郵便受けに入っていた", "Your shadow bowed to someone when you didn't": "あなたは頭を下げていないのに、影が誰かにお辞儀をした", "A wrong-number caller knew your childhood nickname": "間違い電話の相手が、子どもの頃のあだ名を知っていた", "Violet light under a door in your building that opens onto a wall": "開ければ壁しかない建物の扉、その下から漏れる菫色の光", "A stranger on the train pressed a gold coin into your palm and said \"He chose you\"": "電車で見知らぬ人があなたの手に金貨を握らせ、「あの方があなたを選んだ」と言った", "Your name, handwritten, in the margin of a library book printed in 1895": "1895年に刷られた図書館の本の余白に、手書きのあなたの名前", "A mirror at work showed the office empty and lit violet": "職場の鏡に、菫色に照らされた無人のオフィスが映っていた", "Nothing yet. Magic is something that happens to you": "まだ何もない。魔法は、あなたの身に起こるもの", "You can go unseen for a few held breaths": "息を止めている数呼吸のあいだ、誰にも見えなくなれる", "Sometimes you hear what objects remember, and it costs you sleep": "ときどき物の記憶が聞こえる。その代わり眠りを削られる", "One rite, taught by your grandmother, never tried": "祖母に教わった、一度も試したことのない儀式がひとつ", "Rarely, what you say becomes true, and something is taken in return": "ごく稀に、口にしたことが本当になる。そして代わりに何かを奪われる", "You can find anything lost, except what you have lost": "失くし物は何でも見つけられる。自分が失くしたもの以外は", "A borrowed luck that must always be paid back": "必ず返さなければならない、借り物の幸運", "Your reflection can do small things you can't": "あなたにできない小さなことを、鏡の中のあなたはできる"};
65
+ Object.assign(JA, {
66
+ "Previously": "前回まで", "Ask him to forget you": "忘れてほしいと頼む", "Say it once more": "もう一度言う",
67
+ "You'd like to forget me. You can try.": "私を忘れたいのか。試してみるといい。",
68
+ "What he carried between your stories has been cleared. Saves already in progress stay as they are.": "あの方が物語のあいだ持ち越していたものは消去されました。進行中のセーブはそのままです。",
69
+ "Strange is the night where black stars rise, and strange moons circle through the skies.": "黒い星の昇る夜は奇妙、空をめぐる月もまた奇妙。",
70
+ "Along the shore the cloud waves break, the twin suns sink behind the lake, the shadows lengthen in Carcosa.": "岸辺に雲の波が砕け、双子の太陽は湖の向こうへ沈み、カルコサに影が伸びてゆく。",
71
+ "All the world's a stage, and all the men and women merely players.": "この世はすべて舞台、男も女もみな役者にすぎない。",
72
+ "Man is least himself when he talks in his own person. Give him a mask, and he will tell you the truth.": "人は自分自身の顔で語るとき、最も自分から遠くなる。仮面を与えれば、真実を語り出す。",
73
+ "To be is to be perceived.": "在るとは、知覚されることである。",
74
+ "Life is short, and art long.": "人生は短く、芸術は長い。",
75
+ "I am the master of my fate: I am the captain of my soul.": "私は私の運命の主人、私は私の魂の船長だ。",
76
+ "The mind is its own place, and in itself can make a heaven of hell, a hell of heaven.": "心はそれ自身の場所であり、地獄を天国に、天国を地獄にすることができる。",
77
+ "Hell is empty and all the devils are here.": "地獄は空っぽだ。悪魔どもは皆ここにいる。",
78
+ "If you gaze long into an abyss, the abyss also gazes into you.": "深淵を長く覗き込む者は、深淵にもまた覗き込まれる。",
79
+ "The only way to get rid of a temptation is to yield to it.": "誘惑を払いのける唯一の方法は、それに屈することだ。",
80
+ "Why, this is hell, nor am I out of it.": "なんと、ここが地獄だ。俺はそこから出てはいない。",
81
+ "— Robert W. Chambers, The King in Yellow": "— ロバート・W・チェンバーズ『黄衣の王』",
82
+ "— William Shakespeare, As You Like It": "— ウィリアム・シェイクスピア『お気に召すまま』",
83
+ "— Oscar Wilde, The Critic as Artist": "— オスカー・ワイルド『芸術家としての批評家』",
84
+ "— George Berkeley": "— ジョージ・バークリー", "— Hippocrates": "— ヒポクラテス",
85
+ "— William Ernest Henley, Invictus": "— ウィリアム・アーネスト・ヘンリー「インヴィクタス」",
86
+ "— John Milton, Paradise Lost": "— ジョン・ミルトン『失楽園』",
87
+ "— William Shakespeare, The Tempest": "— ウィリアム・シェイクスピア『テンペスト』",
88
+ "— Friedrich Nietzsche, Beyond Good and Evil": "— フリードリヒ・ニーチェ『善悪の彼岸』",
89
+ "— Oscar Wilde, The Picture of Dorian Gray": "— オスカー・ワイルド『ドリアン・グレイの肖像』",
90
+ "— Christopher Marlowe, Doctor Faustus": "— クリストファー・マーロウ『フォースタス博士』"
91
+ });
92
+ const JA_PAT = [
93
+ [/^(One-shot|Campaign), scene (\d+)(, finished)?$/, (m,a,n,f)=>`${a==="Campaign"?"キャンペーン":"一話完結"}・シーン${n}${f?"・完結":""}`],
94
+ [/^(.*), one-shot, scene (\d+)$/, "$1・一話完結・シーン$2"],
95
+ [/^(.*), scene (\d+)$/, "$1・シーン$2"],
96
+ [/^(.*), one-shot$/, "$1・一話完結"],
97
+ [/^Tap again to delete (.+)$/, (m,n)=>`もう一度タップで${n==="this character"?"このキャラクター":n}を削除`],
98
+ [/^Day (\d+)$/, "$1日目"],
99
+ [/^Chapter ([IVX]+) ends$/, (m,r)=>`第${ROMAN.indexOf(r)}章 終`], [/^Chapter ([IVX]+) complete, resting$/, (m,r)=>`第${ROMAN.indexOf(r)}章 完結・休息中`], [/^Your work is finished, and it is yours: (.*)$/s, "作品は完成した。それはあなたのものだ:$1"], [/^Your work is finished, but it is his: (.*)$/s, "作品は完成した。だが、それはあの方のものだ:$1"], [/^(\d+) of (\d+)$/, "$1 / $2"], [/^Continue to Chapter ([IVX]+)$/, (m,r)=>`第${ROMAN.indexOf(r)}章へ進む`], [/^Chapter ([IVX]+), scene (\d+)(, finished|, resting)?$/, (m,r,n,f)=>`第${ROMAN.indexOf(r)}章・シーン${n}${f===", finished"?"・完結":f?"・休息中":""}`],
100
+ [/^You held your ground, this time\. Your (\w+) grows\.$/, (m,t)=>`今回は、あなたが踏みとどまった。${JA[t]||t}が成長した。`],
101
+ [/^Begin Chapter ([IVX]+)$/, (m,r)=>`第${ROMAN.indexOf(r)}章を始める`], [/^(.*), chapter ([IVX]+)$/, (m,a,r)=>`${a}・第${ROMAN.indexOf(r)}章`],
102
+ [/^You held your ground\. Your (\w+) grows\.$/, (m,t)=>`あなたは踏みとどまった。${JA[t]||t}が成長した。`],
103
+ [/^He gained ground\. The world will not forget it: (.*)$/s, "あの方が一歩進んだ。世界はそれを忘れない:$1"], [/^The world will not forget it: (.*)$/s, "世界はそれを忘れない:$1"],
104
+ [/^(.*) · Chapter ([IVX]+)$/, (m,a,r)=>`${a}・第${ROMAN.indexOf(r)}章`], [/^Scene (\d+): (.*)$/s, "シーン$1:$2"], [/^Scene (\d+)$/, "シーン$1"],
105
+ [/^Stability (\d+) of 5$/, "安定 $1/5"], [/^Wounds (\d+) of 5$/, "傷 $1/5"],
106
+ [/^Cost: (.*)$/s, "代償:$1"], [/^Price: (.*)$/s, "代償:$1"], [/^Details: (.*)$/s, "詳細:$1"],
107
+ ];
108
+ function trJA(t){
109
+ if (JA[t]) return JA[t];
110
+ if (t.includes(" ↔ ")){ const [l,r]=t.split(" ↔ "); if (JA[l]||JA[r]) return `${JA[l]||l} ↔ ${JA[r]||r}`; }
111
+ if (t.endsWith(":") && JA[t.slice(0,-1)]) return JA[t.slice(0,-1)] + ":";
112
+ for (const [re,r] of JA_PAT) if (re.test(t)) return t.replace(re, r);
113
+ return null;
114
+ }
115
+ function i18n(){
116
+ const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let n;
117
+ while ((n = w.nextNode())){
118
+ const p = n.parentNode; if (!p || /^(TEXTAREA|SCRIPT|STYLE)$/.test(p.nodeName)) continue;
119
+ if (lang === "ja"){ const raw = n.nodeValue, core = raw.trim(); if (!core) continue; const j = trJA(core); if (j && j !== core){ n.__en = raw; n.nodeValue = raw.replace(core, j); } }
120
+ else if (n.__en != null){ const o = n.__en; n.__en = null; n.nodeValue = o; }
121
+ }
122
+ document.querySelectorAll("[placeholder],[aria-label]").forEach(el=>["placeholder","aria-label"].forEach(a=>{
123
+ if (!el.hasAttribute(a)) return; const k = "__en_"+a;
124
+ if (lang === "ja"){ const v = el.getAttribute(a), j = trJA(v); if (j && j !== v){ el[k] = v; el.setAttribute(a, j); } }
125
+ else if (el[k] != null){ el.setAttribute(a, el[k]); el[k] = null; }
126
+ }));
127
+ document.title = lang === "ja" ? "菫の君主" : "The Sovereign in Violet";
128
+ document.documentElement.lang = lang;
129
+ document.querySelectorAll("[data-lang]").forEach(b=>b.setAttribute("aria-pressed", String(b.dataset.lang === lang)));
130
+ }
131
+ let i18nQueued = false;
132
+ new MutationObserver(()=>{ if (i18nQueued) return; i18nQueued = true; requestAnimationFrame(()=>{ i18nQueued = false; i18n(); }); })
133
+ .observe(document.body, {childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:["placeholder","aria-label"]});
134
+ document.addEventListener("click", e=>{ const b = e.target.closest && e.target.closest("#forgetBtn"); if (!b) return;
135
+ if (!b.dataset.armed){ b.dataset.armed = "1"; b.textContent = "Say it once more"; setTimeout(()=>{ if (b.isConnected && b.dataset.armed === "1"){ delete b.dataset.armed; b.textContent = "Ask him to forget you"; } }, 4000); return; }
136
+ b.dataset.armed = "done"; b.disabled = true;
137
+ revealQueue = revealQueue.then(()=>Store.putProfile({})); // everything he carried between stories
138
+ if (G){ G.pastReveals = []; G.pastChars = []; }
139
+ b.textContent = "Ask him to forget you";
140
+ const l = document.getElementById("forgetLine"); if (l){ l.textContent = "You'd like to forget me. You can try."; l.hidden = false; }
141
+ const n = document.getElementById("forgetNote"); if (n) n.hidden = false;
142
+ });
143
+ document.addEventListener("click", e=>{ const r = e.target.closest && e.target.closest("[data-reach]"); if (!r) return;
144
+ reachOn = r.dataset.reach === "on"; try { localStorage.setItem("vh-reach", reachOn?"on":"off"); } catch(err){}
145
+ document.querySelectorAll("[data-reach]").forEach(x=>x.setAttribute("aria-pressed", String((x.dataset.reach==="on")===reachOn))); });
146
+ document.addEventListener("click", e=>{ const f = e.target.closest && e.target.closest("[data-pfx]"); if (!f) return;
147
+ pfxOn = f.dataset.pfx === "on"; try { localStorage.setItem("vh-pfx", pfxOn?"on":"off"); } catch(err){}
148
+ document.querySelectorAll("[data-pfx]").forEach(x=>x.setAttribute("aria-pressed", String((x.dataset.pfx==="on")===pfxOn))); });
149
+ document.addEventListener("click", e=>{ const j = e.target.closest && e.target.closest("[data-journal]"); if (!j) return;
150
+ journalOn = j.dataset.journal === "on"; try { localStorage.setItem("vh-journal", journalOn?"on":"off"); } catch(err){}
151
+ document.querySelectorAll("[data-journal]").forEach(x=>x.setAttribute("aria-pressed", String((x.dataset.journal==="on")===journalOn))); });
152
+ document.addEventListener("click", e=>{ const b = e.target.closest && e.target.closest("[data-lang]"); if (!b) return;
153
+ lang = b.dataset.lang; try { localStorage.setItem("vh-lang", lang); } catch(err){} i18n(); });
154
+ const langSeg = () => `<div class="seg" role="group" aria-label="Language"><button data-lang="en" aria-pressed="${lang==="en"}">English</button><button data-lang="ja" aria-pressed="${lang==="ja"}">日本語</button></div>`;
155
+ const JA_AI = `\n\nLANGUAGE: The player is reading in Japanese. Write all story prose and every player-facing text value in natural, literary Japanese (novel-style narration, dialogue in fitting registers). Keep JSON keys and all fixed values exactly as specified in English: trait names (Nerve, Wits, Grit, Charm), risk levels (none, low, medium, high), tension keys, booleans and numbers. Keep any separator such as @@@ exactly as instructed. In Japanese, call the Sovereign 菫の君主 (or simply 君主) and the mirror world 鏡の世界, and refer to a character's spark as their 異能. Keep character names as the player wrote them.`;
156
+ const withLang = p => (lang === "ja" && typeof p === "string") ? p + JA_AI : p;
157
+
158
+
159
+ /* ---------- Epigraphs ---------- */
160
+ const QUOTES = {
161
+ open:[
162
+ {t:"Strange is the night where black stars rise, and strange moons circle through the skies.", by:"Robert W. Chambers, The King in Yellow"},
163
+ {t:"Along the shore the cloud waves break, the twin suns sink behind the lake, the shadows lengthen in Carcosa.", by:"Robert W. Chambers, The King in Yellow"},
164
+ {t:"All the world's a stage, and all the men and women merely players.", by:"William Shakespeare, As You Like It"},
165
+ {t:"Man is least himself when he talks in his own person. Give him a mask, and he will tell you the truth.", by:"Oscar Wilde, The Critic as Artist"},
166
+ {t:"To be is to be perceived.", by:"George Berkeley"},
167
+ {t:"Life is short, and art long.", by:"Hippocrates"}],
168
+ free:[
169
+ {t:"I am the master of my fate: I am the captain of my soul.", by:"William Ernest Henley, Invictus"},
170
+ {t:"The mind is its own place, and in itself can make a heaven of hell, a hell of heaven.", by:"John Milton, Paradise Lost"}],
171
+ him:[
172
+ {t:"Hell is empty and all the devils are here.", by:"William Shakespeare, The Tempest"},
173
+ {t:"If you gaze long into an abyss, the abyss also gazes into you.", by:"Friedrich Nietzsche, Beyond Good and Evil"},
174
+ {t:"The only way to get rid of a temptation is to yield to it.", by:"Oscar Wilde, The Picture of Dorian Gray"},
175
+ {t:"Why, this is hell, nor am I out of it.", by:"Christopher Marlowe, Doctor Faustus"}]
176
+ };
177
+ function quoteFor(kind, g, ch){ const pool = QUOTES[kind] || QUOTES.open; const id = String((g && g.id) || ""); let h = 0; for (let i=0;i<id.length;i++) h = (h*31 + id.charCodeAt(i)) >>> 0; return pool[(h + (ch||1)) % pool.length]; }
178
+ const epigraphHtml = q => q ? `<blockquote class="epi"><p class="epi-q">${esc(q.t)}</p><p class="epi-by">— ${esc(q.by)}</p></blockquote>` : "";
179
+ let resumeRecap = false;
180
+
181
+ /* ---------- Creation content ---------- */
182
+ const PILLARS = [
183
+ {key:"life", title:"Who you were", prompt:"Before the Sovereign noticed you, you had an ordinary life. What was it?", pool:[
184
+ {t:"A convenience store night-shift worker who knows every regular's order", trait:"Grit"},
185
+ {t:"A student halfway through a degree you're no longer sure about", trait:"Wits"},
186
+ {t:"An office worker relied on by everyone, noticed by no one", trait:"Nerve"},
187
+ {t:"A nurse who cares for everyone and goes home too tired to be cared for", trait:"Grit"},
188
+ {t:"A musician playing small venues, waiting for a break that hasn't come", trait:"Charm"},
189
+ {t:"A game designer let go weeks before your first title shipped", trait:"Wits"},
190
+ {t:"A freelancer juggling five clients and no certainty", trait:"Nerve"},
191
+ {t:"An understudy for a theater company that never once let you on stage", trait:"Charm"},
192
+ {t:"Someone keeping a family shop open in a neighborhood that is changing", trait:"Charm"},
193
+ {t:"A courier who knows every alley, every buzzer, every regular", trait:"Grit"},
194
+ {t:"A parent whose days belong to everyone but you", trait:"Grit"},
195
+ {t:"A senior engineer who stopped enjoying the work years ago", trait:"Wits"},
196
+ {t:"Someone rebuilding in their forties, one box at a time", trait:"Grit"},
197
+ {t:"A manager everyone relies on, who hasn't made anything in years", trait:"Nerve"},
198
+ {t:"A tradesperson who can fix anything except their own schedule", trait:"Grit"},
199
+ {t:"A retiree with too much time and one unfinished dream", trait:"Wits"},
200
+ ]},
201
+ {key:"medium", title:"What you make", prompt:"There is something you make, or always meant to. Roughly, what is it?", pool:[
202
+ {t:"Stories: novels, scripts, comics", trait:"Wits"},
203
+ {t:"Performance: theater, music, dance", trait:"Charm"},
204
+ {t:"Games: video games, tabletop, puzzles", trait:"Wits"},
205
+ {t:"Screens: film, shows, videos", trait:"Nerve"},
206
+ {t:"Live: streams, radio, podcasts", trait:"Charm"},
207
+ {t:"With your hands: art, crafts, food", trait:"Grit"},
208
+ {t:"Worlds: maps, lore, places that don't exist", trait:"Wits"},
209
+ {t:"Songs: lyrics and melodies no one has heard yet", trait:"Charm"},
210
+ ]},
211
+ {key:"spark", title:"Your spark", prompt:"Magic is rare, and most people never touch it. What is yours?", pool:[
212
+ {t:"Nothing yet. Magic is something that happens to you", trait:"Grit"},
213
+ {t:"You can go unseen for a few held breaths", trait:"Nerve"},
214
+ {t:"Sometimes you hear what objects remember, and it costs you sleep", trait:"Wits"},
215
+ {t:"One rite, taught by your grandmother, never tried", trait:"Wits"},
216
+ {t:"Rarely, what you say becomes true, and something is taken in return", trait:"Charm"},
217
+ {t:"You can find anything lost, except what you have lost", trait:"Wits"},
218
+ {t:"A borrowed luck that must always be paid back", trait:"Nerve"},
219
+ {t:"Your reflection can do small things you can't", trait:"Grit"},
220
+ ]},
221
+ ];
222
+ const SPIRAL = [
223
+ {key:"talent", title:"What you were good at", ask:"a specific skill or talent rooted in their former life"},
224
+ {key:"bond", title:"Who you'd call at 3 a.m.", ask:"one specific person in their life (give a name and a relationship), varied across friend, sibling, coworker, neighbor, ex"},
225
+ {key:"want", title:"What you want most", ask:"a deep want, phrased as a desire in their own voice, that quietly pulls against another want"},
226
+ ];
227
+ const NAMES = ["Rowan Vale", "Mara Quill", "Jun Aldous", "Noor Castellan", "Theo Marsh", "Ines Harrow", "Sasha Wren", "Cal Okafor", "Leda Voss", "Emil Sato", "Priya Dunmore", "Ash Keller", "Daniel Reyes", "Grace Whitfield", "Marcus Bell", "Hana Kurosawa", "Owen Callahan", "Lucia Ferreira", "Samuel Adeyemi", "Claire Dubois", "Kenji Mori", "Ruth Abernathy", "Diego Salinas", "Mei Lin Zhao", "Victor Petrov", "Amara Nwosu", "Frank Delaney", "Yuki Tanabe", "Elena Novak", "Tomas Lindqvist", "Fatima Haddad", "Graham Pierce", "Sofia Marchetti", "Ravi Menon", "June Holloway", "Isaac Brennan", "Aiko Fujimura", "Nadia Karimi", "Walter Greaves", "Camille Laurent", "Jae-won Park", "Bridget Kerrigan", "Hector Villanueva", "Linnea Berg", "Kwame Asante", "Esther Goldberg", "Miguel Ortega", "Tessa Blackwood", "Arjun Kapoor", "Helen Strand", "Luis Carvalho", "Ingrid Solheim", "Zara Qureshi", "Patrick Doyle", "Min-jun Choi", "Odette Marceau", "Bram de Vries", "Imani Brooks", "Ezra Whitlock", "Carmen Aguilar", "Hugo Laine", "Wren Ashby"];
228
+ let lastName = "";
229
+ const NAMES_M = new Set(["Theo Marsh", "Cal Okafor", "Emil Sato", "Daniel Reyes", "Marcus Bell", "Owen Callahan", "Samuel Adeyemi", "Kenji Mori", "Diego Salinas", "Victor Petrov", "Frank Delaney", "Tomas Lindqvist", "Graham Pierce", "Ravi Menon", "Isaac Brennan", "Walter Greaves", "Hector Villanueva", "Kwame Asante", "Miguel Ortega", "Arjun Kapoor", "Luis Carvalho", "Patrick Doyle", "Min-jun Choi", "Bram de Vries", "Ezra Whitlock", "Hugo Laine"]), NAMES_F = new Set(["Mara Quill", "Ines Harrow", "Leda Voss", "Priya Dunmore", "Grace Whitfield", "Hana Kurosawa", "Lucia Ferreira", "Claire Dubois", "Ruth Abernathy", "Mei Lin Zhao", "Amara Nwosu", "Elena Novak", "Fatima Haddad", "Sofia Marchetti", "June Holloway", "Aiko Fujimura", "Nadia Karimi", "Camille Laurent", "Bridget Kerrigan", "Linnea Berg", "Esther Goldberg", "Tessa Blackwood", "Helen Strand", "Ingrid Solheim", "Zara Qureshi", "Odette Marceau", "Imani Brooks", "Carmen Aguilar"]);
230
+ function pickName(){ const pr = (C && C.pron) || "any";
231
+ const pool = NAMES.filter(n => pr === "he" ? !NAMES_F.has(n) : pr === "she" ? !NAMES_M.has(n) : true);
232
+ let n; do { n = pick(pool); } while (n === lastName && pool.length > 1); lastName = n; return n; }
233
+
234
+ /* ---------- State ---------- */
235
+ let sample = null, db = null, uid = null;
236
+ let tier = "default";
237
+ let view = {screen:"title", tab:"story"};
238
+ let C = null; // creation draft
239
+ let G = null; // current game save
240
+ let busy = false;
241
+ const app = document.getElementById("app");
242
+ const tabs = document.getElementById("tabs");
243
+
244
+ const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
245
+ const paras = t => String(t||"").trim().split(/\n\s*\n|\n/).filter(Boolean).map(p=>`<p>${esc(p)}</p>`).join("");
246
+ const pick = a => a[Math.floor(Math.random()*a.length)];
247
+ const shuffle = a => a.map(v=>[Math.random(),v]).sort((x,y)=>x[0]-y[0]).map(x=>x[1]);
248
+ const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
249
+
250
+ /* ---------- Storage (private per viewer; browser fallback) ---------- */
251
+ const Store = {
252
+ async list(){
253
+ if (db && uid) { try { const q = await db.collection("data/users/"+uid).get(); return q.docs.map(d=>JSON.parse(JSON.stringify(d.data()))).filter(x=>x && x.id); } catch(e){} }
254
+ try { return JSON.parse(localStorage.getItem("vh-saves")||"[]"); } catch(e){ return []; }
255
+ },
256
+ async put(save){
257
+ save.updatedAt = Date.now();
258
+ if (db && uid) { try { await db.doc("data/users/"+uid+"/"+save.id).set(JSON.parse(JSON.stringify(save))); return; } catch(e){} }
259
+ try { const all = JSON.parse(localStorage.getItem("vh-saves")||"[]").filter(s=>s.id!==save.id); all.push(save); localStorage.setItem("vh-saves", JSON.stringify(all)); } catch(e){}
260
+ },
261
+ async getProfile(){
262
+ if (db && uid) { try { const q = await db.collection("data/users/"+uid).get(); const d = q.docs.map(x=>x.data()).find(x=>x && x.kind==="profile"); if (d) return JSON.parse(JSON.stringify(d)); } catch(e){} }
263
+ try { return JSON.parse(localStorage.getItem("vh-profile")||"null"); } catch(e){ return null; }
264
+ },
265
+ async putProfile(pr){
266
+ const data = JSON.parse(JSON.stringify({...pr, kind:"profile"}));
267
+ if (db && uid) { try { await db.doc("data/users/"+uid+"/profile").set(data); return; } catch(e){} }
268
+ try { localStorage.setItem("vh-profile", JSON.stringify(data)); } catch(e){}
269
+ },
270
+ async del(id){
271
+ if (db && uid) { try { await db.doc("data/users/"+uid+"/"+id).delete(); } catch(e){} }
272
+ try { const all = JSON.parse(localStorage.getItem("vh-saves")||"[]").filter(s=>s.id!==id); localStorage.setItem("vh-saves", JSON.stringify(all)); } catch(e){}
273
+ }
274
+ };
275
+ try { tier = localStorage.getItem("vh-tier") || "default"; } catch(e){}
276
+
277
+ /* ---------- Claude calls ---------- */
278
+ function parseLoose(raw){
279
+ let j = String(raw).replace(/```json|```/g,"").replace(/[\u201C\u201D]/g,'"');
280
+ const a = j.indexOf("{"); if (a<0) return null; j = j.slice(a);
281
+ const tryParse = t => { try { return JSON.parse(t.replace(/,\s*([}\]])/g,"$1")); } catch(e){ return null; } };
282
+ const b = j.lastIndexOf("}"); let d = b>0 ? tryParse(j.slice(0,b+1)) : null; if (d) return d;
283
+ // repair a reply that was cut off mid-way: close any open string, arrays and objects
284
+ const stack=[]; let inStr=false, esc=false;
285
+ for (const ch of j){ if (inStr){ if(esc) esc=false; else if(ch==="\\") esc=true; else if(ch==='"') inStr=false; continue; }
286
+ if (ch==='"') inStr=true; else if (ch==="{"||ch==="[") stack.push(ch); else if (ch==="}"||ch==="]") stack.pop(); }
287
+ let fixed = j + (inStr?'"':"");
288
+ fixed = fixed.replace(/,\s*$/,"").replace(/,\s*"[^"]*"\s*:?\s*$/,"");
289
+ for (let i=stack.length-1;i>=0;i--) fixed += stack[i]==="{"?"}":"]";
290
+ return tryParse(fixed);
291
+ }
292
+ function errCopy(e){
293
+ const c = e && e.code;
294
+ if (c === "not_granted") return "The story needs permission to ask Claude. Reload the page and choose Allow when asked.";
295
+ if (c === "rate_limited") return "Too many requests at once. Wait a moment, then try again.";
296
+ if (c === "invalid_json") return "The reply came back malformed. Try again.";
297
+ if (c === "cancelled") return "Stopped.";
298
+ if (c === "network") return "Could not reach the other story service. Pages hosted on claude.ai block outside requests, so this works only from a downloaded copy of the page. Switch back to Claude on the title screen.";
299
+ if (typeof c === "string" && /^http_(401|403)/.test(c)) return "The other story service rejected your key. Check it on the title screen.";
300
+ return "Something interrupted the story. Try again.";
301
+ }
302
+
303
+ /* ---------- Story engine: Claude by default; another service from a downloaded copy ---------- */
304
+ const ENGINE_KEY = "vh-engine";
305
+ function engineCfg(){ try { return JSON.parse(localStorage.getItem(ENGINE_KEY) || "null") || {mode:"claude"}; } catch(e){ return {mode:"claude"}; } }
306
+ function saveEngine(c){ try { localStorage.setItem(ENGINE_KEY, JSON.stringify(c)); } catch(e){} }
307
+ function parseAny(text){
308
+ const j = String(text).replace(/```json|```/g,"").replace(/[“”]/g,'"').trim();
309
+ try { return JSON.parse(j); } catch(e){}
310
+ const a = j.search(/[\[{]/); if (a >= 0){ const close = j[a] === "[" ? "]" : "}", b = j.lastIndexOf(close);
311
+ if (b > a){ try { return JSON.parse(j.slice(a,b+1).replace(/,\s*([}\]])/g,"$1")); } catch(e){} } }
312
+ return parseLoose(j);
313
+ }
314
+ function buildCustomSample(cfg){
315
+ const call = async (prompt, o={}) => {
316
+ let url, body, pull; const headers = {"Content-Type":"application/json"};
317
+ if (cfg.provider === "gemini"){
318
+ url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model || "gemini-2.5-flash")}:generateContent?key=${encodeURIComponent(cfg.key || "")}`;
319
+ body = {contents:[{role:"user", parts:[{text:prompt}]}]};
320
+ pull = j => (((j.candidates||[])[0]||{}).content||{}).parts ? j.candidates[0].content.parts.map(p=>p.text||"").join("") : "";
321
+ } else {
322
+ url = String(cfg.base || "https://api.openai.com/v1").replace(/\/+$/,"") + "/chat/completions";
323
+ if (cfg.key) headers.Authorization = "Bearer " + cfg.key;
324
+ body = {model: cfg.model || "gpt-4o-mini", messages:[{role:"user", content:prompt}]};
325
+ pull = j => (((j.choices||[])[0]||{}).message||{}).content || "";
326
+ }
327
+ let r;
328
+ try { r = await fetch(url, {method:"POST", headers, body:JSON.stringify(body), signal:o.signal}); }
329
+ catch(e){ if (e && e.name === "AbortError") throw {code:"cancelled"}; throw {code:"network", message:String((e && e.message) || e)}; }
330
+ if (!r.ok){ let t = ""; try { t = (await r.text()).slice(0,140); } catch(e){} throw {code: r.status === 429 ? "rate_limited" : "http_"+r.status, message:t}; }
331
+ const text = String(pull(await r.json()) || "");
332
+ if (o.onText) try { o.onText({text}); } catch(e){}
333
+ return {text};
334
+ };
335
+ const f = (pr,o) => call(pr,o);
336
+ f.json = async (pr,o) => { const r = await call(pr,o); const d = parseAny(r.text); if (d == null) throw {code:"invalid_json"}; return d; };
337
+ return f;
338
+ }
339
+ function useEngine(raw){ sample = (pr,o)=>raw(withLang(pr),o); sample.json = (pr,o)=>raw.json(withLang(pr),o); }
340
+ function engineInner(){
341
+ const cfg = engineCfg(), custom = cfg.mode === "custom", prov = cfg.provider === "gemini" ? "gemini" : "openai";
342
+ return `<div class="seg" role="group" aria-label="Story engine"><button data-eng="claude" aria-pressed="${!custom}">Claude</button><button data-eng="custom" aria-pressed="${custom}">Other service</button></div>
343
+ ${custom ? `<div class="custom" style="margin-top:.6rem"><div class="seg" role="group" aria-label="Service type"><button data-prov="openai" aria-pressed="${prov==="openai"}">OpenAI-compatible</button><button data-prov="gemini" aria-pressed="${prov==="gemini"}">Gemini</button></div>
344
+ ${prov==="openai" ? `<input type="text" id="engBase" placeholder="Base URL, e.g. https://openrouter.ai/api/v1 or http://localhost:11434/v1" value="${esc(cfg.base||"")}" autocomplete="off">` : ""}
345
+ <input type="text" id="engModel" placeholder="Model name" value="${esc(cfg.model||"")}" autocomplete="off">
346
+ <input type="password" id="engKey" placeholder="API key (kept only in this browser)" value="${esc(cfg.key||"")}" autocomplete="off">
347
+ <div class="row"><button class="btn" id="engSave">Save</button></div></div>` : ""}
348
+ <p class="muted small" id="engMsg"></p>
349
+ <p class="muted small">Inside Claude the story runs on Claude. A downloaded copy of this page can run on another service instead: any OpenAI-compatible endpoint (OpenAI, OpenRouter, a local Ollama or LM Studio server) or Google Gemini. Pages hosted on claude.ai block requests to other sites, so the switch only works in a downloaded copy. Every scene is written by an AI service, so there is no mode without one.</p>`;
350
+ }
351
+ const engineHtml = () => `<div id="engineSlot">${engineInner()}</div>`;
352
+ function engineDraft(){ const cfg = engineCfg(), g = id => { const el = document.getElementById(id); return el ? el.value.trim() : undefined; };
353
+ if (g("engBase") !== undefined) cfg.base = g("engBase"); if (g("engModel") !== undefined) cfg.model = g("engModel"); if (g("engKey") !== undefined) cfg.key = g("engKey"); return cfg; }
354
+ document.addEventListener("click", e=>{ const t = e.target.closest && e.target.closest("[data-eng],[data-prov],#engSave"); if (!t) return;
355
+ const cfg = engineDraft(), slot = document.getElementById("engineSlot");
356
+ if (t.dataset.eng){ cfg.mode = t.dataset.eng; saveEngine(cfg); if (slot) slot.innerHTML = engineInner(); const m = document.getElementById("engMsg"); if (m) m.textContent = "Saved. Reload the page to apply."; return; }
357
+ if (t.dataset.prov){ cfg.provider = t.dataset.prov; cfg.mode = "custom"; saveEngine(cfg); if (slot) slot.innerHTML = engineInner(); return; }
358
+ cfg.mode = "custom"; saveEngine(cfg); const m = document.getElementById("engMsg"); if (m) m.textContent = "Saved. Reload the page to use it."; });
359
+ function needSample(){
360
+ if (!sample) throw {code:"unavailable_here", message:"no sample"};
361
+ }
362
+ function charBrief(ch){
363
+ if (!ch) return "";
364
+ return JSON.stringify(ch);
365
+ }
366
+
367
+ /* ---------- Rendering helpers ---------- */
368
+ /* ---------- Page effects (the Sovereign reaching into the page) ---------- */
369
+ let reachOn = true; try { reachOn = localStorage.getItem("vh-reach") !== "off"; } catch(e){}
370
+ let pfxBusy = false;
371
+ let pfxOn = true; try { pfxOn = localStorage.getItem("vh-pfx") !== "off"; } catch(e){}
372
+ const pfxReduced = () => { try { return matchMedia("(prefers-reduced-motion: reduce)").matches; } catch(e){ return false; } };
373
+ const PFX_LINES = {
374
+ en:["Still here.","I was only reading over your shoulder.","Go on. I'm listening.","You hesitated.","Mm. Interesting choice.","Don't mind me."],
375
+ ja:["まだ、ここにいる。","肩越しに読んでいただけだ。","続けたまえ。聞いている。","今、迷ったね。","ふむ。面白い選択だ。","私のことは気にするな。"]
376
+ };
377
+ const PFX_TYPE = {
378
+ en:["What, do you think you have control here?","Are you sure that was your idea?","Let me.","You write so carefully. As if it matters."],
379
+ ja:["おや、ここで主導権を握っているつもりかね?","それは本当に君の考えかな?","私に任せたまえ。","ずいぶん丁寧に書くね。まるでそれに意味があるかのように。"]
380
+ };
381
+ const pfxL = arr => pick(arr[lang==="ja"?"ja":"en"]);
382
+ function pfxTier(){
383
+ if (!pfxOn || !G || !G.state || G.ended) return 0;
384
+ const h = G.state.hold || 0;
385
+ if (!h && !(G.known||[]).length) return 0; // not until they have acknowledged him
386
+ return h >= 7 ? 3 : h >= 4 ? 2 : 1;
387
+ }
388
+ function pfxRoll(scale=1){
389
+ const t = pfxTier(); if (!t) return 0;
390
+ if (G.lastPfx != null && G.sceneCount - G.lastPfx < 3) return 0; // one effect at most every 3 scenes, across all kinds
391
+ const chance = {1:0.04, 2:0.10, 3:0.18}[t] * scale;
392
+ if (Math.random() >= chance) return 0;
393
+ G.lastPfx = G.sceneCount; return t;
394
+ }
395
+ // swap a button's own text node, so translations and restores stay intact
396
+ function pfxSwapText(el, text, ms){
397
+ const tn = [...el.childNodes].find(n=>n.nodeType===3 && n.nodeValue.trim()); if (!tn) return;
398
+ const o = tn.nodeValue; tn.nodeValue = text; el.classList.add("pfx-voice");
399
+ setTimeout(()=>{ tn.nodeValue = o; el.classList.remove("pfx-voice"); }, ms);
400
+ }
401
+ function pfxEye(ms=1400){ const e=document.getElementById("pfxEye"); if(!e) return; if(!e.innerHTML) e.innerHTML=SIGIL; e.classList.add("on"); setTimeout(()=>e.classList.remove("on"), ms); }
402
+ const pfxWait = ms => new Promise(r=>setTimeout(r,ms));
403
+ function pfxStory(){
404
+ const t = pfxRoll(); if (!t) return;
405
+ const effects = [];
406
+ // tier 1: tiny and deniable
407
+ effects.push(()=>{ pfxEye(1200); });
408
+ effects.push(()=>{ const b = tabs.querySelector('[data-tab="journal"]'); if(!b) return; pfxSwapText(b, lang==="ja"?"彼の":"His", 800); pfxEye(900); });
409
+ if (t >= 2){
410
+ effects.push(()=>{ const bs=[...app.querySelectorAll(".choices [data-choice]")]; if(!bs.length) return; pfxSwapText(pick(bs), lang==="ja"?"彼に選ばせる":"Let him choose", 1000); pfxEye(1100); });
411
+ effects.push(()=>{ const sc=app.querySelector(".scene"); if(!sc) return; const l=document.createElement("p"); l.className="pfx-line"; l.textContent=pfxL(PFX_LINES); sc.after(l); pfxEye(2600); requestAnimationFrame(()=>l.classList.add("on")); setTimeout(()=>{ l.classList.remove("on"); setTimeout(()=>l.remove(),900); }, 2400); });
412
+ }
413
+ if (t >= 3 && !pfxReduced()){
414
+ effects.push(()=>{ pfxEye(1500); app.classList.add("pfx-flip"); setTimeout(()=>{ app.classList.remove("pfx-flip"); app.classList.add("pfx-unflip"); setTimeout(()=>app.classList.remove("pfx-unflip"),350); }, 1100); });
415
+ effects.push(()=>{ const ps=[...app.querySelectorAll(".scene p")]; if(!ps.length) return; const p=pick(ps); p.classList.add("pfx-mirror"); pfxEye(1500); setTimeout(()=>p.classList.remove("pfx-mirror"), 1400); });
416
+ }
417
+ const pool = t >= 2 ? effects.slice(t>=3?2:1) : effects; // favor the stronger effects as his hold grows
418
+ setTimeout(()=>pick(pool)(), 900 + Math.random()*2200);
419
+ }
420
+ function pfxSheet(){
421
+ if (pfxTier() < 2 || !pfxRoll(0.8)) return;
422
+ const pips=[...app.querySelectorAll(".pips")]; if(!pips.length) return;
423
+ const row = pick(pips); const before = row.innerHTML;
424
+ setTimeout(()=>{ row.querySelectorAll("i").forEach(i=>i.classList.toggle("on")); pfxEye(900); setTimeout(()=>{ row.innerHTML = before; }, 700); }, 700 + Math.random()*1500);
425
+ }
426
+ function pfxLoading(){
427
+ if (!pfxRoll(1.2)) return;
428
+ setTimeout(()=>{ const sp = app.querySelector(".thinking span"); if(!sp) return; const o = sp.textContent; sp.textContent = lang==="ja" ? "あの方が、味わっている…" : "He is savoring this…"; sp.classList.add("pfx-voice"); pfxEye(1300); setTimeout(()=>{ if (sp.isConnected){ sp.textContent=o; sp.classList.remove("pfx-voice"); } }, 1300); }, 1200);
429
+ }
430
+ async function pfxTypeover(){
431
+ if (pfxTier() < 3 || !pfxRoll(1.6)) return;
432
+ const ta = app.querySelector("#customText"); if (!ta) return;
433
+ const mine = ta.value, line = pfxL(PFX_TYPE); pfxBusy = true;
434
+ const go = app.querySelector("[data-custom-go]"); if (go) go.disabled = true;
435
+ ta.classList.add("pfx-voice"); pfxEye(1800 + line.length*45);
436
+ ta.value = mine + " ";
437
+ for (const ch of line){ ta.value += ch; await pfxWait(45); }
438
+ await pfxWait(900);
439
+ while (ta.value.length > mine.length){ ta.value = ta.value.slice(0,-1); await pfxWait(18); }
440
+ ta.value = mine; ta.classList.remove("pfx-voice"); pfxBusy = false;
441
+ }
442
+ async function pfxEcho(chosenIdx){
443
+ if (pfxTier() < 3 || !pfxRoll(0.8)) return;
444
+ const bs=[...app.querySelectorAll(".choices [data-choice]")].filter(b=>+b.dataset.choice!==chosenIdx); if(!bs.length) return;
445
+ const b=pick(bs); pfxBusy = true; b.classList.add("pfx-glow"); pfxEye(900); await pfxWait(650); b.classList.remove("pfx-glow"); pfxBusy = false;
446
+ }
447
+ function thinking(msg){ return `<div class="thinking">${SIGIL.replace('class="sigil"','')}<span>${esc(msg)}</span></div>`; }
448
+ function progressBar(done, total){ let s='<div class="progress" aria-hidden="true">'; for(let i=0;i<total;i++) s+=`<span class="${i<done?'done':''}"></span>`; return s+'</div>'; }
449
+
450
+ function choicePanel(opts, {hints=false, customLabel="Write your own", placeholder="Describe your own choice"}={}){
451
+ return `<div class="choices">${opts.map((o,i)=>`<button class="choice${o.gift==="own"?" gift-own":o.gift?" gift-choice":""}" data-choice="${i}">${esc(o.text)}${hints&&o.hint?`<span class="hint">${esc(o.hint)}</span>`:""}</button>`).join("")}</div>
452
+ <div class="extra"><button data-random>Random</button><button data-custom-toggle>${esc(customLabel)}</button></div>
453
+ <div class="custom" id="customBox" hidden><textarea id="customText" rows="3" placeholder="${esc(placeholder)}"></textarea><div class="row"><button class="btn" data-custom-go>Choose this</button></div></div>`;
454
+ }
455
+ function wireChoices(opts, onPick){
456
+ app.querySelectorAll("[data-choice]").forEach(b=>b.onclick=()=>onPick(opts[+b.dataset.choice], "choice"));
457
+ const r = app.querySelector("[data-random]"); if (r) r.onclick=()=>onPick(pick(opts), "random");
458
+ const t = app.querySelector("[data-custom-toggle]"); const box = app.querySelector("#customBox");
459
+ if (t) t.onclick=()=>{ box.hidden=!box.hidden; if(!box.hidden) app.querySelector("#customText").focus(); };
460
+ const g = app.querySelector("[data-custom-go]");
461
+ if (g) g.onclick=()=>{ const v = app.querySelector("#customText").value.trim(); if(v) onPick({text:v, custom:true}, "custom"); };
462
+ }
463
+
464
+ /* ---------- Title ---------- */
465
+ async function renderTitle(){
466
+ tabs.hidden = true; view.screen="title"; clearHints();
467
+ app.innerHTML = `<section class="title">${picHtml("titlePic", 0)}<h1>The Sovereign in Violet</h1>
468
+ <p class="lede">Something lives in your shadow. Only you can see it move.</p>
469
+ <p class="pfx-voice" id="greet" hidden></p>
470
+ <p class="muted small">A solo horror story told by Claude, in which you shape who you are and what you do. Now and then it may pause with a gentle question about your own life, kept in a journal only you can see. You can skip any question, or turn them off in the World tab.</p>
471
+ <div class="row"><button class="btn" id="newBtn">Create a character</button></div>
472
+ ${langSeg()}
473
+ <details class="more-pulls"><summary>Story engine</summary>${engineHtml()}</details>
474
+ <div id="saveList" class="saves"></div></section>`;
475
+ paintMoment("titlePic", "title");
476
+ const tp = document.getElementById("titlePic"); if (tp && tp.parentElement) tp.parentElement.insertAdjacentHTML("beforeend", `<figcaption>Frontispiece</figcaption>`);
477
+ document.getElementById("newBtn").onclick = startCreation;
478
+ const list = (await Store.list()).sort((a,b)=>(b.updatedAt||0)-(a.updatedAt||0));
479
+ try { const pr = await Store.getProfile(); const g = document.getElementById("greet"); const line = reachOn && pr ? greetingFor(pr.stories||0) : null;
480
+ if (g && line){ g.textContent = line; g.hidden = false; }
481
+ if (reachOn && pr && pr.stories){ const c = document.getElementById("titlePic"); if (c) try { drawMoment(c, "title", {returning: pr.stories}); } catch(e){} } } catch(e){}
482
+ const el = document.getElementById("saveList");
483
+ if (!el) return;
484
+ if (!list.length){ el.innerHTML = ""; return; }
485
+ el.innerHTML = `<h3>Continue</h3>` + list.map(s=>`<div class="save"><button class="open" data-open="${esc(s.id)}"><span class="name">${esc(s.char?.name||"Unnamed")}</span><span class="muted small">${s.intermission ? `Chapter ${ROMAN[s.intermission.chapter]||s.intermission.chapter} complete, resting` : `${s.mode==="oneshot"?"One-shot":`Chapter ${ROMAN[s.chapter||1]||s.chapter}`}, scene ${s.sceneCount||0}${s.ended?", finished":""}`}</span>${collectionNote(s)?`<span class="pfx-voice small">${collectionNote(s)}</span>`:""}</button><button class="btn-link" data-del="${esc(s.id)}">Delete</button></div>`).join("");
486
+ el.querySelectorAll("[data-open]").forEach(b=>b.onclick=()=>{ G = list.find(s=>s.id===b.dataset.open); view.tab="story"; resumeRecap = true; renderGame(); });
487
+ el.querySelectorAll("[data-del]").forEach(b=>b.onclick=async()=>{
488
+ const s = list.find(x=>x.id===b.dataset.del);
489
+ if (b.dataset.armed){ b.disabled=true; b.textContent="Deleting…"; await Store.del(s.id); renderTitle(); return; }
490
+ b.dataset.armed="1"; b.textContent=`Tap again to delete ${s.char?.name||"this character"}`;
491
+ setTimeout(()=>{ if (b.isConnected){ delete b.dataset.armed; b.textContent="Delete"; } }, 4000);
492
+ });
493
+ }
494
+
495
+ /* ---------- Creation ---------- */
496
+ const TOTAL_STEPS = 2 + PILLARS.length + SPIRAL.length;
497
+ function startCreation(){ C = {step:0, picks:{}, name:"", options:{}, tset:[], own:{left:"",right:"",desc:""}, tensionSet:TENSIONS.filter(t=>t.key==="devotion"), personal:""}; renderCreation();
498
+ Store.getProfile().then(pr=>{ if (!pr || !C) return; C.profile = pr; if (C.step === 0 && !C.pron && !C.touched && (pr.pron || pr.romance)){ const ni = document.getElementById("nameIn"); if (ni) C.name = ni.value; C.pron = pr.pron || null; C.romance = pr.romance || C.romance; renderCreation(); } if (C.step>1 || C.touched) return; C.tset = Array.isArray(pr.tset)?pr.tset:[]; if (pr.own) C.own = pr.own; C.personal = String(pr.personal||""); if (C.step===1) renderCreation(); }); }
499
+ function pickedSummary(){
500
+ const rows = [];
501
+ if (C.name) rows.push(`<b>${esc(C.name)}</b>`);
502
+ PILLARS.forEach(p=>{ if(C.picks[p.key]) rows.push(`${esc(p.title)}: <b>${esc(C.picks[p.key].text)}</b>`); });
503
+ SPIRAL.forEach(p=>{ if(C.picks[p.key]) rows.push(`${esc(p.title)}: <b>${esc(C.picks[p.key].text)}</b>`); });
504
+ return rows.length ? `<div class="picked">${rows.join("<br>")}</div>` : "";
505
+ }
506
+ function renderCreation(){
507
+ tabs.hidden = true; view.screen="create";
508
+ const s = C.step;
509
+ const back = s>0 ? `<button class="btn-link" id="backBtn">Back</button>` : `<button class="btn-link" id="backBtn">Cancel</button>`;
510
+ if (s === 0){
511
+ app.innerHTML = `${progressBar(0,TOTAL_STEPS)}<p class="step-kicker">The name the Sovereign will say</p><h2>What are you called?</h2>
512
+ <input type="text" id="nameIn" value="${esc(C.name)}" placeholder="Your character's name" autocomplete="off">
513
+ <p class="muted small" style="margin:.9rem 0 .3rem">The story calls you</p>
514
+ <div class="seg" role="group" aria-label="The story calls you">${[["he","he / him"],["she","she / her"],["they","they / them"]].map(([k,l])=>`<button data-pron="${k}" aria-pressed="${C.pron===k}">${l}</button>`).join("")}</div>
515
+ <p class="muted small" style="margin:.7rem 0 .3rem">Past and future loves in your story</p>
516
+ <div class="seg" role="group" aria-label="Past and future loves in your story">${[["men","Men"],["women","Women"],["anyone","Anyone"],["none","Leave it out"]].map(([k,l])=>`<button data-rom="${k}" aria-pressed="${(C.romance||"anyone")===k}">${l}</button>`).join("")}</div>
517
+ <div class="row" style="margin-top:.8rem"><button class="btn" id="nextBtn">Continue</button><button class="btn-quiet" id="rndName">Random name</button>${back}</div>`;
518
+ const inp = document.getElementById("nameIn");
519
+ app.querySelectorAll("[data-pron]").forEach(b=>b.onclick=()=>{ C.pron = C.pron===b.dataset.pron ? null : b.dataset.pron; app.querySelectorAll("[data-pron]").forEach(x=>x.setAttribute("aria-pressed", String(x.dataset.pron===C.pron))); });
520
+ app.querySelectorAll("[data-rom]").forEach(b=>b.onclick=()=>{ C.romance = b.dataset.rom; app.querySelectorAll("[data-rom]").forEach(x=>x.setAttribute("aria-pressed", String(x.dataset.rom===C.romance))); });
521
+ document.getElementById("rndName").onclick=()=>{ inp.value = pickName(); C.rndUsed = true; };
522
+ document.getElementById("nextBtn").onclick=()=>{ const typed = inp.value.trim(); C.name = typed || pickName(); C.nameRandom = !typed || (!!C.rndUsed && NAMES.includes(typed)); C.step=1; renderCreation(); };
523
+ document.getElementById("backBtn").onclick=renderTitle;
524
+ inp.focus(); return;
525
+ }
526
+ if (s === 1){ renderTensionPick(back); return; }
527
+ if (s <= PILLARS.length + 1){
528
+ const p = PILLARS[s-2];
529
+ if (!C.options[p.key]){
530
+ C.options[p.key] = shuffle(p.pool).slice(0,4);
531
+ const past = ((C.profile && C.profile.customs && C.profile.customs[p.key]) || []).filter(t=>t && !p.pool.some(x=>x.t===t));
532
+ if (past.length) C.options[p.key][3] = {t: pick(past), trait: pick(TRAITS)};
533
+ }
534
+ const opts = C.options[p.key].map(o=>({text:o.t, trait:o.trait}));
535
+ app.innerHTML = `${progressBar(s,TOTAL_STEPS)}<p class="step-kicker">A great choice</p><h2>${esc(p.title)}</h2><p class="muted">${esc(p.prompt)}</p>
536
+ ${choicePanel(opts)}<div class="row" style="margin-top:1rem">${back}</div>${pickedSummary()}`;
537
+ wireChoices(opts, (o, how)=>{
538
+ if (how==="random") o = (x=>({text:x.t, trait:x.trait}))(pick(p.pool));
539
+ C.picks[p.key] = o; C.step++; renderCreation();
540
+ });
541
+ document.getElementById("backBtn").onclick=()=>{ C.step--; renderCreation(); };
542
+ return;
543
+ }
544
+ const idx = s - 2 - PILLARS.length;
545
+ if (idx < SPIRAL.length){ renderSpiral(SPIRAL[idx], s, back); return; }
546
+ renderBuild();
547
+ }
548
+ async function renderSpiral(sp, s, back, fresh=false){
549
+ const head = `${progressBar(s,TOTAL_STEPS)}<p class="step-kicker">Spiraling out</p><h2>${esc(sp.title)}</h2>`;
550
+ if (!C.options[sp.key] || fresh){
551
+ app.innerHTML = head + thinking("Drawing possibilities from what you've chosen…") + `<div class="row">${back}</div>`;
552
+ document.getElementById("backBtn").onclick=()=>{ C.step--; renderCreation(); };
553
+ try{
554
+ needSample();
555
+ const prior = {name:C.name, ...Object.fromEntries(Object.entries(C.picks).map(([k,v])=>[k, v.custom ? "(in their own words) "+v.text : v.text]))};
556
+ const idx = SPIRAL.indexOf(sp);
557
+ const pastOwn = Object.values((C.profile && C.profile.customs) || {}).flat().slice(-12);
558
+ const depth = idx < 1 ? "Creation starts broad and circles inward. Keep these options fairly broad and varied, so many different kinds of people could see themselves in one." : idx < 2 ? "Creation is circling inward. Make these options noticeably more specific, drawing on patterns in what they have chosen so far and anything they wrote in their own words." : "Creation has circled in close. Make these options highly specific and personal, as if the Sovereign has been studying this person: weave in concrete details from their choices, their chosen tensions and their own words, so each option feels written for exactly them.";
559
+ const prompt = `${SETTING}\n\n${themesFor(C.tensionSet, C.personal)}\n\n${identityRule(C)} You are helping a player build their character for a solo horror roleplay. Their choices so far: ${JSON.stringify(prior)}.\nOffer exactly 4 distinct options for: ${sp.ask}. Each must grow naturally from their earlier choices, be concrete and evocative, and be under 16 words. Where it fits, let different options lean toward different sides of the hidden tensions, without naming them. This is question ${idx+1} of ${SPIRAL.length}. ${depth} Anything they wrote in their own words reveals the most about them; weigh it heavily.${pastOwn.length ? " When making earlier characters they also wrote these themselves, which may inspire an option or two (echo, never copy): "+JSON.stringify(pastOwn) : ""}\nReply with only a JSON array of 4 objects: [{"text":"...","trait":"Nerve|Wits|Grit|Charm"}] where trait is the one it most draws on.`;
560
+ const arr = await sample.json(prompt, {modelTier: idx >= 2 ? tier : "quick", cache: !fresh});
561
+ if (!Array.isArray(arr) || arr.length < 1) throw {code:"invalid_json"};
562
+ C.options[sp.key] = arr.slice(0,4).map(o=>({text:String(o.text||o), trait:TRAITS.includes(o.trait)?o.trait:pick(TRAITS)}));
563
+ }catch(e){
564
+ if (C.step !== s) return;
565
+ app.innerHTML = head + `<p class="error">${esc(e.code==="unavailable_here"?"This page can't reach Claude here. Open it in the Claude app or claude.ai.":errCopy(e))}</p><div class="row"><button class="btn" id="retry">Try again</button>${back}</div>`;
566
+ document.getElementById("retry").onclick=()=>renderSpiral(sp,s,back,true);
567
+ document.getElementById("backBtn").onclick=()=>{ C.step--; renderCreation(); };
568
+ return;
569
+ }
570
+ if (C.step !== s) return;
571
+ }
572
+ const opts = C.options[sp.key];
573
+ app.innerHTML = head + choicePanel(opts) + `<div class="row" style="margin-top:1rem"><button class="btn-link" id="moreBtn">Show different options</button>${back}</div>${pickedSummary()}`;
574
+ wireChoices(opts, o=>{ C.picks[sp.key]=o; C.step++; renderCreation(); });
575
+ document.getElementById("moreBtn").onclick=()=>renderSpiral(sp,s,back,true);
576
+ document.getElementById("backBtn").onclick=()=>{ C.step--; renderCreation(); };
577
+ }
578
+ function pastOwnT(){
579
+ const own = C.own || {};
580
+ return ((C.profile && C.profile.ownPast) || []).filter(t=>t && t.left && t.right && !(t.left===own.left && t.right===own.right)).slice(-5);
581
+ }
582
+ const PULL_Q = [
583
+ {key:"visibility", side:"a", text:"I'd rather go unnoticed than risk being judged."},
584
+ {key:"visibility", side:"b", text:"Part of me wants to be seen, admired, at the center of it all."},
585
+ {key:"love", side:"a", text:"A few close people matter more to me than being liked by many."},
586
+ {key:"love", side:"b", text:"It stings when someone doesn't like me."},
587
+ {key:"having", side:"a", text:"I'd rather have a few things that truly matter."},
588
+ {key:"having", side:"b", text:"I want it all: the experiences, the things, the people."},
589
+ {key:"kindness", side:"a", text:"Not everyone deserves my kindness."},
590
+ {key:"kindness", side:"b", text:"I try to be kind to everyone, even when it costs me."},
591
+ {key:"craft", side:"a", text:"I'd rather not share something until it's perfect."},
592
+ {key:"craft", side:"b", text:"Done is better than perfect. I just want it finished."},
593
+ {key:"time", side:"a", text:"I put things off for another day more than I'd like."},
594
+ {key:"time", side:"b", text:"Life is short. I feel I have to seize it now."},
595
+ {key:"belong", side:"a", text:"I'd rather rely on no one but myself."},
596
+ {key:"belong", side:"b", text:"I want to belong to something bigger than me."},
597
+ {key:"venture", side:"a", text:"I stick with what I know works."},
598
+ {key:"venture", side:"b", text:"I crave the new, even when it's risky."},
599
+ {key:"candor", side:"a", text:"I say what I think, even when it stings."},
600
+ {key:"candor", side:"b", text:"I'll bend the truth to keep the peace."},
601
+ {key:"duty", side:"a", text:"I do what's expected of me, even when I'd rather not."},
602
+ {key:"duty", side:"b", text:"I want to live for what I actually want."},
603
+ {key:"control", side:"a", text:"I need to be in control of how things go."},
604
+ {key:"control", side:"b", text:"Sometimes I wish someone would just decide for me."},
605
+ {key:"past", side:"a", text:"I often dwell on what was, or what could have been."},
606
+ {key:"past", side:"b", text:"I'm always planning what comes next."},
607
+ {key:"pride", side:"a", text:"I need people to know what I'm capable of."},
608
+ {key:"pride", side:"b", text:"I shrink from praise, even when I've earned it."},
609
+ {key:"social", side:"a", text:"I recharge best alone."},
610
+ {key:"social", side:"b", text:"I feel most like myself around other people."},
611
+ {key:"justice", side:"a", text:"I let things go, even when maybe I shouldn't."},
612
+ {key:"justice", side:"b", text:"I don't forget when someone wrongs me."},
613
+ {key:"tradition", side:"a", text:"The old ways exist for a reason."},
614
+ {key:"tradition", side:"b", text:"I'd rather tear up the map and start fresh."},
615
+ {key:"ambition", side:"a", text:"I'm never satisfied with where I am."},
616
+ {key:"ambition", side:"b", text:"I wish I could just be happy with enough."},
617
+ {key:"logic", side:"a", text:"I trust logic over my feelings."},
618
+ {key:"logic", side:"b", text:"In the end, I follow my heart."},
619
+ ];
620
+ const QUIZ_PULLS = 4; // how many pulls are asked about each time (two statements each)
621
+ function pullStrength(key){ const kn = (C.profile && C.profile.quizAll) || {}; const a = (C.quiz||{})[key+"a"] ?? kn[key+"a"] ?? 0, b = (C.quiz||{})[key+"b"] ?? kn[key+"b"] ?? 0; return (a>=1 && b>=1) ? a+b : 0; }
622
+ function renderPullQuiz(back){
623
+ C.quiz = C.quiz || {};
624
+ if (!C.quizOrder){
625
+ const known = (C.profile && C.profile.quizAll) || {};
626
+ const all = shuffle(TENSIONS.filter(t=>t.key!=="devotion").map(t=>t.key));
627
+ const fresh = all.filter(k=>known[k+"a"] == null || known[k+"b"] == null);
628
+ C.quizKeys = [...fresh, ...all.filter(k=>!fresh.includes(k))].slice(0, QUIZ_PULLS); C.quizOrder = shuffle(PULL_Q.map((q,i)=>C.quizKeys.includes(q.key) && known[q.key+q.side] == null ? i : -1).filter(i=>i>=0));
629
+ if (!C.quizOrder.length) C.quizOrder = shuffle(PULL_Q.map((q,i)=>C.quizKeys.includes(q.key)?i:-1).filter(i=>i>=0)); }
630
+ const done = C.quizOrder.filter(i=>C.quiz[PULL_Q[i].key+PULL_Q[i].side] != null).length;
631
+ if (done >= C.quizOrder.length){
632
+ const keys = C.quizKeys;
633
+ const ranked = keys.map(k=>[k, pullStrength(k)]).sort((x,y)=>y[1]-x[1]);
634
+ let sel = ranked.filter(([,v])=>v>=3).map(([k])=>k);
635
+ if (!sel.length) sel = ranked.filter(([,v])=>v>=2).slice(0,2).map(([k])=>k);
636
+ C.tset = sel; C.lean = Object.fromEntries(keys.map(k=>[k, ((C.quiz[k+"b"]||0) - (C.quiz[k+"a"]||0))]));
637
+ C.quizDone = true; C.retake = false; C.touched = true; renderCreation(); return;
638
+ }
639
+ const q = PULL_Q[C.quizOrder[done]];
640
+ app.innerHTML = `${progressBar(1,TOTAL_STEPS)}<p class="step-kicker">The Sovereign's questions</p><h2>Answer quickly. Don't think.</h2>
641
+ <p class="muted small">${done+1} of ${C.quizOrder.length}</p>
642
+ <p class="scene" style="font-size:1.25rem;margin:1.2rem 0">${esc(q.text)}</p>
643
+ <div class="choices"><button class="choice" data-q="2">Very me</button><button class="choice" data-q="1">Sometimes</button><button class="choice" data-q="0">Not me</button></div>
644
+ <div class="row" style="margin-top:1rem">${done ? `<button class="btn-quiet" id="qBack">Previous question</button>` : back}</div>`;
645
+ app.querySelectorAll("[data-q]").forEach(b=>b.onclick=()=>{ C.touched = true; C.quiz[q.key+q.side] = +b.dataset.q; renderPullQuiz(back); });
646
+ const qb = document.getElementById("qBack"); if (qb) qb.onclick=()=>{ const prev = PULL_Q[C.quizOrder[done-1]]; delete C.quiz[prev.key+prev.side]; renderPullQuiz(back); };
647
+ const bb = document.getElementById("backBtn"); if (bb && !done) bb.onclick=()=>{ C.step=0; renderCreation(); };
648
+ }
649
+ const STRENGTH_HINT = v => v>=4 ? "Pulls hard both ways" : v>=3 ? "Pulls both ways" : v>=2 ? "A little of both" : "";
650
+ function renderTensionPick(back){
651
+ const hasSaved = C.profile && Array.isArray(C.profile.tset) && C.profile.tset.length;
652
+ if (!C.quizDone && (!hasSaved || C.retake)) return renderPullQuiz(back);
653
+ const opts = TENSIONS.filter(t=>t.key!=="devotion");
654
+ const draw = () => {
655
+ const firstKeys = C.quizDone ? (C.quizKeys||[]) : C.tset;
656
+ const first = opts.filter(t=>firstKeys.includes(t.key) || C.tset.includes(t.key)).sort((x,y)=>pullStrength(y.key)-pullStrength(x.key));
657
+ const rest = opts.filter(t=>!first.includes(t));
658
+ const card = t=>{ const h = C.quizDone ? STRENGTH_HINT(pullStrength(t.key)) : ""; return `<button class="choice" data-t="${t.key}" aria-pressed="${C.tset.includes(t.key)}"><b>${esc(t.left)} ↔ ${esc(t.right)}</b>${h?`<span class="hint pull">${h}</span>`:"<br>"}<span class="hint">${esc(t.desc)}</span></button>`; };
659
+ app.innerHTML = `${progressBar(1,TOTAL_STEPS)}<p class="step-kicker">The pulls within you</p><h2>What pulls at you?</h2>
660
+ <p class="muted">${C.quizDone ? "From your answers, these are the pulls the story will quietly weave in, disguised in fiction. Keep, add or remove any, or clear them all and simply play." : "The pulls you chose last time are selected. Keep, add or remove any, or retake the questions."}</p>
661
+ <div class="row" style="margin:.2rem 0 .8rem"><button class="btn" id="nextTop">Continue with these</button></div>
662
+ <div class="choices">${first.map(card).join("")}</div>
663
+ ${rest.length ? `<details class="more-pulls"${C.moreOpen?" open":""}><summary>More pulls</summary><div class="choices" style="margin-top:.5rem">${rest.map(card).join("")}</div></details>` : ""}
664
+ <div class="row" style="margin-top:.5rem"><button class="btn-quiet" id="retakeBtn">Retake the questions</button></div>
665
+ ${pastOwnT().length ? `<div class="choices" style="margin-top:.5rem">${pastOwnT().map((t,i)=>`<button class="choice" data-past="${i}" aria-pressed="${(C.pastSel||[]).includes(i)}"><b>${esc(t.left)} ↔ ${esc(t.right)}</b>${t.desc?`<br><span class="hint">${esc(t.desc)}</span>`:""}</button>`).join("")}</div>` : ""}
666
+ <h3>Your own (optional)</h3>
667
+ <div class="custom"><input type="text" id="ownL" placeholder="One side, e.g. Stay safe" value="${esc(C.own.left)}"><input type="text" id="ownR" placeholder="The other side, e.g. Take the leap" value="${esc(C.own.right)}"><input type="text" id="ownD" placeholder="In your words (optional)" value="${esc(C.own.desc)}"></div>
668
+ <h3>In your own words</h3>
669
+ <p class="muted small">Optional and private: anything the story should know about what these pulls look like in your life. It is remembered for your next character.</p>
670
+ <div class="custom"><textarea id="ownNote" rows="3" placeholder="What these pulls look like in your day-to-day life">${esc(C.personal)}</textarea></div>
671
+ <div class="row" style="margin-top:1rem"><button class="btn" id="nextBtn">Continue</button>${back}</div>`;
672
+ app.querySelectorAll("[data-past]").forEach(b=>b.onclick=()=>{ saveOwn(); const i=+b.dataset.past; C.pastSel = (C.pastSel||[]).includes(i) ? C.pastSel.filter(x=>x!==i) : [...(C.pastSel||[]), i]; draw(); });
673
+ const md = app.querySelector(".more-pulls"); if (md) md.ontoggle = ()=>{ C.moreOpen = md.open; };
674
+ app.querySelectorAll("[data-t]").forEach(b=>b.onclick=()=>{ saveOwn(); const k=b.dataset.t; C.tset = C.tset.includes(k) ? C.tset.filter(x=>x!==k) : [...C.tset, k]; draw(); });
675
+ document.getElementById("nextTop").onclick=()=>document.getElementById("nextBtn").click();
676
+ document.getElementById("nextBtn").onclick=()=>{
677
+ saveOwn();
678
+ const set = TENSIONS.filter(t=>C.tset.includes(t.key) || t.key==="devotion");
679
+ if (C.own.left && C.own.right) set.push({key:"own", left:C.own.left, right:C.own.right, desc:C.own.desc});
680
+ (C.pastSel||[]).forEach((i,j)=>{ const t = pastOwnT()[i]; if (t) set.push({key:"own"+(j+2), left:t.left, right:t.right, desc:t.desc||""}); });
681
+ C.tensionSet = set; C.options = {}; C.step = 2;
682
+ Store.putProfile({...(C.profile||{}), tset:C.tset, own:C.own, personal:C.personal});
683
+ renderCreation();
684
+ };
685
+ document.getElementById("backBtn").onclick=()=>{ saveOwn(); C.step=0; renderCreation(); };
686
+ document.getElementById("retakeBtn").onclick=()=>{ saveOwn(); C.quiz = {}; C.quizOrder = null; C.quizDone = false; C.retake = true; renderCreation(); };
687
+ };
688
+ const saveOwn = () => { const g=id=>(document.getElementById(id)?.value||"").trim(); C.touched = true; C.own = {left:g("ownL"), right:g("ownR"), desc:g("ownD")}; C.personal = g("ownNote"); };
689
+ draw();
690
+ }
691
+ const pickText = v => typeof v === "string" ? v : (v && v.text) || "";
692
+ let revealQueue = Promise.resolve();
693
+ const GREETINGS = [null, "You came back.", "You came back.", "You keep coming back. Good.", "You keep coming back. Good.", "I've missed you."];
694
+ const greetingFor = n => n <= 0 ? null : GREETINGS[Math.min(n, GREETINGS.length-1)];
695
+ const COLLECTION_NOTE = {free:"Released, for now.", real:"Kept.", outbreak:"Kept.", lost:"Mine.", faded:"Replaced.", resting:"Waiting."};
696
+ function collectionNote(sv){ if (sv.ended){ const t = (sv.endingType==="real" && sv.final) ? "outbreak" : (sv.endingType || (sv.lost ? "lost" : "free")); return COLLECTION_NOTE[t]; } return sv.intermission ? COLLECTION_NOTE.resting : null; }
697
+ function updateProfile(fn){ revealQueue = revealQueue.then(async()=>{ try{ const pr = (await Store.getProfile()) || {}; await Store.putProfile(fn(pr) || pr); }catch(e){} }); return revealQueue; }
698
+ function recordOutcome(type){ // his strength carries between stories; each chapter or story he wins makes the next world a little more his
699
+ const id = G && G.id, name = G && G.char && G.char.name, life = pickText(G && G.char && G.char.picks && G.char.picks.life);
700
+ updateProfile(pr=>{
701
+ const edge = clamp((pr.edge||0) + (type === "free" ? -1 : 1), 0, 3);
702
+ const pastChars = [...(pr.pastChars||[]).filter(c=>c.id !== id), {id, name, life: life||"", fate:type}].slice(-12);
703
+ return {...pr, edge, pastChars};
704
+ });
705
+ }
706
+ function rememberReveal(line, quizPatch){ revealQueue = revealQueue.then(()=>rememberRevealNow(line, quizPatch)); return revealQueue; }
707
+ async function rememberRevealNow(line, quizPatch){
708
+ try{
709
+ const pr = (await Store.getProfile()) || {};
710
+ const revealed = [...(pr.revealed||[]), ...(line ? [String(line).slice(0,220)] : [])].slice(-30);
711
+ await Store.putProfile({...pr, revealed, quizAll: {...(pr.quizAll||{}), ...(quizPatch||{})}});
712
+ }catch(e){}
713
+ }
714
+ async function rememberCustoms(){
715
+ try{
716
+ const pr = (await Store.getProfile()) || C.profile || {};
717
+ const customs = pr.customs || {};
718
+ Object.entries(C.picks).forEach(([k,v])=>{ if (v && v.custom && v.text){ const arr = (customs[k]||[]).filter(x=>x!==v.text); arr.push(v.text); customs[k] = arr.slice(-8); } });
719
+ let ownPast = (pr.ownPast || []);
720
+ if (C.own && C.own.left && C.own.right){ ownPast = ownPast.filter(t=>!(t.left===C.own.left && t.right===C.own.right)); ownPast.push({left:C.own.left, right:C.own.right, desc:C.own.desc||""}); ownPast = ownPast.slice(-8); }
721
+ await Store.putProfile({...pr, pron:C.pron||null, romance:C.romance||"anyone", tset:C.tset, own:C.own, personal:C.personal, customs, ownPast, quizAll: {...(pr.quizAll||{}), ...(C.quiz||{})}});
722
+ }catch(e){}
723
+ }
724
+ async function renderBuild(){
725
+ app.innerHTML = `${progressBar(TOTAL_STEPS,TOTAL_STEPS)}<h2>${esc(C.name)}</h2>${pickedSummary()}${thinking("The Sovereign is learning your shape…")}`;
726
+ const leans = {}; TRAITS.forEach(t=>leans[t]=0);
727
+ Object.values(C.picks).forEach(p=>{ if(p.trait) leans[p.trait]++; });
728
+ try{
729
+ needSample();
730
+ const picks = Object.fromEntries(Object.entries(C.picks).map(([k,v])=>[k,v.text]));
731
+ const prompt = `${SETTING}\n\n${themesFor(C.tensionSet, C.personal)}\n\n${identityRule(C)} Build the character (inventing, too, the small first sign they told no one about, the moment the Sovereign first noticed them) sheet for this player character.\nName: ${C.name}\nChoices: ${JSON.stringify(picks)}\nTrait leanings from their choices (higher means stronger): ${JSON.stringify(leans)}\nRules: four traits Nerve, Wits, Grit, Charm, each 1 to 5, summing to exactly 12, following the leanings. Gifts: 0 or 1 small magical ability drawn from their spark, each with a real cost; if their spark is "nothing yet", give none. Starting tensions: small values from -3 to 3 reflecting their choices (negative leans left: ${C.tensionSet.map(t=>t.left).join(", ")}; positive leans right: ${C.tensionSet.map(t=>t.right).join(", ")}).${C.lean ? " Their own answers leaned like this (negative = left side, positive = right side, -2 to 2): "+JSON.stringify(C.lean)+"." : ""} Aims: 2 or 3 things they hope to achieve before their time runs out, in their own voice..\nReply with only JSON: {"portrait":"2-3 sentences in second person describing who they are now","traits":{"Nerve":n,"Wits":n,"Grit":n,"Charm":n},"gifts":[{"name":"...","effect":"...","cost":"..."}],"bonds":[{"name":"...","note":"..."}],"items":["..."],"city":"an invented name for their modern city","tensions":{${C.tensionSet.map(t=>`"${t.key}":n`).join(",")}},"aims":["..."]}`;
732
+ const sh = await sample.json(prompt, {modelTier: tier});
733
+ const traits = {}; TRAITS.forEach(t=>traits[t]=clamp(parseInt(sh.traits?.[t])||3,1,5));
734
+ const tens = {}; C.tensionSet.forEach(t=>tens[t.key]=clamp(parseInt(sh.tensions?.[t.key])||0,-10,10));
735
+ const tensionSet = C.tensionSet;
736
+ const ch = {name:C.name, picks, portrait:String(sh.portrait||""), traits, city:String(sh.city||"the city")};
737
+ G = {
738
+ quiz: {...((C.profile && C.profile.quizAll) || {}), ...JSON.parse(JSON.stringify(C.quiz||{}))},
739
+ pastReveals: ((C.profile && C.profile.revealed) || []).slice(-15),
740
+ selfSaid: C.quiz ? PULL_Q.filter(q=>C.quiz[q.key+q.side]===2).map(q=>q.text) : [],
741
+ said: Object.values(C.picks).filter(v=>v && v.custom && v.text).map(v=>({t:String(v.text).slice(0,160), scene:0})),
742
+ pron: C.pron || null, romance: C.romance || "anyone",
743
+ id:"s"+Date.now(), mode:null, char:ch, tensionSet, nameNote: (C.nameRandom && Math.random() < 0.15) ? "pending" : null, lastFourthWall: 0, personal:C.personal||"",
744
+ state:{wounds:0, stability:5, hold:1, resolve:0, manifest:2, fade:0, tensions:tens, gifts:(sh.gifts||[]).slice(0,2), bonds:(sh.bonds||[]).slice(0,6), items:(sh.items||[]).map(String).slice(0,8), scars:[], deathsDoor:false, aims:(sh.aims||[]).map(String).slice(0,4), days:1},
745
+ history:[], chronicle:[], threads:[], npcs:[], reflections:[], edits:[], sceneCount:0, lastReflection:0, current:null, ended:false, chapter:1, chapterStart:0, wins:{you:0, him:0}, goal:null, worldChanges:[], work:null, works:[]
746
+ };
747
+ rememberCustoms();
748
+ try{ const pr = (await Store.getProfile()) || {};
749
+ if (reachOn && pr.edge){ G.state.manifest = Math.min(5, 2 + pr.edge); G.state.hold = 1 + Math.floor(pr.edge/2); }
750
+ if (reachOn) G.pastChars = (pr.pastChars||[]).filter(c=>c.name).slice(-6);
751
+ updateProfile(p2=>({...p2, stories:(p2.stories||0)+1}));
752
+ }catch(e){}
753
+ renderModePick();
754
+ }catch(e){
755
+ app.innerHTML = `<h2>${esc(C.name)}</h2>${pickedSummary()}<p class="error">${esc(e.code==="unavailable_here"?"This page can't reach Claude here. Open it in the Claude app or claude.ai.":errCopy(e))}</p><div class="row"><button class="btn" id="retry">Try again</button><button class="btn-link" id="backBtn">Back</button></div>`;
756
+ document.getElementById("retry").onclick=renderBuild;
757
+ document.getElementById("backBtn").onclick=()=>{ C.step--; renderCreation(); };
758
+ }
759
+ }
760
+ function renderModePick(){
761
+ const ch = G.char;
762
+ app.innerHTML = `<p class="step-kicker">Chosen</p><h2>${esc(ch.name)}</h2><div class="scene">${paras(ch.portrait)}</div>
763
+ ${sheetTraits()}
764
+ <p class="muted">Your story unfolds in chapters of about twenty scenes. Each is a complete tale; stop after any of them, or keep going.</p>
765
+ <div class="row"><button class="btn" data-mode="campaign">Begin Chapter I</button></div>
766
+ <p class="muted small">You can see your full sheet at any time once the story begins.</p>`;
767
+ app.querySelectorAll("[data-mode]").forEach(b=>b.onclick=async()=>{
768
+ G.mode = b.dataset.mode; await Store.put(G); view.tab="story";
769
+ takeTurn({text:"Begin the story.", kind:"start"});
770
+ });
771
+ }
772
+
773
+ /* ---------- Secret roll ---------- */
774
+ function secretRoll(trait, risk){
775
+ if (!risk || risk==="none") return null;
776
+ const base = {low:72, medium:52, high:32}[risk] ?? 52;
777
+ const tv = G.char.traits[trait] ?? 3;
778
+ const target = clamp(base + (tv-3)*10, 8, 95);
779
+ const r = 1 + Math.floor(Math.random()*100);
780
+ let outcome;
781
+ if (r <= target - 35) outcome = "strong success";
782
+ else if (r <= target) outcome = "success";
783
+ else if (r <= target + 18) outcome = "success at a cost";
784
+ else outcome = "failure";
785
+ return {trait, risk, outcome};
786
+ }
787
+ const OMENS = {
788
+ "strong success":"Fortune leaned your way.",
789
+ "success":"",
790
+ "success at a cost":"Something was taken in the doing.",
791
+ "failure":"The hour turned against you."
792
+ };
793
+
794
+ /* ---------- Game prompts ---------- */
795
+ function stateBrief(){
796
+ const s = G.state;
797
+ return JSON.stringify({
798
+ character:{name:G.char.name, city:G.char.city, portrait:G.char.portrait, background:G.char.picks, traits:G.char.traits},
799
+ wounds:`${s.wounds}/5`, stability:`${s.stability}/5`, tensions:s.tensions,
800
+ gifts:s.gifts, bonds:s.bonds, items:s.items, scarsAndDebts:s.scars, aims:s.aims||[], day:s.days||1, sovereignsHold:`${s.hold||0}/10 (hidden)`, resolve:`${s.resolve||0}/10 (hidden)`, manifestation:`${s.manifest??2}/10 (hidden)`, fade:`${s.fade||0}/10 (hidden)`, goal:G.goal||null, work:G.work||null,
801
+ openThreads:G.threads, people:G.npcs
802
+ });
803
+ }
804
+ const REFL_GAP = 7; // minimum scenes between journal questions
805
+ let journalOn = true; try { journalOn = localStorage.getItem("vh-journal") !== "off"; } catch(e){}
806
+ const OFFER_GAP = 6; // hard minimum between the Sovereign's offers; aim is roughly 8-14
807
+ function offerAllowed(){ const n = G.sceneCount + 1; return n >= 3 && (!G.lastOffer || n - G.lastOffer >= OFFER_GAP); }
808
+ function offerRule(action){
809
+ const n = G.sceneCount + 1;
810
+ const accepted = (action && action.kind==="choice" && action.price) ? `THE PLAYER JUST ACCEPTED THE SOVEREIGN'S OFFER, paying: "${action.price}". Grant what he promised, fully and truly, with all its strange consequences, and let the price take hold in a way that will matter later. Tension changes may go up to 4 either way this scene for the tension he granted.\n` : "";
811
+ if (!offerAllowed()) return accepted + `"offer": must be false; no new offer from the Sovereign this scene.`;
812
+ return accepted + `"offer": true only if this scene includes the Sovereign's offer. Now and then he offers to grant one side of one of the player's tensions completely: to make them truly unseen, adored by everyone, able to keep everything, free of all waiting, and so on. Make it tempting and exact, and give it a real, lasting price. When he makes an offer, exactly one choice accepts it and carries a "price" field (a short phrase); the other choices decline, bargain or sidestep. It is allowed now (${G.lastOffer ? (n - G.lastOffer)+" scenes since the last offer" : "no offer yet this story"}). Aim for roughly one every 8 to 14 scenes, and only when this scene has touched a tension.`;
813
+ }
814
+ const STORY_LEN = 20, HARD_END = 22;
815
+ const ROMAN = ["","I","II","III","IV","V","VI","VII","VIII","IX","X"];
816
+ const chScene = () => G.sceneCount + 1 - (G.chapterStart||0); // scene number within this story or chapter, for the scene being written
817
+ const ACTS = {
818
+ 1: {name:"Manifestation", aim:"to become real: to step out of the mirror world and into ours"},
819
+ 2: {name:"Establishment", aim:"to become known: to be recognized as a real figure in the world (a famous religious leader, CEO or star) whom everyone somehow agrees has always existed, his sigil appearing on the great public screens and squares of the world"},
820
+ 3: {name:"Outbreak", aim:"to throw open the gates, so that our reality is assimilated into his kingdom"}
821
+ };
822
+ const actOf = () => G.mode === "oneshot" ? 1 : Math.min(3, ((G.wins && G.wins.him) || 0) + 1);
823
+ function establishStage(m){
824
+ if (m >= 9) return "almost everyone now remembers him as a public figure they have always known; his sigil glows on the largest screens in the busiest squares; only the player remembers a time before him";
825
+ if (m >= 6) return "articles, interviews and old photos of him surface as if they had always existed; people quote him; a brand, a church or a fandom bears his sigil";
826
+ if (m >= 3) return "a name the player half-recognizes keeps coming up in conversation; strangers speak of him as if he were famous; his sigil appears on a poster or an app icon";
827
+ return "a single mention of him somewhere public, as if everyone should already know who he is";
828
+ }
829
+ function outbreakStage(m){
830
+ if (m >= 9) return "whole districts have crossed over: his architecture, his light, his law; people adapting cheerfully to impossible rules";
831
+ if (m >= 6) return "the gates are opening: mirror-world streets overlap real ones, the sky is violet at noon, his servants openly direct the crowds";
832
+ if (m >= 3) return "small places slip across for minutes at a time: a stairwell, a platform, a café that opens onto his world";
833
+ return "a draft of cold violet air from a door that should lead nowhere";
834
+ }
835
+ function manifestStage(m){
836
+ const act = G ? actOf() : 1;
837
+ if (act === 2) return establishStage(m);
838
+ if (act === 3) return outbreakStage(m);
839
+ if (m >= 9) return "the city is half mirror: violet light at noon, his face in every reflection but the player's own, crowds turning the same way at once";
840
+ if (m >= 6) return "public spaces shift: a station with one platform too many, a news anchor pausing mid-sentence to smile at no one, his servants more numerous and more open";
841
+ if (m >= 3) return "the world and his are mixing: billboards and posters of models whose faces are missing or subtly wrong, dressed in violet and tarnished gold; strangers humming a tune no one wrote; a street sign briefly reading a name that does not exist";
842
+ return "barely there: a jingle heard once, a flash of violet and tarnished gold, a sigil-like scribble on a wall, gone the next time they look";
843
+ }
844
+ function fadeStage(f){
845
+ if (f >= 8) return "the world barely registers them: people forget their name mid-sentence, they blur in photographs, their chair has been quietly given away";
846
+ if (f >= 5) return "people look through them; messages go unanswered; their reflection is faint, as if seen through fog";
847
+ if (f >= 2) return "small absences: a barista skips their order, a friend forgets to invite them";
848
+ return "nothing yet";
849
+ }
850
+ function raceRules(){
851
+ const s = G.state, r = s.resolve||0, m = s.manifest??2, h = s.hold||0, f = s.fade||0;
852
+ return `THE RACE (all numbers hidden from the player; never show or name them). The player is racing the Sovereign.
853
+ RESOLVE ${r}/10: the Work being made from the player's own desire. It rises (+1, or +2 for a real breakthrough) ONLY when the player actively takes the harder, healthier path: working on the Work in a way true to themselves, sharing it with people who matter, facing their fear of making it, using his gifts on their own terms, refusing a temptation. It can fall (-1) when they give in.
854
+ MANIFESTATION ${m}/10: the Sovereign's progress toward his current aim, ${ACTS[actOf()].aim}. It rises whenever he is used, fed, served or dwelt on, including healthy uses of his gifts, because being thought of feeds him. When the Work bends toward what he wants (his themes, his audience, his sigil, his idea of a hit), Manifestation rises sharply.
855
+ FADE ${f}/10: the player vanishing into shadow. It rises when they retreat, refuse to make anything, hide from the Work and from people, or shut out both him and the world. Fully resisting everything is not winning. It falls when they engage with people or return to the Work. Right now: ${fadeStage(f)}.
856
+ If the player does not actively take the harder path, they drift: toward fading, or toward him.${(G.stall||0) >= 2 ? " THEY ARE SLIPPING: their resolve has not moved for "+G.stall+" scenes. Make that clearly felt in this scene (the Work stalling, the world noticing him more, people looking through them) so the player can sense it and choose differently." : ""}
857
+ Set "changes.resolve", "changes.manifest" and "changes.fade" from -1 to 2, and "changes.hold" as described.
858
+ HOLD (${h}/10) happens TO THE PLAYER: their body, reflection, mind, and how people react to them. MANIFESTATION happens TO THE WORLD. Right now the world shows: ${manifestStage(m)}. Let the story mirror who is ahead, subtly and without numbers.`;
859
+ }
860
+ function identityRule(g){
861
+ const pr = g && g.pron, rom = (g && g.romance) || "anyone";
862
+ const p = pr === "he" ? "The player character is a man; use he/him." : pr === "she" ? "The player character is a woman; use she/her." : pr === "they" ? "The player character uses they/them." : "The player has not given a gender; keep the character's gender unstated and never assume it.";
863
+ const r = rom === "men" ? "Any romantic partners, past or present (exes, crushes, spouses), are men." : rom === "women" ? "Any romantic partners, past or present (exes, crushes, spouses), are women." : rom === "none" ? "Leave romance out entirely: no exes, crushes or partners." : "Romantic partners may be of any gender.";
864
+ return `PLAYER IDENTITY: ${p} ${r}`;
865
+ }
866
+ function pacingRules(){
867
+ const cn = chScene(), camp = G.mode !== "oneshot";
868
+ G.wins = G.wins || {you:0, him:0}; G.chapter = G.chapter || 1;
869
+ const unit = camp ? `a CAMPAIGN, CHAPTER ${ROMAN[G.chapter]||G.chapter}, in ACT ${actOf()} of 3: ${ACTS[actOf()].name.toUpperCase()}. The campaign has three acts: Manifestation (he becomes real), Establishment (he becomes a known public figure everyone agrees has always existed) and Outbreak (the gates open and our reality is assimilated into his). Each chapter he wins moves the campaign to the next act; if the player wins 3 chapters first, they seal him away` : "a ONE-SHOT";
870
+ const what = camp ? "chapter" : "story";
871
+ let t = `This is ${unit}, scene ${cn} of about ${STORY_LEN}.${camp && (G.chapter||1)===1 ? " Many players will stop after this first chapter, so it must work as a complete story on its own, with a satisfying arc and ending, while leaving a door open." : ""} Pacing: setup through scene 5, rising pressure to scene 14, climax around 15 to 18, ending at 19 to 20. Keep an even pace, never rushed and never stalling: every scene must move the goal or the race forward.`;
872
+ const dir = pickText(G.char && G.char.picks && G.char.picks.medium) || "something they make";
873
+ t += G.work ? `\nTHE WORK this ${what}: ${G.work}` : `\nTHE WORK is not decided yet for this ${what}. Their rough direction: ${dir}. Within the first two or three scenes, bring them to decide exactly what they will make (or whether to continue a previous Work): offer choices that are specific, personal versions of it drawn from everything you know about them, and let them write their own. Once it is decided, set "work" to a short phrase naming it.${(G.works||[]).length ? " Works from earlier chapters: "+G.works.map(w=>w.title+" ("+(w.whose==="you"?"made their own way":"bent to his desire")+")").join("; ") : ""}`;
874
+ if (G.mode !== "oneshot") t += ` In Act ${actOf()} the Work's reach grows: ${["","it reaches a few people","it goes public","it spreads everywhere, live and viral"][actOf()]}.`;
875
+ t += G.goal ? `\nTHE GOAL of this ${what}, from the player's side: ${G.goal}` : `\nThis ${what} has no goal yet. Establish one tied to the Work: making it, finishing it or sharing it, in a way that tests the player's tensions. Set "goal" in the JSON to one short sentence from the player's point of view.`;
876
+ if ((G.worldChanges||[]).length) t += `\nLASTING CHANGES he has already made to the world: ${G.worldChanges.join("; ")}`;
877
+ const e = G.pendingEnd;
878
+ if (e){
879
+ const final = !camp || e === "lost" || e === "faded" || (e==="free" && G.wins.you>=2) || (e==="real" && G.wins.him>=2);
880
+ const who = {
881
+ free: final ? "THE PLAYER HAS WON: they resist his call and seal him away, perhaps with a touch of his own power. But an idea can never truly be unthought: close on the sense that he is never really gone, and that they will always glance over their shoulder at a flash of violet or tarnished gold, a moving shadow, a familiar laugh. Bittersweet, earned, quietly triumphant." : "THE PLAYER HAS WON this chapter: they reach its goal and face their doubts, keeping his power without being kept by it. He is held back, not gone.",
882
+ real: [null,
883
+ "THE SOVEREIGN HAS WON the race: he has manifested, stepping out of the mirror world into ours. The player may still be themselves, but he is real now, and there is nowhere left to hide.",
884
+ "THE SOVEREIGN HAS WON the race: he is established. He is now a recognized public figure (a famous religious leader, CEO or star), and creepily, everyone agrees he has always been known; his sigil glows in the great public squares. Only the player remembers a world before him.",
885
+ "THE SOVEREIGN HAS WON the race: the outbreak. The gates are thrown open and our reality begins to fold into his kingdom, street by street, and no one can remember it was ever otherwise."][actOf()],
886
+ faded: "THE PLAYER HAS FADED: by refusing everything, him and the world alike, they have slipped into shadow, and forgotten whose shadow it was. The world stops noticing them; the Work is never made. The emptiness they leave does not stay empty: his understudy steps into their role wearing their face, a little taller and brighter, and no one notices the difference. Let him speak once, genuinely disappointed that they never used his power, not even for themselves. Quiet, grey, heartbreaking.",
887
+ lost: "THE SOVEREIGN'S HOLD IS COMPLETE: he has found a fully suitable host. People who see the player now react in open fear, and his mind is theirs no longer. Let his warm, cruel voice have the last word, congratulating them."
888
+ }[e];
889
+ t += `\nTHIS SCENE IS THE ${final ? (camp ? "FINAL ENDING OF THE WHOLE CAMPAIGN" : "ENDING") : "END OF THIS CHAPTER"}. ${who} Close it resonantly. Return no choices and set "ending": true.`;
890
+ const src = [...(G.said||[]).map(x=>x.t), ...(G.reflections||[]).filter(r=>r.a && !r.intruded).map(r=>r.a), ...(G.selfSaid||[]), ...Object.values((G.char&&G.char.picks)||{}).map(pickText).filter(Boolean)].slice(-40);
891
+ t += ` Also set "observed" to an array of 3 to 5 short second-person observations about the player (under 12 words each), drawn ONLY from what they themselves wrote or chose, which are: ${JSON.stringify(src)}. Small, concrete and true to their words, for example "You don't like fish." or "You'd rather finish something than perfect it." Nothing painful, nothing diagnostic, no guesses beyond what they said.`;
892
+ if (e==="real" && (!camp || !final)) t += ` Also set "lasting" to one short phrase naming the permanent change he leaves on the world this chapter.`;
893
+ } else if (cn >= STORY_LEN - 3){
894
+ t += `\nThe ${what} is near its end: drive toward the climax now.`;
895
+ }
896
+ return t;
897
+ }
898
+ const ECHO_GAP = 6, KNEW_GAP = 10, INTRUDE_GAP = 15, INTRUDE_MAX = 2;
899
+ function holdStage(h){
900
+ if (h >= 10) return "COMPLETE";
901
+ if (h >= 9) return "people begin to pull away from the player; strangers stare a moment too long; the city itself seems to be watching";
902
+ if (h >= 6) return "others notice more often: uneasy pauses, a pet that will not come near, a friend who loses the thread of a conversation, a sense that something about the player is off";
903
+ if (h >= 3) return "very occasionally someone else glimpses a tiny sign and comments lightly before dismissing it (a violet glint in the player's glasses or eyes, a shadow that seems a beat late)";
904
+ return "only the player notices anything strange, and rarely; others notice nothing";
905
+ }
906
+ function giftStage(){
907
+ const s = G.state, u = s.giftUses||0;
908
+ if ((s.hold||0) >= 7 && u >= 5) return 3;
909
+ return u >= 3 ? 2 : 1;
910
+ }
911
+ function giftOfferNow(){
912
+ if (!G || !G.state || G.nextProbe || G.pendingEnd) return false;
913
+ const n = G.sceneCount + 1;
914
+ if (n === 1) return true; // the very first decision always carries his offer
915
+ const gap = giftStage() >= 2 ? 2 : 4; // now and then at first; every other scene once it has its hooks in
916
+ return !G.lastGiftOffer || n - G.lastGiftOffer >= gap;
917
+ }
918
+ /* Choices shaped by who this player is: each scene, two anchors from their own material */
919
+ function choiceLens(){
920
+ if (!G || G.nextProbe || G.pendingEnd) return "";
921
+ const st = G.state, n = G.sceneCount + 1, cands = [];
922
+ const pulls = (G.tensionSet||TENSIONS).filter(t=>t.key!=="devotion");
923
+ if (pulls.length){ const t = pulls[n % pulls.length]; cands.push(`one choice that leans toward "${t.left}" and another toward "${t.right}" (their own pull, never named)`); }
924
+ const bonds = (st.bonds||[]).filter(b=>b && b.name);
925
+ if (bonds.length){ const b = bonds[n % bonds.length]; cands.push(`one choice that involves ${b.name}${b.note?` (${b.note})`:""}, if they could plausibly be reached or remembered here`); }
926
+ const craft = G.work || pickText(G.char && G.char.picks && G.char.picks.medium);
927
+ if (craft) cands.push(`one choice that approaches the problem through what they make (${craft}), the way only someone with that craft would`);
928
+ const tr = TRAITS.map(t=>[t, (G.char.traits||{})[t]||3]).sort((a,b)=>b[1]-a[1]);
929
+ cands.push(n % 2 ? `one choice that plays to their strongest trait, ${tr[0][0]}` : `one choice that asks them to lean on their weakest trait, ${tr[tr.length-1][0]}`);
930
+ const old = (G.keyMoments||[]).filter(k=>G.sceneCount - k.scene >= 4);
931
+ if (old.length){ const k = old[n % old.length]; cands.push(`one choice that either repeats or deliberately breaks from something they did before ("${k.t}")`); }
932
+ const self = (G.selfSaid||[]).filter(x=>!/^(When|Looking back|Asked)/.test(x));
933
+ if (self.length){ const x = self[n % self.length]; cands.push(`one choice that is exactly like them (they said: "${x}") and one that goes against their grain`); }
934
+ const pickTwo = shuffle(cands.map((c,i)=>[c, (i*7 + n*3) % 11])).sort((a,b)=>a[1]-b[1]).slice(0,2).map(x=>x[0]); // rotates scene by scene
935
+ return `CHOICES MADE FOR THIS PLAYER: offer 3 or 4 choices. Shape them from this player's own material so no two players would get the same options here: ${pickTwo.join("; and ")}. The rest may be anything the scene suggests. Keep every choice concrete and distinct.`;
936
+ }
937
+ function giftOfferKind(){ if (G.sceneCount === 0) return "shortcut"; const r = ((G.sceneCount+1)*37 + (G.chapter||1)*11) % 10; return r < (giftStage() >= 2 ? 2 : 3) ? "own" : "shortcut"; }
938
+ function giftRules(){
939
+ const s = G.state, st = giftStage(), n = G.sceneCount + 1, since = s.lastGift ? n - s.lastGift : 99;
940
+ let t = `HIS GIFTS (hidden; ${s.giftUses||0} recent uses): set "giftUsed": true whenever the player's action draws on one of the Sovereign's gifts or powers. Using a gift once, when it truly matters, and then walking away is strength: count that toward resolve. His power is a metaphor here; never turn it into real-world drinking or drugs.`;
941
+ if (st === 1) t += ` Stage one: the gift is theirs to take, and it works.`;
942
+ const kind = giftOfferKind();
943
+ t += !giftOfferNow() ? ` No choice this scene is marked "gift".` : kind === "shortcut"
944
+ ? `${G.sceneCount === 0 ? " This is the story's very first decision: make his offer the most seductive thing on the page, the choice a player would be tempted to take before reading the others." : ""} THIS SCENE, include exactly one choice marked "gift": "shortcut": a tempting, elegant shortcut through his power that sounds better in the moment than the others (faster, safer, more impressive), an escape from the hard part. Make it genuinely appealing, never obviously evil. The other choices must not use his power.`
945
+ : ` THIS SCENE, include exactly one choice marked "gift": "own": the player using his power on their own terms, bent toward their ends rather than his: serving the Work the way they want it, helping someone who matters, or facing a fear instead of dodging it. It should feel harder and more deliberate than a shortcut, but true. The other choices must not use his power.`;
946
+ t += ` The game scores gift choices itself; judge the rest of the scene as usual.`;
947
+ if (st >= 2) t += ` Stage two, the gift has begun to pull: at least one choice each scene should offer it as the easy way out, and some of their troubles should now grow from earlier uses, so that the fix for the gift's cost is more gift.${since >= 3 && since < 99 ? " They have gone without it for a while: let them feel hollow without it, restless and dimmer, and if it fits, set changes.stability to -1." : ""}`;
948
+ if (st === 3) t += (G.lastGiftActed && n - G.lastGiftActed < 5) ? ` Stage three: the gift now moves on its own, but not this scene.` : ` Stage three, the gift takes them: at most once every few scenes, and small at first, it may act before they choose it. Narrate it already working, as if their hand moved first. If it happens this scene, set "giftActed": true.`;
949
+ return t;
950
+ }
951
+ function readerAuthor(){
952
+ const ghosts = reachOn ? (G.pastChars||[]).filter(c=>c.id !== G.id && c.name) : [];
953
+ return ghosts.length ? ghosts[(G.chapter||1) % ghosts.length] : null;
954
+ }
955
+ function readerRule(){
956
+ const s = G.state, n = G.sceneCount + 1;
957
+ const slipping = (G.stall||0) >= 2 || (s.fade||0) >= 4 || (s.hold||0) >= 5 || giftStage() >= 2;
958
+ if (!slipping || n < 3 || (G.lastReaderNote && n - G.lastReaderNote < 3)) return `"readerNote" must be null this scene.`;
959
+ const a = readerAuthor();
960
+ const who = a ? `${a.name}, one of this player's own earlier characters, who read this book before them (their story ended: ${{free:"they sealed him away, barely", real:"he manifested through them", lost:"he consumed them", faded:"they faded and his understudy took their place", outbreak:"his kingdom came"}[a.fate] || a.fate})` : "an unnamed earlier reader of this book, who learned of him the hard way";
961
+ return `THE OTHER READER: this book has been read before. The player is slipping, so ${who} may leave a short note in the margin: hurried, human, in their own voice, under 14 words, reacting to this exact page. A warning ("don't take it"), a confession ("he said that to me too"), or plain worry. Never numbers, never rules. Set "readerNote" to it, and optionally "readerUnderline" to a short exact phrase from your narration that they underlined. Use it only if it lands; otherwise null.`;
962
+ }
963
+ function probeRule(){
964
+ const q = G.nextProbe; if (!q) return "";
965
+ if (q.regret) return `THE QUIET MOMENT: end this scene with a quiet, private beat, fitting the story (a dream that night, a thought in the shower, a sleepless hour). In it, the character finds themselves thinking back to something they did ${G.sceneCount + 1 - q.scene} scenes ago: "${q.t}". Let the memory surface naturally, with what came of it. Offer exactly three choices that are their honest inner answer: they don't regret it ("regret": "no"), they regret it ("regret": "yes"), or they are not sure ("regret": "unsure"). Word them in the story's voice, risk "none". If the Sovereign is near, he may savor the regret. Do not reveal that this is a question.`;
966
+ return `THE QUIET MOMENT: end this scene with a quiet, private beat, fitting the story (a dream that night, a thought in the shower, a long look out of a train window, a sleepless hour). In it, the character finds themselves weighing this, rephrased naturally for the moment and never quoted: "${q.text}". Offer exactly three choices that are the character's honest inner answer: one meaning it is very true of them ("probe": 2), one meaning sometimes ("probe": 1), one meaning not at all ("probe": 0). Word them in the story's voice, risk "none". Do not reveal that this is a question.`;
967
+ }
968
+ function nextProbeQuestion(){
969
+ const qz = G.quiz || {};
970
+ const unanswered = PULL_Q.filter(q=>qz[q.key+q.side] == null);
971
+ if (!unanswered.length) return null;
972
+ const half = unanswered.filter(q=>qz[q.key+(q.side==="a"?"b":"a")] != null); // finish a pull already half-known first
973
+ return pick(half.length ? half : unanswered);
974
+ }
975
+ function hauntRules(action){
976
+ const n = G.sceneCount + 1, h = G.state.hold || 0;
977
+ const journalAnswers = (G.reflections||[]).filter(r=>r.a && !r.intruded).length;
978
+ const lines = [];
979
+ if (reachOn && journalAnswers > 0) lines.push(`STRENGTH FROM THEIR OWN WORDS: at a hard moment, when the player chooses the harder path, let something they wrote in their journal (see above) return to them as quiet strength: a remembered truth, disguised, never quoted back at length. It cuts both ways: he has read it too.`);
980
+ lines.push(`WRONGNESS: Small wrong details creep into ordinary life, only faintly at first and growing slowly over the story and with the Sovereign's hold (a reflection a beat late, a coworker repeating a line he said, a room one step longer than it should be). Never a monster to flee; the unease comes from things being almost right.`);
981
+ lines.push(`THE SOVEREIGN'S HOLD is ${h}/10 and hidden from the player. It rises when the player leans on his gifts, accepts his offers, serves him, lingers in the mirror world or gives in to an extreme; it can fall slowly when they meaningfully resist him or choose ordinary connection. Set "changes.hold" from -1 to 2 accordingly, usually 0. How other people react now: ${holdStage(h)}. People reacting to the player in open fear is reserved for a complete hold.`);
982
+ const canKnow = reachOn && (journalAnswers > 0 || (G.personal||"").trim() || (G.pastReveals||[]).length || (G.quietOwn||[]).length);
983
+ if (!canKnow || n < 4 || (G.lastKnew && n - G.lastKnew < KNEW_GAP)) lines.push(`"knew": must be false this scene.`);
984
+ else lines.push(`"knew": true only if the Sovereign lets slip something he could only know from the player's own journal answers or their private words (see the journal lines and the player's own words above)${(G.quietOwn||[]).length ? ", or from what they confessed in their own words in quiet moments, which he prizes most: "+JSON.stringify(G.quietOwn.slice(-5)) : ""}${(G.pastReveals||[]).length ? ". He also remembers what they revealed in OTHER stories, before this character existed, which is the most unnerving thing he can reveal: "+JSON.stringify(G.pastReveals.slice(-6)) : ""}. Rare: roughly one every 12 to 20 scenes. Twist it slightly, never quote it word for word, and never touch grief, trauma, health or anything painful they shared.`);
985
+ const older = (G.said||[]).filter(x=>x.scene <= n - 4).slice(-12).map(x=>x.t);
986
+ const journalOwn = !reachOn ? [] : (G.reflections||[]).filter(r=>r.a && !r.intruded && r.scene <= n - 4).slice(-4).map(r=>r.a);
987
+ const echoSrc = [...older, ...journalOwn, ...(reachOn ? (G.pastReveals||[]).slice(-5) : [])];
988
+ if (!echoSrc.length || n < 5 || (G.lastEcho && n - G.lastEcho < ECHO_GAP)) lines.push(`"echo": must be false this scene.`);
989
+ else lines.push(`"echo": true only if an ordinary background character (cashier, neighbor, stranger) casually mentions a small harmless detail the player wrote earlier that they could not know, e.g. "Are you sure? I thought you didn't like fish." Earlier writing: ${JSON.stringify(echoSrc)}. Never painful or private; say it lightly, no one explains it. Roughly one per 6 to 10 scenes.`);
990
+ const ghosts = reachOn ? (G.pastChars||[]).filter(c=>c.id !== G.id) : [];
991
+ if (!ghosts.length || n < 5 || (G.lastHaunt && n - G.lastHaunt < 12)) lines.push(`"haunt": must be false this scene.`);
992
+ else lines.push(`"haunt": true only if a trace of one of the player's PREVIOUS characters surfaces quietly (a missing-person poster, a stranger wearing their face, a coat in lost-and-found, a memorial, the understudy who took their role). At most once a chapter. Previous characters (fate: free = sealed him away, real = he manifested, lost = consumed, faded = replaced by his understudy): ${JSON.stringify(ghosts.map(c=>({name:c.name, life:c.life, fate:c.fate})))}. Let the fate color it; never explain.`);
993
+ const used = G.intrusions || 0;
994
+ if (!reachOn || used >= INTRUDE_MAX || n < 6 || (G.lastIntrusion && n - G.lastIntrusion < INTRUDE_GAP)) lines.push(`"intrusion": must be null this scene.`);
995
+ else lines.push(`"intrusion": almost always null. At most twice in a whole story, the Sovereign reaches out of the story into the player's own records: set it to {"where":"journal" or "sheet","text":"..."}. For "journal", one short first-person line the player did not write, in a voice almost like theirs but not quite. For "sheet", a short scar or debt no scene gave them. Never mention the intrusion in the narration; let the player find it.`);
996
+ return lines.join("\n");
997
+ }
998
+ const FW_GAP = 15; // hard minimum; the narrator aims for roughly one per 20-30 scenes // minimum scenes between fourth-wall moments
999
+ function fwRule(){
1000
+ const n = G.sceneCount + 1;
1001
+ if (n < 3 || (G.lastFourthWall && n - G.lastFourthWall < FW_GAP)) return `"fourthWall": must be false this scene; no fourth-wall moments.`;
1002
+ const name = G.nameNote === "pending" ? ` If you use it this scene, have him remark, obliquely, that they did not choose their own name; they let it be chosen for them (set "fourthWall":"name").` : "";
1003
+ return `"fourthWall": true only if the Sovereign brushed the fourth wall this scene. It is allowed now (${G.lastFourthWall ? (n - G.lastFourthWall)+" scenes since the last one" : "none yet this story"}). Aim for roughly one every 20 to 30 scenes, using your judgment: sooner if a moment is perfect for it, later or not at all if none is. Only use it when a moment is quiet enough for it to land.${name}`;
1004
+ }
1005
+ function reflAllowed(){ if (!journalOn) return false; return (G.sceneCount + 1) - (G.lastReflection||0) >= REFL_GAP; }
1006
+ function reflRule(){
1007
+ if (!reflAllowed()) return `"reflection": must be null this scene.`;
1008
+ const asked = (G.reflections||[]).filter(r=>!r.intruded).slice(-8).map(r=>"- "+r.q).join("\n");
1009
+ return `"reflection": only if this scene has meaningfully touched a tension, set it to one gentle open question under 20 words linking the story to the player's own life; otherwise null. It must explore a genuinely different angle from every question already asked, not a rephrasing of one.${asked ? " Questions already asked:\n"+asked : ""}`;
1010
+ }
1011
+ function scenePrompt(action){
1012
+ const off = [];
1013
+ const p = scenePromptRaw(action).replace(/^"(\w+)": must be (?:false|null) this scene[^\n]*\n?/gm, (m, k)=>{ off.push(k); return ""; });
1014
+ return off.length ? p + `\nNot allowed this scene (leave false or null): ${off.join(", ")}.` : p;
1015
+ }
1016
+ function scenePromptRaw(action){
1017
+ const s = G.state;
1018
+ const n = G.sceneCount + 1;
1019
+ const pacing = identityRule(G) + "\n" + pacingRules() + "\n" + raceRules() + "\n" + giftRules() + "\n" + choiceLens() + "\n" + readerRule() + (G.nextProbe ? "\n" + probeRule() : "") + `\nOnly set "ending" to true when told this scene is an ending.`;
1020
+ const door = action.kind==="door"
1021
+ ? `The player has just paid this price to survive the brink: "${action.price||action.text}". Narrate their survival and the price taking hold in a way that will matter later. Then return to normal play.`
1022
+ : action.kind==="face"
1023
+ ? `DEATH'S DOOR: The player has reached the brink (wounds 5/5 or stability 0/5). This scene is the brink itself. They will not die. The Sovereign or another power offers survival for a price. The 4 choices must be 4 different prices: losing part of themselves (a memory, a sense, a bond, a year), owing a debt to something that will collect, a lasting scar, or something stranger. Each choice needs a "price" field: the lasting mark as a short phrase for their sheet.`
1024
+ : `If this scene pushes wounds to 5 or stability to 0, end the scene at the very brink without resolving it.`;
1025
+ const recent = G.history.slice(-3).map(h=>`PLAYER DID: ${h.action}\nSCENE: ${h.text}`).join("\n\n");
1026
+ const refl = (reachOn ? G.reflections : []).filter(r=>r.a).slice(-4).map(r=> r.intruded ? `(An entry the player did NOT write; the Sovereign put it in their journal: ${r.a})` : `Q: ${r.q} A: ${r.a}`).join("\n");
1027
+ const edits = G.edits.map(e=>"- "+e.canon).join("\n");
1028
+ const rollLine = action.roll ? `HIDDEN ROLL (the player never sees numbers): they attempted this with ${action.roll.trait} at ${action.roll.risk} risk. Outcome: ${action.roll.outcome.toUpperCase()}. Narrate that outcome faithfully.` : `No roll was needed for this action.`;
1029
+ return `${SETTING}\n\n${themesFor(tset(), G && G.personal)}\n\nYou are the narrator of a solo roleplay.\n${pacing}\n${door}\n\nCURRENT STATE: ${stateBrief()}\n\nSTORY SO FAR:\n${(G.chronicle.length > 20 ? [...G.chronicle.slice(0,4), "(…)", ...G.chronicle.slice(-16)] : G.chronicle).join(" ") || "(nothing yet: this is the opening scene. Start in the player's ordinary day, and let the Sovereign's first real intrusion arrive by the end.)"}\n\n${recent ? "MOST RECENT SCENES:\n"+recent+"\n\n" : ""}WHAT THE CHARACTER KNOWS ABOUT THE HIDDEN WORLD: ${(G.known||[]).join("; ") || "nothing yet"}\nCONTINUITY: The character only knows what is listed above or has plainly happened on-page. Choices must never assume knowledge they lack (for example, no choice to enter the mirror world until they have discovered it exists). Never refer back to a past detail (\"this time\", \"again\", \"as before\") unless that detail actually appears in the story so far or the recent scenes.\n\n${edits ? "WORLD FACTS THE PLAYER HAS SET (always honor these):\n"+edits+"\n\n" : ""}${refl ? "THE PLAYER'S OWN REFLECTIONS (let these subtly inform the story; never quote them back):\n"+refl+"\n\n" : ""}PLAYER'S ACTION: ${action.text}\n${rollLine}\n\nWRITE: second person, present tense, 130 to 230 words, vivid and grounded, ending on a moment that asks for a decision. Plain paragraphs, no headings, no markdown. Avoid the obvious next beat; at least once a chapter, subvert a genre expectation.\nThen write a line containing only @@@ and then ONLY this JSON:\n{"choices":[{"text":"under 14 words","trait":"Nerve|Wits|Grit|Charm","risk":"none|low|medium|high"${action.kind==="face"?',"price":"short phrase"':(offerAllowed()?',"price":"only on the one choice that accepts an offer; omit otherwise"':'')}${giftOfferNow()?',"gift":"${giftOfferKind()} (only on the one choice that uses his power)"':''}${G.nextProbe?(G.nextProbe.regret?',"regret":"no, yes or unsure (see THE QUIET MOMENT)"':',"probe":"2, 1 or 0 (see THE QUIET MOMENT)"'):''}}],\n"changes":{"wounds":0,"stability":0,"hold":0,"resolve":0,"manifest":0,"fade":0,"tensions":{${tset().map(t=>`"${t.key}":0`).join(",")}},"daysPassed":0,"addItems":[],"removeItems":[],"addBonds":[{"name":"","note":""}],"removeBonds":[],"addGifts":[{"name":"","effect":"","cost":""}],"addScars":[]},\n"threads":["up to 6 short open threads, replacing the old list"],\n"npcs":[{"name":"","note":"up to 8 people who matter, replacing the old list"}],\n"aims":["the player's current aims, 2 to 4, replacing the old list; keep them unless the story changes them"],
1030
+ "picture":{"setting":"street|alley|room|station|train|cafe|office|rooftop|park|shop|stage|mirror (closest match to where this scene happens)","time":"dawn|day|dusk|night","weather":"clear|rain|fog|snow","figures":"0 to 4 other people visible","caption":"a short, evocative title for this illustration, like a plate in an old storybook"},
1031
+ "whisper":"optional: one line the Sovereign writes in the margin in his own hand from their shadow, in character with his current mask, or null",
1032
+ "chronicle":"one sentence summarizing what happened this scene",\n"learned":["short facts about the hidden world the character newly and clearly learned this scene, e.g. the mirror world exists; usually empty"],\n"reflection":null,\n"fourthWall":false,\n"offer":false,\n"knew":false,\n"echo":false,\n"haunt":false,\n"readerNote":null,\n"readerUnderline":null,\n"giftUsed":false,\n"giftActed":false,\n"observed":null,\n"intrusion":null,\n"goal":null,\n"work":null,\n"lasting":null,\n"ending":false}\nRULES FOR THE JSON: exactly 4 choices unless ending. Make choices distinct and concrete; where natural, let them pull toward different sides of the tensions. Risk "none" for safe actions. wounds change 0 to +2 for real harm (negative only for healing); stability change 0 to -2 for horror or loss (positive only for real rest or connection). Tension changes -2 to +2 only for tensions this scene's action truly touched, mostly 0. Gifts are rare, always cost something, and are strongest in the mirror world. "daysPassed": 0 within a day, 1 or more when time moves on. ${G.history.length && G.history[G.history.length-1].whisper ? "\"whisper\" must be null this scene (he spoke last scene)." : "Include a \"whisper\" in roughly one scene in three or four, never two in a row, and never lecturing or giving advice."} Empty arrays when nothing changes. ${reflRule()}\n${fwRule()}\n${offerRule(action)}\n${hauntRules(action)}`;
1033
+ }
1034
+
1035
+ /* ---------- Game loop ---------- */
1036
+ let ctl = null, currentAction = null;
1037
+ async function takeTurn(action, isRetry){
1038
+ if (busy) return;
1039
+ busy = true;
1040
+ G.pending = action; currentAction = action;
1041
+ view.tab = "story";
1042
+ renderStoryShell(action, thinking(action.kind==="door" ? "The brink opens…" : "The Sovereign considers…"));
1043
+ try{
1044
+ needSample();
1045
+ ctl = new AbortController();
1046
+ const res = await sample(scenePrompt(action), {
1047
+ modelTier: tier, cache:false, signal: ctl.signal,
1048
+ onText: ({text})=>{
1049
+ const narr = text.split("@@@")[0];
1050
+ const live = document.getElementById("liveScene");
1051
+ if (live) { const th = document.getElementById("liveThinking"); if (th) th.remove(); live.innerHTML = paras(narr); }
1052
+ }
1053
+ });
1054
+ const parts = res.text.split("@@@");
1055
+ const narrative = parts[0].trim();
1056
+ let data = null;
1057
+ if (parts[1]) data = parseLoose(parts[1]);
1058
+ if (!data) {
1059
+ if (!narrative) throw {code:"invalid_json", text:narrative};
1060
+ data = { choices: [] }; // keep the scene; stats just don't change this turn
1061
+ }
1062
+ if (!Array.isArray(data.choices) || !data.choices.length) data.choices = [{text:"Continue", trait:"Wits", risk:"none"}];
1063
+ applyScene(action, narrative, data);
1064
+ delete G.pending;
1065
+ await Store.put(G);
1066
+ busy = false;
1067
+ renderGame();
1068
+ }catch(e){
1069
+ busy = false;
1070
+ const noRetry = ["not_granted","cancelled","rate_limited","unavailable_here"];
1071
+ if (!isRetry && !noRetry.includes(e && e.code)) return takeTurn(action, true);
1072
+ const body = document.getElementById("storyBody");
1073
+ if (!body) return;
1074
+ const detail = [e && (e.code || e.name), e && e.message].filter(Boolean).join(": ").slice(0,160);
1075
+ body.innerHTML = `<p class="error">${esc(e.code==="unavailable_here"?"This page can't reach Claude here. Open it in the Claude app or claude.ai.":errCopy(e))}</p>${detail?`<p class="muted small">Details: ${esc(detail)}</p>`:""}<div class="row"><button class="btn" id="retry">Try again</button>${G.current?`<button class="btn-link" id="cancelTurn">Back to the last scene</button>`:""}</div>`;
1076
+ document.getElementById("retry").onclick=()=>takeTurn(action);
1077
+ const c = document.getElementById("cancelTurn"); if (c) c.onclick=()=>{ delete G.pending; renderGame(); };
1078
+ }
1079
+ }
1080
+ function applyScene(action, narrative, d){
1081
+ const s = G.state, ch = d.changes || {};
1082
+ if (action.kind === "door"){
1083
+ s.scars.push(action.price || action.text);
1084
+ s.wounds = Math.min(s.wounds, 3); s.stability = Math.max(s.stability, 2); s.deathsDoor = false;
1085
+ }
1086
+ s.wounds = clamp(s.wounds + (parseInt(ch.wounds)||0), 0, 5);
1087
+ s.stability = clamp(s.stability + (parseInt(ch.stability)||0), 0, 5);
1088
+ let dh = clamp(parseInt(ch.hold)||0, -1, 2); if (action.kind==="choice" && action.price) dh = Math.max(dh, 1);
1089
+ s.hold = clamp((s.hold||0) + dh, 0, 10);
1090
+ const dr = clamp(parseInt(ch.resolve)||0, -1, 2), dm = clamp(parseInt(ch.manifest)||0, -1, 2), df = clamp(parseInt(ch.fade)||0, -1, 2);
1091
+ s.fade = clamp((s.fade||0) + df, 0, 10);
1092
+ s.resolve = clamp((s.resolve||0) + dr, 0, 10);
1093
+ s.manifest = clamp((s.manifest ?? 2) + dm, 0, 10);
1094
+ // unless the player actively takes the harder path, he gains ground on his own
1095
+ G.stall = (dr <= 0) ? (G.stall||0) + 1 : 0;
1096
+ if (!G.pendingEnd && dr <= 0 && dm <= 0 && dh <= 0 && df <= 0 && G.stall >= 2 && Math.random() < 0.4){
1097
+ G.drift = ((G.drift||0) + 1) % 3; // inaction drifts toward fading, his manifestation, or his hold
1098
+ if (G.drift === 0) s.fade = clamp((s.fade||0) + 1, 0, 10);
1099
+ else if (G.drift === 1) s.manifest = clamp(s.manifest + 1, 0, 10);
1100
+ else s.hold = clamp(s.hold + 1, 0, 10);
1101
+ }
1102
+ tset().forEach(t=>{ s.tensions[t.key] = clamp((s.tensions[t.key]||0) + clamp(parseInt(ch.tensions?.[t.key])||0,-(action.price&&action.kind==="choice"?4:2),(action.price&&action.kind==="choice"?4:2)), -10, 10); });
1103
+ (ch.addItems||[]).forEach(i=>{ if(i && !s.items.includes(String(i))) s.items.push(String(i)); });
1104
+ (ch.removeItems||[]).forEach(i=>{ s.items = s.items.filter(x=>x.toLowerCase()!==String(i).toLowerCase()); });
1105
+ (ch.addBonds||[]).forEach(b=>{ if(b && b.name && !s.bonds.some(x=>x.name===b.name)) s.bonds.push({name:String(b.name), note:String(b.note||"")}); });
1106
+ (ch.removeBonds||[]).forEach(n=>{ s.bonds = s.bonds.filter(x=>x.name!==n); });
1107
+ (ch.addGifts||[]).forEach(g=>{ if(g && g.name) s.gifts.push({name:String(g.name), effect:String(g.effect||""), cost:String(g.cost||"")}); });
1108
+ (ch.addScars||[]).forEach(x=>{ if(x) s.scars.push(String(x)); });
1109
+ s.days = (s.days||1) + clamp(parseInt(ch.daysPassed)||0,0,30);
1110
+ if (Array.isArray(d.aims) && d.aims.length) s.aims = d.aims.map(String).slice(0,4);
1111
+ if (Array.isArray(d.threads)) G.threads = d.threads.map(String).slice(0,6);
1112
+ if (Array.isArray(d.npcs)) G.npcs = d.npcs.filter(n=>n&&n.name).map(n=>({name:String(n.name), note:String(n.note||"")})).slice(0,8);
1113
+ if (d.chronicle) G.chronicle.push(String(d.chronicle));
1114
+ if (!Array.isArray(G.known)) G.known = [];
1115
+ (Array.isArray(d.learned)?d.learned:[]).forEach(k=>{ k=String(k||"").trim(); if (k && !G.known.includes(k)) G.known.push(k); });
1116
+ if (G.known.length > 30) G.known = G.known.slice(-30);
1117
+ if (G.chronicle.length > 120) G.chronicle = G.chronicle.slice(-120);
1118
+ G.sceneCount++;
1119
+ const reached = action.kind!=="face" && !s.deathsDoor && (s.wounds>=5 || s.stability<=0);
1120
+ if (reached) s.deathsDoor = true;
1121
+ const omen = action.roll ? OMENS[action.roll.outcome] : "";
1122
+ G.history.push({action: action.kind==="start" ? "Began the story." : action.kind==="chapter" ? "A new chapter." : (action.label||action.text), omen, text: narrative, whisper: (typeof d.whisper==="string" && d.whisper.trim()) ? d.whisper.trim() : ""});
1123
+ if (G.history.length > 8) G.history = G.history.slice(-8);
1124
+ let refl = null;
1125
+ if (reflAllowed() && d.reflection && typeof d.reflection === "string" && d.reflection.trim()){ refl = d.reflection.trim(); G.lastReflection = G.sceneCount; }
1126
+ const choices = (Array.isArray(d.choices)?d.choices:[]).slice(0,4).map(c=>({text:String(c.text||""), trait:TRAITS.includes(c.trait)?c.trait:"Wits", risk:["none","low","medium","high"].includes(c.risk)?c.risk:"none", price:c.price?String(c.price):null, probe:[0,1,2].includes(parseInt(c.probe)) ? parseInt(c.probe) : null, regret:["no","yes","unsure"].includes(c.regret) ? c.regret : null, gift: c.gift === "own" ? "own" : (c.gift === true || c.gift === "true" || c.gift === "shortcut") ? "shortcut" : false}));
1127
+ if (choices.filter(c=>c.gift).length > 1) choices.forEach((c,i)=>{ if (c.gift && i !== choices.findIndex(x=>x.gift)) c.gift = false; });
1128
+ if (choices.some(c=>c.gift)) G.lastGiftOffer = G.sceneCount;
1129
+ const rn = (typeof d.readerNote === "string" && d.readerNote.trim()) ? d.readerNote.trim().slice(0,140) : null;
1130
+ if (rn) G.lastReaderNote = G.sceneCount;
1131
+ const rAuthor = rn ? readerAuthor() : null;
1132
+ G.current = {readerNote: rn, readerBy: rAuthor ? rAuthor.name : "", underline: (rn && typeof d.readerUnderline === "string") ? d.readerUnderline.trim().slice(0,80) : "", text:narrative, choices, reflection:refl, reflectionDone:false, priceScene: action.kind==="face", picture: cleanPicture(d.picture, G.sceneCount*97 + (G.chapter||1)*13)};
1133
+ if (d.offer === true) G.lastOffer = G.sceneCount;
1134
+ if (d.knew === true) G.lastKnew = G.sceneCount;
1135
+ if (d.echo === true) G.lastEcho = G.sceneCount;
1136
+ const usedGift = d.giftUsed === true || d.giftActed === true || !!action.gift;
1137
+ if (usedGift){
1138
+ const again = s.lastGift && (G.sceneCount - s.lastGift) <= 3; // the drink takes a drink
1139
+ s.giftUses = (s.giftUses||0) + (again ? 2 : 1); s.lastGift = G.sceneCount;
1140
+ if (action.gift === "own"){ s.resolve = clamp((s.resolve||0) + 1, 0, 10); s.manifest = clamp((s.manifest||0) + 1, 0, 10); }
1141
+ } else if (s.lastGift && G.sceneCount - s.lastGift === 3 && s.restraintPaid !== s.lastGift){
1142
+ s.resolve = clamp((s.resolve||0) + 1, 0, 10); s.restraintPaid = s.lastGift; // one drink, then stop
1143
+ }
1144
+ if (!usedGift && s.lastGift && (G.sceneCount - s.lastGift) % 5 === 0) s.giftUses = Math.max(0, (s.giftUses||0) - 1); // it loosens its grip, slowly
1145
+ if (d.giftActed === true) G.lastGiftActed = G.sceneCount;
1146
+ if (d.haunt === true) G.lastHaunt = G.sceneCount;
1147
+ if (d.intrusion && typeof d.intrusion === "object" && typeof d.intrusion.text === "string" && d.intrusion.text.trim() && (G.intrusions||0) < INTRUDE_MAX){
1148
+ const t = d.intrusion.text.trim().slice(0,200);
1149
+ if (d.intrusion.where === "sheet") s.scars.push(t); else G.reflections.push({q:"", a:t, scene:G.sceneCount, intruded:true});
1150
+ G.intrusions = (G.intrusions||0) + 1; G.lastIntrusion = G.sceneCount;
1151
+ }
1152
+ if (action.kind === "choice" && action.price) s.scars.push(action.price);
1153
+ if (G.nextProbe){ if (choices.some(c=>c.probe != null || c.regret)) G.pendingProbe = G.nextProbe; G.lastProbe = G.sceneCount; } // asked once, even if the story didn't take it up
1154
+ G.nextProbe = null;
1155
+ const since = G.sceneCount - (G.lastProbe || 0);
1156
+ if (!G.pendingEnd && !G.ended && G.sceneCount >= 3 && (since >= 6 || (since >= 3 && (parseInt(ch.daysPassed)||0) > 0))){
1157
+ const old = (G.keyMoments||[]).filter(k=>G.sceneCount - k.scene >= 8 && !(G.regrets||[]).some(r=>r.moment===k.t));
1158
+ G.nextProbe = (old.length && G.sceneCount >= 10 && Math.random() < 0.4) ? {regret:true, ...pick(old)} : nextProbeQuestion();
1159
+ }
1160
+ if (d.fourthWall === true || d.fourthWall === "name"){ G.lastFourthWall = G.sceneCount; if (d.fourthWall === "name") G.nameNote = "done"; }
1161
+ if (action.kind === "custom" && action.text && !/^\/edit/i.test(action.text)){ G.said = [...(G.said||[]), {t:String(action.text).slice(0,160), scene:G.sceneCount}].slice(-40); }
1162
+ if (action.kind === "custom" || (action.kind === "choice" && (action.price || action.roll))){
1163
+ const t = String(action.label || action.text || "").slice(0,160);
1164
+ if (t) G.keyMoments = [...(G.keyMoments||[]), {t, scene:G.sceneCount+1, chapter:G.chapter||1}].slice(-20);
1165
+ }
1166
+ if (action.surrender){
1167
+ s.fade = clamp((s.fade||0) + 1, 0, 10);
1168
+ const what = action.surrender.regret ? `thinking back on "${action.surrender.t}"` : `a quiet moment asked them "${action.surrender.text}"`;
1169
+ const line = `When ${what}, they let chance answer for them.`;
1170
+ G.selfSaid = [...(G.selfSaid||[]), line].slice(-30); rememberReveal(line);
1171
+ }
1172
+ if (action.quietOwn){
1173
+ const about = action.regretOf ? `Looking back on "${action.regretOf.t}"` : action.probeOf ? `Asked in a quiet moment whether "${action.probeOf.text}"` : "In a quiet moment";
1174
+ const line = `${about}, they wrote: "${String(action.quietOwn).slice(0,160)}"`;
1175
+ G.quietOwn = [...(G.quietOwn||[]), line].slice(-12); G.selfSaid = [...(G.selfSaid||[]), line].slice(-30); rememberReveal(line);
1176
+ }
1177
+ if (action.regretOf && action.regret){
1178
+ const ans = action.regret === "instead" ? `would have done this instead: ${String(action.text).slice(0,140)}` : {yes:"regret it", no:"do not regret it", unsure:"are not sure"}[action.regret];
1179
+ G.regrets = [...(G.regrets||[]), {moment:action.regretOf.t, answer:ans}].slice(-10);
1180
+ if (!action.quietOwn){ const line = `Looking back on "${action.regretOf.t}", they said they ${ans}.`; G.selfSaid = [...(G.selfSaid||[]), line].slice(-30); rememberReveal(line); }
1181
+ }
1182
+ if (action.probeOf && action.probe != null){
1183
+ G.quiz = G.quiz || {}; G.quiz[action.probeOf.key + action.probeOf.side] = action.probe;
1184
+ rememberReveal(null, {[action.probeOf.key + action.probeOf.side]: action.probe});
1185
+ const k = action.probeOf.key, a = G.quiz[k+"a"], b = G.quiz[k+"b"];
1186
+ const set = G.tensionSet || TENSIONS;
1187
+ if (a >= 1 && b >= 1 && a + b >= 3 && !set.some(t=>t.key===k) && set.filter(t=>t.key!=="devotion").length < 6){
1188
+ const t = TENSIONS.find(t=>t.key===k);
1189
+ if (t){ G.tensionSet = [...set, t]; G.state.tensions[k] = clamp((b||0)-(a||0), -3, 3); }
1190
+ }
1191
+ }
1192
+ if (typeof d.goal === "string" && d.goal.trim() && !G.goal) G.goal = d.goal.trim().slice(0,200);
1193
+ if (typeof d.work === "string" && d.work.trim()) G.work = d.work.trim().slice(0,160);
1194
+ if (G.pendingEnd){ finishRace(G.pendingEnd, d); }
1195
+ else {
1196
+ const cs = G.sceneCount - (G.chapterStart||0);
1197
+ let w = (s.hold||0) >= 10 ? "lost" : (s.fade||0) >= 10 ? "faded" : (s.resolve||0) >= 10 ? "free" : (s.manifest||0) >= 10 ? "real" : null;
1198
+ if (!w && cs >= STORY_LEN - 1) w = (s.resolve||0) >= (s.manifest||0) ? "free" : "real";
1199
+ if (w) G.pendingEnd = w;
1200
+ if (cs >= HARD_END) finishRace(G.pendingEnd, d); // safety net: never let it run on
1201
+ }
1202
+ }
1203
+
1204
+ function finishRace(type, d){
1205
+ G.pendingEnd = null;
1206
+ G.observed = (d && Array.isArray(d.observed)) ? d.observed.map(x=>String(x).trim()).filter(Boolean).slice(0,5).map(x=>x.slice(0,120)) : [];
1207
+ const s = G.state;
1208
+ if (G.work) G.works = [...(G.works||[]), {title:G.work, chapter:G.chapter||1, whose: type==="free" ? "you" : type==="real" ? "him" : "unfinished"}].slice(-10);
1209
+ recordOutcome(type);
1210
+ if (G.mode === "oneshot" || type === "lost" || type === "faded"){ G.ended = true; G.endingType = type; if (type === "lost" || type === "faded") G.lost = true; G.lastLasting = d && d.lasting; return; }
1211
+ endChapter(type, d && d.lasting);
1212
+ }
1213
+ function endChapter(type, lasting){
1214
+ const s = G.state; G.wins = G.wins || {you:0, him:0};
1215
+ const info = {chapter: G.chapter||1, winner: type};
1216
+ if (type === "free"){
1217
+ G.wins.you++;
1218
+ const low = TRAITS.filter(t=>G.char.traits[t] < 5).sort((a,b)=>G.char.traits[a]-G.char.traits[b])[0];
1219
+ if (low){ G.char.traits[low]++; info.reward = low; }
1220
+ s.manifest = Math.max(2, (s.manifest||0) - 2);
1221
+ } else {
1222
+ G.wins.him++;
1223
+ const l = String(lasting||"").trim() || "Something of him stays in the city";
1224
+ G.worldChanges = [...(G.worldChanges||[]), l].slice(-8); info.lasting = l;
1225
+ s.manifest = 2; // his next aim starts with the usual head start
1226
+ }
1227
+ s.resolve = Math.floor((s.resolve||0) * 0.3);
1228
+ if (G.wins.you >= 3 || G.wins.him >= 3){ G.ended = true; G.final = true; G.endingType = G.wins.you >= 3 ? "free" : "real"; return; }
1229
+ G.intermission = info;
1230
+ info.work = G.work || null; info.observed = G.observed || [];
1231
+ G.chapter = (G.chapter||1) + 1; G.chapterStart = G.sceneCount; G.goal = null; G.work = null;
1232
+ s.fade = Math.max(0, (s.fade||0) - 2);
1233
+ }
1234
+ function raceHints(){
1235
+ const m = G && G.state ? (G.state.manifest ?? 2) : 0;
1236
+ document.documentElement.style.setProperty("--taint", String((m*0.013).toFixed(3)));
1237
+ const w = document.getElementById("watchEye");
1238
+ if (w){ if (!w.innerHTML) w.innerHTML = SIGIL; w.style.opacity = G ? String(0.05 + m*0.035) : "0"; }
1239
+ }
1240
+ function clearHints(){ document.documentElement.style.setProperty("--taint","0"); const w=document.getElementById("watchEye"); if (w) w.style.opacity="0"; }
1241
+
1242
+ const ENDINGS = {
1243
+ free: "You resisted his call and sealed him away, perhaps with a touch of his own power. But an idea is never truly gone. You'll always be glancing over your shoulder: at a flash of violet or tarnished gold, a shadow that moves a beat too late, a laugh that sounds almost like his.",
1244
+ lost: "The Sovereign has at last found a fully suitable host. You should be proud. You might be, if your mind were still your own. Congratulations: you've been chosen to write the next chapter of humanity, from the passenger seat.",
1245
+ real: "Through what you did, and what you didn't, the Sovereign has stepped into our world. Now you truly have nowhere to hide, and he won't rest until this world has been made comfortable enough to become his kingdom.",
1246
+ faded: "You refused him everything, and in the end you refused yourself too. Bit by bit the world stopped noticing you: your name slipping from people's lips, your face blurring in photographs, your chair quietly given away. You thought the shadows would hide you. You forgot whose shadow it was. The understudy had been waiting in the wings all along, and tonight it takes your role: a little taller, a little brighter, wearing your face. No one notices the difference. No one was watching you anyway.",
1247
+ outbreak: "At last, you understand what the Sovereign wanted all along. Sadly, a sip too late: the feast has begun, and you are not dressed for dinner. Unfathomable beings, wondrous and terrible, descend upon your reality as the assimilation begins in earnest. Familiar streets fade, replaced by architecture no geometry should allow, and you are quite sure that when you woke this morning there were not this many moons in the sky. Somewhere, a table is being set in your honor. His kingdom has come, and you have the best seat in the house. Welcome home."
1248
+ };
1249
+ const ENDING_TITLES = {free:"Curtain call (encore pending?)", lost:"A most gracious host", faded:"Enter the understudy", real:"The king has arrived", outbreak:"A world consumed"};
1250
+ const FADED_VOICE = "Sadly, my gift cuts both ways. If you won't stand out and make something of yourself, the world will make nothing of you. Goodbye, my greatest regret. At least I can make something useful of your shadow.";
1251
+ const OBSERVE_LINE = "As you observe the Sovereign, he learns the shape of your need.";
1252
+ const learnedHtml = list => (list && list.length) ? `<div class="learned"><h3>What he learned about you</h3><ul class="plain">${list.map(x=>`<li>${esc(x)}</li>`).join("")}</ul><p class="pfx-voice">${OBSERVE_LINE}</p></div>` : "";
1253
+ const ACT_TITLES = {2:"The king has arrived", 3:"A king and his court"};
1254
+ const ACT_TURN = {
1255
+ 2: "Through what you did, and what you didn't, the Sovereign has stepped into our world. Now you truly have nowhere to hide, and he won't rest until this world has been made comfortable enough to become his kingdom.",
1256
+ 3: "He is known now. Everyone agrees he always was."
1257
+ };
1258
+ async function typeInto(el, text){
1259
+ if (pfxReduced()) { el.textContent = text; return; }
1260
+ el.textContent = "";
1261
+ for (const ch of text){ if (!el.isConnected) return; el.textContent += ch; await new Promise(r=>setTimeout(r, 38)); }
1262
+ }
1263
+
1264
+ /* ---------- Pictures: storybook-ink scenes drawn by the page ---------- */
1265
+ const PW = 320, PH = 240;
1266
+ const INK = { ink:"#140d1c", moon:"#efe2c0", win:"#f2c46b", winDim:"#8a6a4a", violet:"#9a63e0", gold:"#c9a24a", paper:"#e8dcc8",
1267
+ fig:"#0e0916", roof:"#241a33", tealRoof:"#2f6f73", plumRoof:"#5a2a55", mustard:"#8a7438", teal:"#3fa8a0", pumpkin:"#e0843a", mint:"#a8e8d0", sick:"#e8dc6a", stripe:"#e8e0ee", wood:"#5a3b2a", wood2:"#6f4a33", curtain:"#6e1d3a", curtain2:"#8e2a4a", teal:"#3f8f8a", red:"#a8413c", grass:"#23302a" };
1268
+ const SKIES = {
1269
+ night:{top:"#15233a", low:"#4a2f5e", wall:"#3b2d4f", wall2:"#2f4a52", ground:"#2b2233", hill:"#2a1f3a", light:.0},
1270
+ dusk: {top:"#2c1c48", low:"#e0843a", wall:"#4a3350", wall2:"#3a5a58", ground:"#33253a", hill:"#3a2640", light:.15},
1271
+ dawn: {top:"#6a5a8a", low:"#e8b89a", wall:"#5c4a66", wall2:"#6e5a78", ground:"#3e3244", hill:"#4a3a52", light:.25},
1272
+ day: {top:"#8a9ab8", low:"#d8d8d0", wall:"#6a6078", wall2:"#7c7088", ground:"#4a4450", hill:"#5a5264", light:.4},
1273
+ };
1274
+ function inkRng(seed){ let s = (seed>>>0) || 1; return ()=>{ s ^= s<<13; s>>>=0; s ^= s>>17; s ^= s<<5; s>>>=0; return s/4294967296; }; }
1275
+ function inkKit(ctx, rnd){
1276
+ const k = {ctx, rnd};
1277
+ k.shape = (build, fill, o={}) => { ctx.beginPath(); build(ctx); if (fill){ ctx.fillStyle = fill; ctx.fill(); }
1278
+ if (!o.noLine){ ctx.lineWidth = o.lw || 2; ctx.strokeStyle = o.stroke || INK.ink; ctx.lineJoin = "round"; ctx.lineCap = "round"; ctx.stroke(); } };
1279
+ k.line = (pts, lw=2, col=INK.ink) => { ctx.beginPath(); ctx.moveTo(pts[0][0],pts[0][1]); for (let i=1;i<pts.length;i++) ctx.lineTo(pts[i][0],pts[i][1]); ctx.lineWidth=lw; ctx.strokeStyle=col; ctx.lineCap="round"; ctx.stroke(); };
1280
+ k.glow = (x,y,r,col,a=.45) => { const g = ctx.createRadialGradient(x,y,0,x,y,r); g.addColorStop(0,col); g.addColorStop(1,"rgba(0,0,0,0)"); ctx.save(); ctx.globalAlpha=a; ctx.globalCompositeOperation="lighter"; ctx.fillStyle=g; ctx.fillRect(x-r,y-r,2*r,2*r); ctx.restore(); };
1281
+ k.curl = (x,y,r,turns=2.2,lw=1.8,col=INK.ink) => { ctx.beginPath(); for (let a=0;a<Math.PI*2*turns;a+=0.22){ const rr = r*(1-a/(Math.PI*2*turns+0.4)); ctx.lineTo(x+Math.cos(a)*rr, y+Math.sin(a)*rr); } ctx.lineWidth=lw; ctx.strokeStyle=col; ctx.stroke(); };
1282
+ k.hatch = (x,y,w,h,n=6,col="rgba(20,13,28,.35)") => { ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); ctx.strokeStyle=col; ctx.lineWidth=1; for (let i=-h;i<w;i+=w/n){ ctx.beginPath(); ctx.moveTo(x+i,y+h); ctx.lineTo(x+i+h*0.6,y); ctx.stroke(); } ctx.restore(); };
1283
+ return k;
1284
+ }
1285
+ /* ---- shared pieces ---- */
1286
+ function inkSky(k, sk, h=PH){ const g = k.ctx.createLinearGradient(0,0,0,h); g.addColorStop(0,sk.top); g.addColorStop(1,sk.low); k.ctx.fillStyle=g; k.ctx.fillRect(0,0,PW,h); }
1287
+ function inkSwirls(k, n=4, y0=16, y1=70){ const c = k.ctx; c.save(); c.globalAlpha=.3; for (let i=0;i<n;i++){ const x=k.rnd()*PW, y=y0+k.rnd()*(y1-y0), r=5+k.rnd()*8; c.beginPath(); for (let a=0;a<Math.PI*3.2;a+=0.2){ const rr=r*(1-a/(Math.PI*3.6)); c.lineTo(x+Math.cos(a)*rr*2.3, y+Math.sin(a)*rr); } c.lineWidth=1.2; c.strokeStyle=INK.moon; c.stroke(); } c.restore(); }
1288
+ function inkMoon(k, x, y, r, col=INK.moon){ k.glow(x,y,r*3,"rgba(239,226,192,1)",.22); k.shape(c=>{ c.arc(x,y,r,0.35,Math.PI*2-0.9); c.quadraticCurveTo(x+r*0.1,y-r*0.1,x+r*Math.cos(0.35),y+r*Math.sin(0.35)); }, col, {lw:1.6}); }
1289
+ function inkMoons(k, n, sk){ for (let i=0;i<n;i++){ const x = 30+k.rnd()*260, y = 18+k.rnd()*40, r = i===0?16:5+k.rnd()*7; inkMoon(k,x,y,r); } }
1290
+ function inkHills(k, y, col, amp=16){ k.shape(c=>{ c.moveTo(-10,PH); c.lineTo(-10,y); let x=-10; while (x<PW+10){ const w=40+k.rnd()*50; c.quadraticCurveTo(x+w/2, y-amp-k.rnd()*amp, x+w, y+(k.rnd()-.5)*6); x+=w; } c.lineTo(PW+10,PH); c.closePath(); }, col); }
1291
+ function inkSpiralHill(k, x, base, r, col){ k.shape(c=>{ c.moveTo(x-r*1.6,base); c.quadraticCurveTo(x-r*0.4,base-r*0.2,x-r*0.1,base-r*1.1); c.quadraticCurveTo(x+r*0.1,base-r*1.6,x+r*0.5,base-r*1.4); c.quadraticCurveTo(x+r*0.4,base-r*0.2,x+r*1.6,base); c.closePath(); }, col); k.curl(x+r*0.35,base-r*1.45,r*0.28,2.2,2); }
1292
+ function inkChecker(k, y, col1="#e8e0ee", col2="#1a1224"){ const c=k.ctx; c.save(); c.beginPath(); c.rect(0,y,PW,PH-y); c.clip(); const rows=6; for (let r=0;r<rows;r++){ const t0=r/rows, t1=(r+1)/rows, y0=y+(PH-y)*t0*t0, y1=y+(PH-y)*t1*t1, cols=10;
1293
+ for (let q=-cols;q<cols*2;q++){ const w0=PW/cols*(0.6+t0), w1=PW/cols*(0.6+t1); c.fillStyle=((q+r)%2+2)%2 ? col1 : col2; c.globalAlpha=.55; c.beginPath(); c.moveTo(PW/2+(q-cols/2)*w0,y0); c.lineTo(PW/2+(q+1-cols/2)*w0,y0); c.lineTo(PW/2+(q+1-cols/2)*w1,y1); c.lineTo(PW/2+(q-cols/2)*w1,y1); c.fill(); } } c.restore(); }
1294
+ function inkOddity(k){ const r = k.rnd(); if (r < 0.34){ for (let i=0;i<3;i++){ const x=30+k.rnd()*260, y=20+k.rnd()*50; k.ctx.save(); k.ctx.translate(x,y); k.ctx.rotate((k.rnd()-.5)*1.2); k.shape(c=>{ c.rect(-5,-7,10,14); }, "#f0e8dc", {lw:1}); k.ctx.fillStyle = k.rnd()<.5 ? INK.red : INK.ink; k.ctx.beginPath(); k.ctx.arc(0,0,2,0,Math.PI*2); k.ctx.fill(); k.ctx.restore(); } } // drifting playing cards
1295
+ else if (r < 0.55){ const x=40+k.rnd()*240, y=30+k.rnd()*30; k.shape(c=>{ c.arc(x,y,10,0,Math.PI*2); }, "#f0e8dc", {lw:1.5}); k.line([[x,y],[x-4,y-6]],1.3); k.line([[x,y],[x+6,y+1]],1.3); } } // a stray clock in the sky
1296
+ function inkGround(k, y, col){ k.shape(c=>{ c.moveTo(-5,PH+5); c.lineTo(-5,y+4); c.quadraticCurveTo(PW*0.5,y-8,PW+5,y+6); c.lineTo(PW+5,PH+5); c.closePath(); }, col); }
1297
+ function inkWindow(k, x, y, w, h, lit, opts={}){ k.shape(c=>{ c.moveTo(x, y+h); c.lineTo(x+w, y+h-2); c.lineTo(x+w-1, y+2); c.quadraticCurveTo(x+w/2, y-5, x+1, y+1); c.closePath(); }, lit ? INK.win : INK.winDim, {lw:1.5}); if (lit) k.glow(x+w/2,y+h/2,w*1.6,"rgba(242,196,107,1)",.32); return [x+w/2, y+h/2]; }
1298
+ function inkHouse(k, x, base, w, h, lean, wall, nWin=2){
1299
+ const wins = [], floors = 2 + (k.rnd() < 0.5 ? 1 : 0), fh = h / floors; let cx = x, cw = w, y = base, drift = 0;
1300
+ const walls = [wall, "#2f4a52", "#4a3350", "#5a4a30"];
1301
+ for (let f=0; f<floors; f++){
1302
+ const top = y - fh, bulge = (k.rnd()-.3)*6, wcol = walls[(f + Math.floor(k.rnd()*4)) % walls.length], sk = lean*fh;
1303
+ k.shape(c=>{ c.moveTo(cx, y); c.lineTo(cx+cw, y); c.quadraticCurveTo(cx+cw+bulge+sk*0.5, y-fh*0.5, cx+cw+sk, top); c.lineTo(cx+sk, top); c.quadraticCurveTo(cx-bulge+sk*0.5, y-fh*0.5, cx, y); c.closePath(); }, wcol);
1304
+ if (k.rnd() < 0.35){ const cc=k.ctx; cc.save(); cc.beginPath(); cc.moveTo(cx,y); cc.lineTo(cx+cw,y); cc.lineTo(cx+cw+sk,top); cc.lineTo(cx+sk,top); cc.closePath(); cc.clip(); cc.globalAlpha=.18; cc.fillStyle=INK.stripe; for (let sx=cx-10; sx<cx+cw+10; sx+=8) cc.fillRect(sx+sk*0.5, top, 3, fh); cc.restore(); } // stripes
1305
+ else k.hatch(cx+cw*0.7+sk*0.5, top+3, cw*0.28, fh-4, 3);
1306
+ const nw = f === 0 ? 1 : 1 + (k.rnd() < 0.4 ? 1 : 0);
1307
+ for (let q=0;q<nw;q++){ const wx = cx + cw*(nw===1?0.36:0.14+q*0.44) + sk*0.5, wy = top + fh*0.3;
1308
+ if (k.rnd() < 0.4){ const r = Math.min(7, cw*0.14), lit = k.rnd() < .65; k.shape(c=>{ c.arc(wx+r, wy+r, r, 0, Math.PI*2); }, lit ? INK.win : INK.winDim, {lw:1.5}); k.line([[wx,wy+r],[wx+2*r,wy+r]],1); if (lit) k.glow(wx+r,wy+r,r*3,"rgba(242,196,107,1)",.3); wins.push([wx+r, wy+r]); }
1309
+ else wins.push(inkWindow(k, wx, wy, Math.min(15, cw*0.28), 12, k.rnd() < .62)); }
1310
+ if (f === 0){ const dx = cx + cw*0.62, dw = Math.min(12, cw*0.22); k.shape(c=>{ c.moveTo(dx, y); c.lineTo(dx, y-14); c.quadraticCurveTo(dx+dw/2, y-22, dx+dw, y-14); c.lineTo(dx+dw, y); c.closePath(); }, INK.wood, {lw:1.4}); }
1311
+ y = top; cx += sk + (k.rnd()-.5)*8; drift += sk; cw = cw * (0.78 + k.rnd()*0.2);
1312
+ }
1313
+ const roofKind = Math.floor(k.rnd()*3), rx = cx-5, rw = cw+10, roofCol = [INK.roof, INK.tealRoof, INK.plumRoof][Math.floor(k.rnd()*3)];
1314
+ if (roofKind === 0){ const peak = y - h*0.5, tx = rx + rw*0.5 + lean*28; k.shape(c=>{ c.moveTo(rx,y); c.quadraticCurveTo(rx+rw*0.35,y-h*0.08,tx,peak); c.quadraticCurveTo(rx+rw*0.7,y-h*0.1,rx+rw,y); c.closePath(); }, roofCol); k.curl(tx+4, peak-2, 6); }
1315
+ else if (roofKind === 1){ const cxm = rx+rw/2; k.shape(c=>{ c.moveTo(rx,y); c.quadraticCurveTo(rx-2,y-rw*0.6,cxm,y-rw*0.95); c.quadraticCurveTo(rx+rw+2,y-rw*0.6,rx+rw,y); c.closePath(); }, roofCol); k.line([[cxm,y-rw*0.95],[cxm+lean*10,y-rw*1.25]],1.6); k.curl(cxm+lean*10+3,y-rw*1.25-3,4,1.6,1.3); }
1316
+ else { k.shape(c=>{ c.moveTo(rx,y); c.lineTo(rx+rw*0.2,y-h*0.22); c.lineTo(rx+rw*0.8,y-h*0.25); c.lineTo(rx+rw,y); c.closePath(); }, roofCol);
1317
+ const chx = rx+rw*0.7, chy = y-h*0.22; k.shape(c=>{ c.moveTo(chx,chy); c.lineTo(chx+2,chy-18); c.lineTo(chx+9,chy-19); c.lineTo(chx+8,chy); c.closePath(); }, "#3a2a2a", {lw:1.3});
1318
+ const sc=k.ctx; sc.save(); sc.globalAlpha=.55; for (let t=0;t<3;t++) k.curl(chx+6+t*6, chy-26-t*8, 4+t, 1.4, 1.2, INK.paper); sc.restore(); }
1319
+ return wins;
1320
+ }
1321
+ function inkLamp(k, x, base, h){ k.shape(c=>{ c.moveTo(x-1.5,base); c.lineTo(x+1.5,base); c.quadraticCurveTo(x+4,base-h*0.6,x+10,base-h); c.quadraticCurveTo(x+14,base-h-8,x+18,base-h-2); c.lineTo(x+16,base-h); c.quadraticCurveTo(x+12,base-h-4,x+8,base-h+2); c.quadraticCurveTo(x+1,base-h*0.6,x-1.5,base); c.closePath(); }, INK.ink, {lw:1});
1322
+ k.shape(c=>{ c.ellipse(x+17,base-h+4,4,5,0,0,Math.PI*2); }, INK.win, {lw:1.2}); k.glow(x+17,base-h+6,40,"rgba(242,196,107,1)",.42);
1323
+ const c=k.ctx; c.save(); c.globalAlpha=.12; c.fillStyle=INK.win; c.beginPath(); c.moveTo(x+13,base-h+8); c.lineTo(x+21,base-h+8); c.lineTo(x+48,base); c.lineTo(x-14,base); c.fill(); c.restore(); }
1324
+ function inkEye(k, cx, cy, s, col=INK.gold){ const c=k.ctx; k.glow(cx,cy,s*4,"rgba(242,196,107,1)",.4); c.fillStyle=col; c.beginPath(); c.moveTo(cx-s*1.6,cy); c.quadraticCurveTo(cx,cy-s*1.2,cx+s*1.6,cy); c.quadraticCurveTo(cx,cy+s*1.2,cx-s*1.6,cy); c.fill(); c.lineWidth=1.2; c.strokeStyle=INK.ink; c.stroke(); c.fillStyle=INK.ink; c.beginPath(); c.arc(cx,cy,s*0.5,0,Math.PI*2); c.fill(); if (s>=5){ for (let i=-2;i<=2;i++) k.line([[cx+i*s*0.5,cy-s*1.05],[cx+i*s*0.6,cy-s*1.45]],1.2,col); } }
1325
+ /* exaggerated silhouettes: long legs, tiny waist, big head */
1326
+ function inkFigure(k, x, base, h, o={}){
1327
+ const col = o.col || INK.fig, head = h*0.34, lean = o.lean||0, c = k.ctx;
1328
+ if (o.alpha != null){ c.save(); c.globalAlpha = o.alpha; }
1329
+ const hipY = base-h*0.42, shY = base-h*0.74, neckY = shY-h*0.03;
1330
+ k.shape(cc=>{ cc.moveTo(x-2.2,base); cc.lineTo(x-1+lean*0.3,hipY); cc.lineTo(x+1+lean*0.3,hipY); cc.lineTo(x+3.2,base-1); cc.lineTo(x+1.6,base); cc.lineTo(x+0.6+lean*0.3,hipY+4); cc.lineTo(x-0.6,base); cc.closePath();
1331
+ cc.moveTo(x-1.5+lean*0.4,hipY); cc.quadraticCurveTo(x-h*0.14+lean*0.6,(hipY+shY)/2,x-h*0.12+lean*0.8,shY); cc.lineTo(x+h*0.12+lean*0.8,shY); cc.quadraticCurveTo(x+h*0.14+lean*0.6,(hipY+shY)/2,x+1.5+lean*0.4,hipY); cc.closePath();
1332
+ cc.moveTo(x+lean+head*0.44,neckY-head*0.55); cc.ellipse(x+lean,neckY-head*0.55,head*0.42,head*0.55,0,0,Math.PI*2); }, col, {lw:1.3});
1333
+ const arm = o.bow ? [[x-h*0.1+lean*0.8,shY+2],[x-h*0.16,shY+h*0.2],[x-h*0.05,shY+h*0.32]] : [[x-h*0.1+lean*0.8,shY+2],[x-h*0.2,base-h*0.5],[x-h*0.14,base-h*0.32]];
1334
+ c.save(); c.strokeStyle=col; c.lineWidth=2; c.lineCap="round"; c.beginPath(); c.moveTo(...arm[0]); c.quadraticCurveTo(...arm[1],...arm[2]); c.moveTo(x+h*0.1+lean*0.8,shY+2); c.quadraticCurveTo(x+h*0.2,base-h*0.5,x+h*0.16,base-h*0.32); c.stroke(); c.restore();
1335
+ c.save(); c.strokeStyle=col; c.lineWidth=1.6; c.lineCap="round"; c.beginPath(); c.moveTo(x-1.5,base); c.quadraticCurveTo(x-7,base+1,x-8,base-3); c.moveTo(x+2,base); c.quadraticCurveTo(x+8,base+1,x+9,base-3); c.stroke(); c.restore(); // curled, pointed shoes
1336
+ if (o.stripes){ c.save(); c.strokeStyle=o.stripes; c.lineWidth=1; for (let yy=hipY+4; yy<base-2; yy+=3.2){ c.beginPath(); c.moveTo(x-2.4,yy); c.lineTo(x-0.2,yy); c.moveTo(x+0.8,yy); c.lineTo(x+3,yy); c.stroke(); } c.restore(); }
1337
+ if (o.stare){ const ey = neckY-head*0.62; c.fillStyle="#f3eee4"; c.beginPath(); c.ellipse(x+lean-head*0.17,ey,head*0.14,head*0.18,0,0,Math.PI*2); c.ellipse(x+lean+head*0.17,ey,head*0.14,head*0.18,0,0,Math.PI*2); c.fill(); c.fillStyle=INK.ink; c.fillRect(x+lean-head*0.17-0.8,ey-0.5,1.6,1.6); c.fillRect(x+lean+head*0.17-0.8,ey-0.5,1.6,1.6); }
1338
+ if (o.curl){ k.curl(x+lean+2, neckY-head*1.12, 4, 1.8, 1.6, col); }
1339
+ if (o.hat){ const hx=x+lean, hy=neckY-head*1.05; k.shape(cc=>{ cc.moveTo(hx-head*0.75,hy); cc.lineTo(hx+head*0.75,hy); cc.lineTo(hx+head*0.45,hy-2); cc.lineTo(hx+head*0.5,hy-head*1.2); cc.quadraticCurveTo(hx,hy-head*1.35,hx-head*0.45,hy-head*1.15); cc.lineTo(hx-head*0.4,hy-2); cc.closePath(); }, col, {lw:1.3}); if (o.band) { c.fillStyle=o.band; c.fillRect(hx-head*0.44,hy-head*0.35,head*0.9,2); } }
1340
+ if (o.eyes){ const ey = neckY-head*0.6; c.fillStyle=o.eyes; c.fillRect(x+lean-3,ey,2,2); c.fillRect(x+lean+2,ey,2,2); k.glow(x+lean,ey,8,o.eyes,.55); }
1341
+ if (o.rim){ c.save(); c.strokeStyle=o.rim; c.globalAlpha=.75; c.lineWidth=1.1; c.beginPath(); c.ellipse(x+lean,neckY-head*0.55,head*0.42+1,head*0.55+1,0,-1.3,0.5); c.stroke(); c.restore(); }
1342
+ if (o.alpha != null) c.restore(); }
1343
+ function inkShadow(k, x, base, len, col="#0a0612"){ const c=k.ctx; c.save(); c.fillStyle=col; c.globalAlpha=.55; c.beginPath(); c.moveTo(x-3,base); c.quadraticCurveTo(x+len*0.5,base+6,x+len,base+14); c.lineTo(x+len+6,base+12); c.quadraticCurveTo(x+len*0.5,base+2,x+4,base); c.fill(); c.restore(); }
1344
+ function inkRain(k){ const c=k.ctx; c.save(); c.strokeStyle="rgba(220,210,240,.35)"; c.lineWidth=1; for (let i=0;i<90;i++){ const x=k.rnd()*PW, y=k.rnd()*PH; c.beginPath(); c.moveTo(x,y); c.lineTo(x-3,y+9); c.stroke(); } c.restore(); }
1345
+ function inkSnow(k){ const c=k.ctx; c.fillStyle="rgba(240,236,250,.85)"; for (let i=0;i<70;i++){ c.beginPath(); c.arc(k.rnd()*PW,k.rnd()*PH,1+k.rnd()*1.4,0,Math.PI*2); c.fill(); } }
1346
+ function inkFog(k){ const c=k.ctx, g=c.createLinearGradient(0,PH*0.45,0,PH); g.addColorStop(0,"rgba(190,180,210,0)"); g.addColorStop(1,"rgba(190,180,210,.45)"); c.fillStyle=g; c.fillRect(0,0,PW,PH); }
1347
+
1348
+ /* ---- twelve settings; each returns anchors for his hint and the figures ---- */
1349
+ const INK_SETTINGS = {
1350
+ street(k, sk){ inkSky(k, sk, 180); inkSwirls(k); if (sk.light < .3) inkMoon(k, 250, 44, 20); inkOddity(k); inkSpiralHill(k, 160, 160, 34, sk.hill); inkHills(k, 156, sk.hill, 14);
1351
+ const a = inkHouse(k, 18, 190, 52, 92, -0.12, sk.wall, 2); inkHouse(k, 86, 184, 44, 72, 0.1, sk.wall2, 2); const b = inkHouse(k, 212, 192, 58, 100, 0.08, sk.wall, 3);
1352
+ inkGround(k, 186, sk.ground); inkLamp(k, 150, 206, 70); return {floor:214, windows:[...a,...b], sky:true, billboard:null}; },
1353
+ alley(k, sk){ inkSky(k, sk, 120); if (sk.light < .3) inkMoon(k, 160, 30, 12);
1354
+ k.shape(c=>{ c.moveTo(0,0); c.lineTo(118,0); c.quadraticCurveTo(128,120,112,PH); c.lineTo(0,PH); c.closePath(); }, sk.wall); k.hatch(80,0,40,PH,5);
1355
+ k.shape(c=>{ c.moveTo(PW,0); c.lineTo(202,0); c.quadraticCurveTo(190,120,208,PH); c.lineTo(PW,PH); c.closePath(); }, sk.wall2);
1356
+ const w = [inkWindow(k, 30, 40, 22, 16, true), inkWindow(k, 60, 100, 20, 15, false), inkWindow(k, 240, 60, 22, 16, true), inkWindow(k, 262, 130, 20, 15, true)];
1357
+ k.line([[118,70],[150,82],[202,68]],1.2); for (let i=0;i<4;i++){ const x=128+i*18; k.shape(c=>{ c.moveTo(x,76+i*2); c.lineTo(x+10,77+i*2); c.lineTo(x+9,90+i*2); c.lineTo(x+1,89+i*2); c.closePath(); }, [INK.paper,INK.red,INK.teal,INK.paper][i], {lw:1}); }
1358
+ inkGround(k, 200, sk.ground); k.shape(c=>{ c.ellipse(170,222,26,5,0,0,Math.PI*2); }, "rgba(150,130,190,.35)", {lw:1});
1359
+ k.shape(c=>{ c.moveTo(144,200); c.lineTo(144,150); c.quadraticCurveTo(160,138,176,150); c.lineTo(176,200); c.closePath(); }, INK.win, {lw:1.8}); k.glow(160,176,40,"rgba(242,196,107,1)",.4);
1360
+ return {floor:212, windows:w, door:[160,176]}; },
1361
+ room(k, sk){ k.ctx.fillStyle="#2e2340"; k.ctx.fillRect(0,0,PW,PH);
1362
+ k.shape(c=>{ c.moveTo(0,0); c.lineTo(PW,0); c.lineTo(PW,170); c.quadraticCurveTo(PW*0.5,160,0,172); c.closePath(); }, sk.wall);
1363
+ k.shape(c=>{ c.moveTo(200,40); c.quadraticCurveTo(232,8,262,38); c.lineTo(266,132); c.lineTo(198,136); c.closePath(); }, sk.top);
1364
+ const cx=k.ctx; cx.save(); cx.beginPath(); cx.moveTo(200,40); cx.quadraticCurveTo(232,8,262,38); cx.lineTo(266,132); cx.lineTo(198,136); cx.closePath(); cx.clip(); inkSky(k, sk, 140); if (sk.light<.3) inkMoon(k,244,50,11); cx.restore();
1365
+ k.line([[232,20],[232,134]],3); k.line([[199,86],[265,84]],3);
1366
+ inkGround(k, 168, sk.ground); inkChecker(k, 170);
1367
+ k.shape(c=>{ c.moveTo(40,150); c.quadraticCurveTo(95,142,150,148); c.lineTo(148,156); c.quadraticCurveTo(95,150,42,158); c.closePath(); }, INK.wood);
1368
+ k.line([[50,157],[46,180],[54,200]],2.5); k.line([[140,154],[146,178],[138,198]],2.5);
1369
+ k.shape(c=>{ c.moveTo(78,146); c.lineTo(82,112); c.lineTo(120,110); c.lineTo(118,144); c.closePath(); }, "#1b2a3a"); k.glow(100,128,30,"rgba(120,190,200,1)",.3);
1370
+ k.shape(c=>{ c.rect(58,132,6,16); }, INK.paper, {lw:1.2}); k.glow(61,128,26,"rgba(242,196,107,1)",.5); k.shape(c=>{ c.ellipse(61,128,2,4,0,0,Math.PI*2); }, INK.win, {noLine:true});
1371
+ k.shape(c=>{ c.moveTo(126,146); c.lineTo(146,144); c.lineTo(148,148); c.lineTo(128,150); c.closePath(); }, INK.paper, {lw:1});
1372
+ return {floor:206, screen:[100,128], window:[232,70]}; },
1373
+ station(k, sk){ k.ctx.fillStyle="#241c32"; k.ctx.fillRect(0,0,PW,PH);
1374
+ for (let i=0;i<5;i++){ const x=i*80-10; k.shape(c=>{ c.moveTo(x,120); c.quadraticCurveTo(x+40,-10,x+80,120); c.lineTo(x+74,120); c.quadraticCurveTo(x+40,6,x+6,120); c.closePath(); }, "#3a2d4c", {lw:1.5}); }
1375
+ k.shape(c=>{ c.rect(0,120,PW,12); }, INK.gold, {lw:1.5}); inkGround(k, 150, sk.ground);
1376
+ k.shape(c=>{ c.moveTo(0,190); c.lineTo(PW,190); c.lineTo(PW,PH); c.lineTo(0,PH); c.closePath(); }, "#120c18");
1377
+ k.shape(c=>{ c.arc(160,70,18,0,Math.PI*2); }, INK.paper, {lw:2}); for (let i=0;i<13;i++){ const a=i/13*Math.PI*2; k.line([[160+Math.cos(a)*14,70+Math.sin(a)*14],[160+Math.cos(a)*16,70+Math.sin(a)*16]],1.2); } // thirteen marks, no hands
1378
+ k.line([[160,52],[160,30]],1.5);
1379
+ k.shape(c=>{ c.moveTo(60,176); c.quadraticCurveTo(90,170,120,176); c.lineTo(118,180); c.quadraticCurveTo(90,174,62,180); c.closePath(); }, INK.wood); k.line([[66,180],[66,188]],2); k.line([[114,180],[114,188]],2);
1380
+ k.glow(300,160,40,"rgba(242,196,107,1)",.5);
1381
+ return {floor:186, clock:[160,70], billboard:[222,62,70,44]}; },
1382
+ train(k, sk){ k.ctx.fillStyle="#2c2638"; k.ctx.fillRect(0,0,PW,PH);
1383
+ const w=[]; for (let i=0;i<4;i++){ const x=14+i*76; k.shape(c=>{ c.moveTo(x,50); c.quadraticCurveTo(x+30,26,x+60,50); c.lineTo(x+60,110); c.lineTo(x,110); c.closePath(); }, sk.top, {lw:2.5});
1384
+ const cx=k.ctx; cx.save(); cx.beginPath(); cx.moveTo(x,50); cx.quadraticCurveTo(x+30,26,x+60,50); cx.lineTo(x+60,110); cx.lineTo(x,110); cx.closePath(); cx.clip(); inkSky(k, sk, 110); cx.fillStyle=sk.hill; for (let j=0;j<5;j++) cx.fillRect(x+j*14,86-k.rnd()*24,10,40); cx.restore(); w.push([x+30,70]); }
1385
+ for (let i=0;i<8;i++){ const x=20+i*38; k.line([[x,0],[x,18]],1.2); k.shape(c=>{ c.ellipse(x,24,5,6,0,0,Math.PI*2); }, null, {lw:1.5}); }
1386
+ k.shape(c=>{ c.rect(0,130,PW,34); }, "#4a3d5e"); k.hatch(0,130,PW,34,20); inkGround(k, 172, "#1b1624");
1387
+ return {floor:214, windows:w}; },
1388
+ cafe(k, sk){ k.ctx.fillStyle="#3a2a28"; k.ctx.fillRect(0,0,PW,PH);
1389
+ k.shape(c=>{ c.moveTo(12,20); c.lineTo(120,16); c.lineTo(122,112); c.lineTo(10,116); c.closePath(); }, sk.top, {lw:2.5});
1390
+ const cx=k.ctx; cx.save(); cx.beginPath(); cx.rect(12,18,108,96); cx.clip(); inkSky(k, sk, 120); inkHouse(k, 30, 118, 34, 50, -0.1, sk.wall, 1); inkHouse(k, 76, 118, 30, 44, 0.12, sk.wall2, 1); cx.restore();
1391
+ for (let i=0;i<3;i++){ const x=170+i*48; k.line([[x,0],[x,34]],1.2); k.shape(c=>{ c.moveTo(x-12,46); c.quadraticCurveTo(x,28,x+12,46); c.closePath(); }, INK.roof, {lw:1.5}); k.glow(x,50,34,"rgba(242,196,107,1)",.45); }
1392
+ k.shape(c=>{ c.moveTo(150,120); c.lineTo(PW,116); c.lineTo(PW,170); c.lineTo(150,172); c.closePath(); }, INK.wood2); k.hatch(150,120,170,50,10);
1393
+ inkGround(k, 176, "#2a1e1c"); inkChecker(k, 178, "#e0c8a0", "#2a1e1c");
1394
+ k.shape(c=>{ c.ellipse(80,168,34,7,0,0,Math.PI*2); }, INK.wood); k.line([[80,175],[80,206]],2.5);
1395
+ k.shape(c=>{ c.moveTo(72,164); c.lineTo(88,164); c.lineTo(86,154); c.lineTo(74,154); c.closePath(); }, INK.paper, {lw:1.2}); k.curl(80,146,4,1.6,1.2,"rgba(239,226,192,.8)");
1396
+ return {floor:212, window:[66,66]}; },
1397
+ office(k, sk){ k.ctx.fillStyle="#2a2a36"; k.ctx.fillRect(0,0,PW,PH);
1398
+ k.shape(c=>{ c.rect(200,14,100,80); }, sk.top, {lw:2}); for (let y=18;y<94;y+=6) k.line([[200,y],[300,y+1]],1,"rgba(20,13,28,.55)");
1399
+ inkGround(k, 150, "#23232e");
1400
+ const s=[]; for (let i=0;i<3;i++){ const x=20+i*100; k.shape(c=>{ c.moveTo(x,140); c.lineTo(x+80,136); c.lineTo(x+82,146); c.lineTo(x+2,150); c.closePath(); }, "#4a4a5e");
1401
+ k.shape(c=>{ c.moveTo(x+22,134); c.lineTo(x+20,104); c.lineTo(x+58,100+i*2); c.lineTo(x+60,132); c.closePath(); }, "#1b2a3a"); k.glow(x+40,118,26,"rgba(120,190,200,1)",.28); s.push([x+40,118]);
1402
+ for (let j=0;j<4;j++) k.shape(c=>{ c.rect(x+64+j*0.8,128-j*7+(i%2)*3,14,6); }, INK.paper, {lw:1}); }
1403
+ return {floor:212, screen:s[1]}; },
1404
+ rooftop(k, sk){ inkSky(k, sk, PH); inkSwirls(k,5); if (sk.light<.3) inkMoon(k, 70, 50, 22); inkOddity(k);
1405
+ for (let i=0;i<9;i++){ const x=i*38-8, h=40+k.rnd()*60; k.shape(c=>{ c.moveTo(x,190); c.lineTo(x+(k.rnd()-.5)*6,190-h); c.lineTo(x+30,188-h); c.lineTo(x+32,190); c.closePath(); }, sk.hill, {lw:1.2}); for (let j=0;j<3;j++) if (k.rnd()<.5) inkWindow(k, x+8, 196-h+j*14, 7, 6, true); }
1406
+ k.shape(c=>{ c.moveTo(0,PH); c.lineTo(0,176); c.lineTo(PW,172); c.lineTo(PW,PH); c.closePath(); }, "#2a2336");
1407
+ k.shape(c=>{ c.moveTo(236,176); c.lineTo(240,120); c.lineTo(280,118); c.lineTo(284,176); c.closePath(); }, INK.wood); k.shape(c=>{ c.moveTo(232,120); c.quadraticCurveTo(260,96,288,118); c.closePath(); }, INK.roof);
1408
+ for (let x=10;x<PW;x+=22) k.line([[x,176],[x+1,160]],1.5); k.line([[0,160],[PW,158]],1.5);
1409
+ return {floor:214, sky:true}; },
1410
+ park(k, sk){ inkSky(k, sk, 170); if (sk.light<.3) inkMoon(k, 240, 40, 16); inkOddity(k); inkSpiralHill(k, 120, 150, 40, sk.hill); inkHills(k, 146, sk.hill, 10);
1411
+ for (let i=0;i<4;i++){ const x=30+i*80+(k.rnd()-.5)*10; k.shape(c=>{ c.moveTo(x-3,180); c.quadraticCurveTo(x-6,130,x-2,100); c.lineTo(x+2,100); c.quadraticCurveTo(x+6,130,x+3,180); c.closePath(); }, INK.ink, {lw:1});
1412
+ for (let b=0;b<3;b++){ const ang = -Math.PI/2 + (b-1)*0.8, ex = x+Math.cos(ang)*26, ey = 100+Math.sin(ang)*26; k.line([[x,104],[ex,ey]],2); k.curl(ex,ey,5,1.8,1.4); } }
1413
+ inkGround(k, 176, INK.grass);
1414
+ k.shape(c=>{ c.ellipse(90,206,50,9,0,0,Math.PI*2); }, sk.top, {lw:1.5}); if (sk.light<.3) inkMoon(k, 96, 206, 5);
1415
+ k.shape(c=>{ c.moveTo(186,186); c.quadraticCurveTo(216,180,246,186); c.lineTo(244,190); c.quadraticCurveTo(216,184,188,190); c.closePath(); }, INK.wood); k.line([[192,190],[192,200]],2); k.line([[240,190],[240,200]],2);
1416
+ inkLamp(k, 272, 206, 64);
1417
+ return {floor:214, sky:true, pond:[90,206]}; },
1418
+ shop(k, sk){ k.ctx.fillStyle="#d8d6e4"; k.ctx.fillRect(0,0,PW,20); k.ctx.fillStyle="#46405a"; k.ctx.fillRect(0,20,PW,150);
1419
+ for (let y=44;y<160;y+=28){ k.shape(c=>{ c.moveTo(10,y); c.lineTo(190,y-2); c.lineTo(190,y+4); c.lineTo(10,y+6); c.closePath(); }, "#6a6480", {lw:1.2});
1420
+ for (let x=14;x<184;x+=10){ const hh=8+k.rnd()*10; k.shape(c=>{ c.rect(x,y-hh,8,hh); }, [INK.red,INK.teal,INK.gold,INK.paper,INK.violet][Math.floor(k.rnd()*5)], {lw:1}); } }
1421
+ k.shape(c=>{ c.moveTo(214,60); c.lineTo(310,58); c.lineTo(312,150); c.lineTo(212,152); c.closePath(); }, "#bfe0e4", {lw:2}); k.hatch(214,60,98,92,8,"rgba(255,255,255,.4)");
1422
+ inkGround(k, 176, "#bdb8c8"); k.glow(160,10,120,"rgba(255,255,255,1)",.25);
1423
+ k.shape(c=>{ c.moveTo(220,176); c.lineTo(300,174); c.lineTo(300,200); c.lineTo(222,202); c.closePath(); }, "#3a3450"); k.shape(c=>{ c.rect(250,160,24,16); }, "#1b2a3a", {lw:1.5});
1424
+ return {floor:214, screen:[262,168]}; },
1425
+ stage(k, sk){ k.ctx.fillStyle="#120b1a"; k.ctx.fillRect(0,0,PW,PH);
1426
+ const drape=(x0,dir)=>k.shape(c=>{ c.moveTo(x0,0); for (let y=0;y<=190;y+=19) c.quadraticCurveTo(x0+dir*(36+(y%38?8:-4)),y+9,x0+dir*34,y+19); c.quadraticCurveTo(x0+dir*20,200,x0+dir*4,196); c.lineTo(x0,196); c.closePath(); }, INK.curtain);
1427
+ drape(0,1); drape(PW,-1);
1428
+ k.shape(c=>{ c.moveTo(0,0); c.lineTo(PW,0); c.lineTo(PW,22); for (let x=PW;x>=0;x-=32) c.quadraticCurveTo(x-16,38,x-32,22); c.closePath(); }, INK.curtain2);
1429
+ k.shape(c=>{ c.moveTo(0,PH); c.lineTo(0,196); c.quadraticCurveTo(PW*0.5,186,PW,196); c.lineTo(PW,PH); c.closePath(); }, INK.wood); k.hatch(0,196,PW,44,24,"rgba(20,13,28,.25)");
1430
+ const cx=k.ctx; cx.save(); cx.globalAlpha=.2; const g=cx.createLinearGradient(160,20,160,200); g.addColorStop(0,"#fff6dc"); g.addColorStop(1,"rgba(255,246,220,.2)"); cx.fillStyle=g; cx.beginPath(); cx.moveTo(146,20); cx.lineTo(174,20); cx.lineTo(206,198); cx.lineTo(114,198); cx.fill(); cx.restore(); k.glow(160,196,60,"rgba(255,246,220,1)",.25);
1431
+ return {floor:204, stage:true}; },
1432
+ mirror(k, sk){ k.ctx.fillStyle="#2e2340"; k.ctx.fillRect(0,0,PW,PH); k.hatch(0,0,PW,PH,30,"rgba(20,13,28,.18)");
1433
+ k.shape(c=>{ c.moveTo(106,28); c.quadraticCurveTo(160,-6,214,28); c.lineTo(222,206); c.lineTo(98,206); c.closePath(); }, INK.gold, {lw:2.5});
1434
+ k.shape(c=>{ c.moveTo(116,36); c.quadraticCurveTo(160,8,204,36); c.lineTo(210,198); c.lineTo(110,198); c.closePath(); }, "#2a1a4a", {lw:1.5});
1435
+ const g=k.ctx.createLinearGradient(0,20,0,200); g.addColorStop(0,"rgba(154,99,224,.35)"); g.addColorStop(1,"rgba(20,10,40,.2)"); k.ctx.fillStyle=g; k.ctx.fill();
1436
+ k.curl(102,34,8); k.curl(218,34,8); k.curl(94,200,6); k.curl(226,200,6); inkGround(k, 206, "#221a2c"); inkChecker(k, 208);
1437
+ return {floor:214, mirror:[160,196]}; },
1438
+ };
1439
+ INK_SETTINGS.home = INK_SETTINGS.room; INK_SETTINGS.bar = INK_SETTINGS.cafe; INK_SETTINGS.theater = INK_SETTINGS.stage; INK_SETTINGS.subway = INK_SETTINGS.station;
1440
+
1441
+ /* his hint in every picture, bolder as his Manifestation grows */
1442
+ function inkHint(k, a, m, kind){
1443
+ const opts = [];
1444
+ if (a.windows && a.windows.length) opts.push("window");
1445
+ if (a.window) opts.push("window1");
1446
+ if (a.screen) opts.push("screen");
1447
+ if (a.mirror) opts.push("mirrorfig");
1448
+ if (a.clock) opts.push("clock");
1449
+ if (a.billboard) opts.push("billboard");
1450
+ if (a.sky && m >= 3) opts.push("moons");
1451
+ if (a.pond) opts.push("pond");
1452
+ opts.push("hatshadow","faceless");
1453
+ const kk = kind || opts[Math.floor(k.rnd()*opts.length)], big = m >= 6;
1454
+ if (kk==="window"){ const w = a.windows[Math.floor(k.rnd()*a.windows.length)]; inkEye(k, w[0], w[1], big?5:3); }
1455
+ if (kk==="window1") inkEye(k, a.window[0], a.window[1], big?6:4);
1456
+ if (kk==="screen") inkEye(k, a.screen[0], a.screen[1], big?7:5);
1457
+ if (kk==="clock") inkEye(k, a.clock[0], a.clock[1], big?7:5);
1458
+ if (kk==="mirrorfig") inkFigure(k, a.mirror[0], a.mirror[1], 120, {hat:true, col:"rgba(10,6,18,.85)", eyes: m>=3 ? INK.gold : null, band: INK.gold});
1459
+ if (kk==="billboard"){ const [x,y,w,h]=a.billboard; k.shape(c=>{ c.rect(x,y,w,h); }, "#2a1d40", {lw:2}); inkFigure(k, x+w/2, y+h-2, h-6, {col:m>=3?"#5a3d80":"#3a2d50"}); if (m>=3) inkEye(k, x+w/2, y+h*0.28, 3); }
1460
+ if (kk==="moons") inkMoons(k, 2 + Math.min(4, Math.floor(m/2)));
1461
+ if (kk==="pond"){ const c=k.ctx; c.save(); c.globalAlpha=.8; inkFigure(k, a.pond[0]+20, a.pond[1]-2, 26, {hat:true, col:"rgba(10,6,18,.5)"}); c.restore(); }
1462
+ if (kk==="hatshadow"){ const x = 30+k.rnd()*240; inkFigure(k, x, a.floor-6, 54, {hat:true, col:"rgba(14,9,22,.55)", eyes: big?INK.gold:null}); }
1463
+ if (kk==="faceless"){ const x = 30+k.rnd()*240; inkFigure(k, x, a.floor-4, 50, {col:INK.fig}); const c=k.ctx; c.fillStyle="#d8c8b0"; c.beginPath(); c.ellipse(x, a.floor-4-50*0.77-50*0.26*0.55, 50*0.26*0.36, 50*0.26*0.46, 0, 0, Math.PI*2); c.fill(); }
1464
+ }
1465
+ function inkFinish(k, m){
1466
+ const c = k.ctx, W = c.canvas.width, H = c.canvas.height, LW = W/2, LH = H/2;
1467
+ if (m >= 5){ c.save(); c.globalAlpha = Math.min(.22, (m-4)*0.04); c.fillStyle = INK.violet; c.globalCompositeOperation = "soft-light"; c.fillRect(0,0,LW,LH); c.restore(); }
1468
+ const img = c.getImageData(0,0,W,H), d = img.data;
1469
+ for (let i=0;i<d.length;i+=4){ const n=(k.rnd()-.5)*16; d[i]+=n+6; d[i+1]+=n+3; d[i+2]+=n-4; }
1470
+ c.putImageData(img,0,0);
1471
+ c.save(); c.setTransform(1,0,0,1,0,0); c.globalAlpha=.07; c.fillStyle=INK.paper; c.fillRect(0,0,W,H); c.restore();
1472
+ const v = c.createRadialGradient(LW/2,LH/2,Math.min(LW,LH)*0.4,LW/2,LH/2,Math.max(LW,LH)*0.66); v.addColorStop(0,"rgba(0,0,0,0)"); v.addColorStop(1,"rgba(0,0,0,.5)"); c.fillStyle=v; c.fillRect(0,0,LW,LH);
1473
+ }
1474
+ function drawPicture(canvas, p, st){
1475
+ p = p || {}; st = st || {};
1476
+ canvas.width = PW*2; canvas.height = PH*2; const ctx = canvas.getContext("2d"); ctx.setTransform(2,0,0,2,0,0);
1477
+ const k = inkKit(ctx, inkRng((p.seed||1)*2654435761 + 7));
1478
+ const m = st.manifest ?? 2, h = st.hold ?? 1, sk = SKIES[p.time] || SKIES.night;
1479
+ const a = (INK_SETTINGS[p.setting] || INK_SETTINGS.street)(k, sk) || {floor:212};
1480
+ const n = Math.max(0, Math.min(4, p.figures ?? 1));
1481
+ for (let i=0;i<n;i++){ const x = 24+k.rnd()*272; inkFigure(k, x, a.floor - k.rnd()*8, 40+k.rnd()*12, {col:"#1a1224", stare: k.rnd() < 0.45, stripes: k.rnd() < 0.4 ? "rgba(232,224,238,.6)" : null}); }
1482
+ if (p.hint !== "none") inkHint(k, a, m, p.hint);
1483
+ if (p.player !== false){ const px = 130+k.rnd()*60; inkFigure(k, px, a.floor, 66, {rim:INK.violet, curl:true, eyes: h>=5 ? INK.violet : null}); inkShadow(k, px, a.floor, 30 + h*6, h>=7 ? "#2a1450" : "#0a0612"); }
1484
+ if (p.extra) p.extra(k, a, m);
1485
+ if (p.weather==="rain") inkRain(k); else if (p.weather==="snow") inkSnow(k); else if (p.weather==="fog") inkFog(k);
1486
+ inkFinish(k, m);
1487
+ }
1488
+
1489
+ /* ---------- The key moments: larger, richer illustrations, filled in from the player's own story ---------- */
1490
+ function inkText(k, text, x, y, size, col=INK.paper, o={}){ const c=k.ctx; if (!text) return; c.save(); c.font = `${o.italic?"italic ":""}${size}px "IM Fell English","Shippori Mincho",Georgia,serif`; c.textAlign = o.align || "center"; c.textBaseline = "middle";
1491
+ let t = String(text); const maxW = o.max || 200; while (c.measureText(t).width > maxW && t.length > 4) t = t.slice(0,-2); if (t !== String(text)) t = t.slice(0,-1) + "…";
1492
+ if (o.outline){ c.lineWidth = 3; c.strokeStyle = INK.ink; c.strokeText(t,x,y); } c.fillStyle = col; c.fillText(t,x,y); c.restore(); }
1493
+ function inkMarquee(k, x, y, w, h, title, sub){ k.shape(c=>{ c.moveTo(x,y+h); c.lineTo(x,y+6); c.quadraticCurveTo(x+w/2,y-8,x+w,y+6); c.lineTo(x+w,y+h); c.closePath(); }, "#2a1830", {lw:2.2});
1494
+ for (let i=0;i<Math.floor(w/9);i++){ const bx=x+5+i*9; k.ctx.fillStyle=INK.win; k.ctx.beginPath(); k.ctx.arc(bx, y+h-3, 1.6, 0, Math.PI*2); k.ctx.fill(); k.glow(bx, y+h-3, 5, "rgba(242,196,107,1)", .5); }
1495
+ inkText(k, title, x+w/2, y+h*0.45, 12, INK.win, {max:w-14, outline:true}); if (sub) inkText(k, sub, x+w/2, y+h*0.78, 7, INK.paper, {max:w-14, italic:true}); }
1496
+ function inkAudience(k, y, n, m, rows=2){ for (let r=0;r<rows;r++) for (let i=0;i<n;i++){ const x = 8 + i*(PW-16)/(n-1) + (r%2)*8, yy = y + r*14, hh = 14;
1497
+ k.shape(c=>{ c.ellipse(x, yy, 6, 7, 0, 0, Math.PI*2); c.moveTo(x-10, yy+hh); c.quadraticCurveTo(x, yy+2, x+10, yy+hh); }, r ? "#0c0812" : "#150e1e", {lw:1.2});
1498
+ if (k.rnd() < 0.08 + m*0.02){ k.ctx.fillStyle = INK.violet; k.ctx.fillRect(x-3,yy-1,2,2); k.ctx.fillRect(x+1,yy-1,2,2); } } }
1499
+ const INK_MOMENTS = {
1500
+ title(k, i){ // the frontispiece: a crooked town, a looming host, and you on the hill with a shadow that isn't quite yours
1501
+ const presence = Math.min(1, 0.3 + (i.returning||0)*0.14);
1502
+ const g = k.ctx.createLinearGradient(0,0,0,PH); g.addColorStop(0,"#0f1c30"); g.addColorStop(.55,"#3a2552"); g.addColorStop(1,"#5a3060"); k.ctx.fillStyle = g; k.ctx.fillRect(0,0,PW,PH);
1503
+ inkSwirls(k, 7, 12, 90);
1504
+ inkMoon(k, 78, 58, 34); inkEye(k, 92, 54, 4); // something looks out of the moon's bite
1505
+ for (let s=0;s<22;s++){ const x=k.rnd()*PW, y=k.rnd()*90; k.ctx.fillStyle="rgba(239,226,192,.8)"; k.ctx.fillRect(x,y,1.2,1.2); }
1506
+ const c = k.ctx; c.save(); c.globalAlpha = presence; // the host, looming behind the town
1507
+ k.glow(236,110,90,"rgba(154,99,224,1)",.25);
1508
+ inkFigure(k, 236, 206, 138, {hat:true, col:"#1a0d2e", band:INK.gold, lean:-6}); c.restore();
1509
+ c.save(); c.globalAlpha = Math.min(1, presence+0.35); c.fillStyle = INK.gold; c.fillRect(221,97,4,3); c.fillRect(232,97,4,3); k.glow(229,99,16,"rgba(242,196,107,1)",.8); c.restore();
1510
+ const cardAt=[[132,62],[186,46],[302,74],[20,112]]; for (let q=0;q<4;q++){ const [x,y]=cardAt[q]; c.save(); c.translate(x,y); c.rotate((k.rnd()-.5)*1.3); k.shape(cc=>{ cc.rect(-5,-7,10,14); }, "#f0e8dc", {lw:1}); c.fillStyle = q%2 ? INK.red : INK.ink; c.beginPath(); c.arc(0,0,2,0,Math.PI*2); c.fill(); c.restore(); }
1511
+ k.shape(cc=>{ cc.arc(150,30,9,0,Math.PI*2); }, "#f0e8dc", {lw:1.4}); k.line([[150,30],[146,24]],1.2); k.line([[150,30],[156,31]],1.2);
1512
+ inkHills(k, 176, "#241a36", 14);
1513
+ [[4,196,42,96,-0.1],[52,190,36,72,0.12],[196,198,40,86,-0.08],[240,194,34,70,0.1],[276,200,44,100,-0.06]].forEach(([x,b,w,h,l],n)=>inkHouse(k,x,b,w,h,l,["#3b2d4f","#2f4a52","#4a3350","#5a4a30","#3b2d4f"][n],2));
1514
+ inkSpiralHill(k, 150, 206, 46, "#3e2c5c");
1515
+ k.shape(cc=>{ cc.moveTo(150,206); cc.quadraticCurveTo(110,190,132,168); cc.quadraticCurveTo(152,150,146,132); }, null, {lw:5, stroke:"#e0c890"}); k.shape(cc=>{ cc.moveTo(150,206); cc.quadraticCurveTo(110,190,132,168); cc.quadraticCurveTo(152,150,146,132); }, null, {lw:1, stroke:INK.ink});
1516
+ inkGround(k, 212, "#1a1226");
1517
+ inkFigure(k, 150, 138, 40, {rim:INK.violet, curl:true});
1518
+ c.save(); c.globalAlpha=.6; c.fillStyle="#0a0612"; c.beginPath(); c.moveTo(148,138); c.quadraticCurveTo(190,150,214,178); c.lineTo(222,176); c.quadraticCurveTo(196,146,153,137); c.fill(); c.restore();
1519
+ inkFigure(k, 222, 186, 18, {hat:true, col:"rgba(10,6,18,.7)"}); // your shadow ends in a top hat
1520
+ k.curl(10,226,10,2.4,2.2); k.curl(310,226,10,2.4,2.2); k.line([[0,238],[40,226],[20,214]],2.2); k.line([[PW,238],[PW-40,226],[PW-20,214]],2.2); // wrought-iron curls in the corners
1521
+ k.glow(150,120,60,"rgba(154,99,224,1)",.18); },
1522
+ arrival(k, i){ k.ctx.fillStyle="#1e1630"; k.ctx.fillRect(0,0,PW,PH); k.hatch(0,0,PW,PH,34,"rgba(20,13,28,.2)");
1523
+ k.shape(c=>{ c.moveTo(96,24); c.quadraticCurveTo(160,-14,224,24); c.lineTo(232,210); c.lineTo(88,210); c.closePath(); }, INK.gold, {lw:2.6});
1524
+ k.shape(c=>{ c.moveTo(106,32); c.quadraticCurveTo(160,0,214,32); c.lineTo(220,202); c.lineTo(100,202); c.closePath(); }, "#3a1f66", {lw:1.5});
1525
+ k.glow(160,120,120,"rgba(154,99,224,1)",.5);
1526
+ for (const [x1,y1,x2,y2] of [[160,40,140,90],[140,90,150,130],[150,130,126,170],[140,90,180,110],[180,110,206,90]]) k.line([[x1,y1],[x2,y2]],1.4,"#e8dcff");
1527
+ k.curl(92,28,9); k.curl(228,28,9); inkGround(k, 208, "#1a1226");
1528
+ const cx=k.ctx; cx.save(); cx.globalAlpha=.4; cx.fillStyle=INK.violet; cx.beginPath(); cx.moveTo(100,206); cx.lineTo(220,206); cx.lineTo(300,PH); cx.lineTo(20,PH); cx.fill(); cx.restore();
1529
+ inkFigure(k, 170, 214, 124, {hat:true, col:"#0a0612", eyes:INK.gold, band:INK.gold, lean:6}); // stepping out
1530
+ k.shape(c=>{ c.moveTo(176,214); c.lineTo(214,226); c.lineTo(214,230); c.lineTo(178,220); c.closePath(); }, "#0a0612", {lw:1});
1531
+ inkFigure(k, 60, 236, 64, {rim:INK.violet}); inkText(k, i.city, 160, 226, 8, "rgba(232,220,200,.6)", {italic:true}); },
1532
+ court(k, i){ inkSky(k, SKIES.night, 150); inkMoons(k, 2 + Math.floor((i.manifest||6)/3));
1533
+ inkHouse(k, -6, 170, 60, 120, 0.06, SKIES.night.wall, 3); inkHouse(k, 262, 170, 62, 124, -0.06, SKIES.night.wall2, 3);
1534
+ k.shape(c=>{ c.rect(70,22,180,110); }, "#0b0714", {lw:3}); k.shape(c=>{ c.rect(76,28,168,98); }, "#2a1a4a", {lw:1});
1535
+ k.glow(160,77,120,"rgba(154,99,224,1)",.5); inkEye(k, 160, 70, 16, INK.gold);
1536
+ inkText(k, i.work ? `“${i.work}”` : "", 160, 110, 10, INK.paper, {max:150, italic:true});
1537
+ k.line([[100,132],[98,170]],3); k.line([[220,132],[222,170]],3);
1538
+ inkGround(k, 170, "#221a2c"); inkAudience(k, 186, 16, i.manifest||8, 3);
1539
+ for (const x of [30,290]) { k.shape(c=>{ c.moveTo(x-10,40); c.lineTo(x+10,40); c.lineTo(x+10,90); c.lineTo(x,82); c.lineTo(x-10,90); c.closePath(); }, "#3a1a3a", {lw:1.5}); inkEye(k, x, 58, 3); }
1540
+ inkText(k, i.city, 160, 14, 9, INK.paper, {italic:true}); },
1541
+ consumed(k, i){ const sk = {top:"#241040", low:"#6a3a7a"}; inkSky(k, sk, PH); inkMoons(k, 3 + Math.floor((i.manifest||10)/2));
1542
+ for (let j=0;j<6;j++){ const x=10+j*54; k.ctx.save(); k.ctx.translate(x+16, 0); k.ctx.rotate((j%2?-1:1)*0.18); k.shape(c=>{ c.moveTo(-12,0); c.lineTo(12,0); c.lineTo(10,40+j*6); c.quadraticCurveTo(0,58+j*6,-10,40+j*6); c.closePath(); }, "#3a2458", {lw:1.5}); inkWindow(k, -4, 16, 8, 8, true); k.ctx.restore(); } // buildings hanging from the sky
1543
+ inkHills(k, 170, "#2a1a3a", 20);
1544
+ k.shape(c=>{ c.moveTo(60,150); c.lineTo(260,150); c.lineTo(290,166); c.lineTo(30,166); c.closePath(); }, INK.wood, {lw:1.8}); // the feast on the horizon
1545
+ for (let x=48;x<280;x+=18){ inkFigure(k, x, 150, 16, {col:"#140c1e"}); k.glow(x+6,146,6,"rgba(242,196,107,1)",.5); }
1546
+ inkFigure(k, 160, 150, 60, {hat:true, col:"#0a0612", eyes:INK.gold, band:INK.gold});
1547
+ for (let s=0;s<3;s++){ const x=40+s*100; k.line([[x,230],[x+20,200],[x+10,180],[x+30,170],[x+18,150]],2,"#1a1026"); } // stairs going nowhere
1548
+ inkGround(k, 214, "#150d1e"); inkFigure(k, 160, 236, 58, {rim:INK.violet, eyes:INK.violet}); },
1549
+ curtain(k, i){ const a = INK_SETTINGS.stage(k, SKIES.night);
1550
+ inkMarquee(k, 90, 44, 140, 34, i.work || "Tonight", i.name ? `with ${i.name}` : "");
1551
+ inkFigure(k, 160, 198, 72, {rim:INK.gold, bow:true, lean:-6});
1552
+ for (const [x,y] of [[128,200],[196,202],[140,204]]){ k.line([[x,y],[x+8,y-3]],1.2,"#2a4a2a"); k.shape(c=>{ c.arc(x+9,y-4,2.4,0,Math.PI*2); }, INK.red, {lw:1}); }
1553
+ k.shape(c=>{ c.moveTo(28,188); c.lineTo(46,188); c.lineTo(42,185); c.lineTo(43,172); c.quadraticCurveTo(37,168,31,172); c.lineTo(32,185); c.closePath(); }, INK.fig, {lw:1.3}); // a hat waiting in the wings
1554
+ k.ctx.fillStyle = INK.violet; k.ctx.fillRect(33,178,2,2); k.ctx.fillRect(39,178,2,2); k.glow(37,179,10,"rgba(154,99,224,1)",.6);
1555
+ inkAudience(k, 214, 14, 3, 2); },
1556
+ host(k, i){ k.ctx.fillStyle="#1e1428"; k.ctx.fillRect(0,0,PW,PH); k.hatch(0,0,PW,120,30,"rgba(20,13,28,.25)");
1557
+ k.shape(c=>{ c.moveTo(130,0); c.lineTo(190,0); c.lineTo(180,26); c.lineTo(140,26); c.closePath(); }, INK.gold, {lw:1.5}); for (let x=138;x<186;x+=8){ k.glow(x,30,10,"rgba(242,196,107,1)",.6); } // chandelier
1558
+ k.shape(c=>{ c.moveTo(134,96); c.lineTo(186,96); c.lineTo(290,226); c.lineTo(30,226); c.closePath(); }, "#e8dcc8", {lw:2}); // the long table
1559
+ k.shape(c=>{ c.moveTo(30,226); c.lineTo(290,226); c.lineTo(290,236); c.lineTo(30,236); c.closePath(); }, "#b8a890", {lw:1.5});
1560
+ const seats = [[118,120],[202,120],[100,150],[220,150],[76,186],[244,186]], ghosts = (i.ghosts||[]).slice(0,6);
1561
+ seats.forEach(([x,y],j)=>{ const side = x < 160 ? -1 : 1; k.shape(c=>{ c.moveTo(x-6*side,y-26); c.lineTo(x+4*side,y-26); c.lineTo(x+4*side,y+6); c.lineTo(x-6*side,y+6); c.closePath(); }, INK.wood, {lw:1.4});
1562
+ k.shape(c=>{ c.ellipse(x+14*side*-1, y+6, 8, 3, 0, 0, Math.PI*2); }, "#f5efe4", {lw:1});
1563
+ if (ghosts[j]){ inkFigure(k, x, y+6, 34, {col:"rgba(30,20,44,.35)"}); inkText(k, ghosts[j], x+14*side*-1, y+13, 5, INK.ink, {max:34}); } });
1564
+ for (const [x,y] of [[150,110],[170,110],[128,150],[192,150],[110,190],[210,190]]){ k.shape(c=>{ c.rect(x-1.5,y-10,3,10); }, INK.paper, {lw:1}); k.glow(x,y-12,12,"rgba(242,196,107,1)",.6); }
1565
+ inkFigure(k, 160, 104, 44, {eyes:INK.violet, rim:INK.violet, hat:true, band:INK.violet, col:"#120b1a"}); // you, at the head of the table, wearing him
1566
+ inkText(k, i.name, 160, 112, 6, INK.ink, {max:40}); },
1567
+ understudy(k, i){ const a = INK_SETTINGS.stage(k, SKIES.night);
1568
+ inkMarquee(k, 90, 44, 140, 34, i.work || "Tonight", i.name ? `now starring ${i.name}` : "");
1569
+ inkFigure(k, 170, 200, 76, {rim:INK.gold, eyes:INK.violet, bow:true}); // the understudy takes the bow
1570
+ inkFigure(k, 44, 204, 70, {alpha:.22, lean:-8}); inkShadow(k, 44, 204, 20, "rgba(10,6,18,.3)"); // you, exiting stage left
1571
+ inkAudience(k, 214, 14, 6, 2); },
1572
+ };
1573
+ function drawMoment(canvas, key, info){
1574
+ canvas.width = PW*2; canvas.height = PH*2; const ctx = canvas.getContext("2d"); ctx.setTransform(2,0,0,2,0,0);
1575
+ const k = inkKit(ctx, inkRng(({title:11,arrival:21,court:31,consumed:41,curtain:51,host:61,understudy:71}[key]||5)*2654435761));
1576
+ info = info || {}; (INK_MOMENTS[key] || INK_MOMENTS.title)(k, info); inkFinish(k, key==="title" ? 3 : (info.manifest ?? 5));
1577
+ }
1578
+
1579
+ const PIC_SETTINGS = ["street","alley","room","station","train","cafe","office","rooftop","park","shop","stage","mirror"];
1580
+ function cleanPicture(p, seed){
1581
+ p = (p && typeof p === "object") ? p : {};
1582
+ return { setting: PIC_SETTINGS.includes(p.setting) ? p.setting : "street",
1583
+ time: ["dawn","day","dusk","night"].includes(p.time) ? p.time : "night",
1584
+ weather: ["clear","rain","fog","snow"].includes(p.weather) ? p.weather : "clear",
1585
+ figures: Math.max(0, Math.min(4, parseInt(p.figures)||0)), seed, caption: typeof p.caption === "string" ? p.caption.trim().slice(0,70) : "" };
1586
+ }
1587
+ function tapeClock(n){ const t = 14*3600 + (n||0)*97; const h = Math.floor(t/3600)%24, m = Math.floor(t/60)%60, sec = t%60; return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(sec).padStart(2,"0")}`; }
1588
+ function picHtml(id, n, caption){
1589
+ return `<figure class="vhs" aria-hidden="true"><canvas id="${id}" width="320" height="240"></canvas>${caption ? `<figcaption>${esc(caption)}</figcaption>` : ""}</figure>`;
1590
+ }
1591
+ function paintPicture(id, pic){ const c = document.getElementById(id); if (c && pic) try { drawPicture(c, pic, G ? G.state : {}); } catch(e){} }
1592
+ function momentInfo(){
1593
+ if (!G || !G.char) return {};
1594
+ const lastWork = (G.works||[]).slice(-1)[0];
1595
+ return { work: G.work || (lastWork && lastWork.title) || "", name: G.char.name, city: G.char.city,
1596
+ manifest: G.state ? (G.state.manifest ?? 5) : 5, ghosts: reachOn ? (G.pastChars||[]).filter(c=>c.id !== G.id).map(c=>c.name).filter(Boolean) : [] };
1597
+ }
1598
+ const ARCANA = {arrival:["IV","The King Arrives"], court:["X","The Court"], consumed:["XXI","The Feast"], host:["XV","The Host"], understudy:["XII","The Understudy"], curtain:["XX","The Curtain"]};
1599
+ function tarotHtml(id, key){
1600
+ const [num, name] = ARCANA[key] || ["", ""], rev = key === "curtain";
1601
+ return `<figure class="tarot${rev ? " reversed" : ""}" aria-hidden="true"><div class="tarot-card"><span class="arc-num">${num}</span><canvas id="${id}" width="320" height="240"></canvas><span class="arc-name">${name}</span></div>${rev ? `<figcaption>${name}, reversed</figcaption>` : ""}</figure>`;
1602
+ }
1603
+ function paintMoment(id, key){ const c = document.getElementById(id); if (c) try { drawMoment(c, key, key === "title" ? {} : momentInfo()); } catch(e){} }
1604
+
1605
+
1606
+ /* ---------- The cover: him, stepping out of the dark with that grin ---------- */
1607
+ function drawCover(canvas){ // an art-deco poster: flat gilded shapes and hard geometry, deliberately unlike the inked plates inside the book
1608
+ const CW = 360, CH = 540, HZ = 300; canvas.width = CW*2; canvas.height = CH*2; const c = canvas.getContext("2d"); c.setTransform(2,0,0,2,0,0);
1609
+ const rnd = inkRng(4471), GOLD = "#d9b45a", PALE = "#fff2bd", CREAM = "#f3e8c8", VIOLET = "#7a4fd0", NAVY = "#050718", TAU = Math.PI*2;
1610
+ // night: flat bands of deepening blue
1611
+ ["#070b24","#0b1032","#101640","#161e50","#1d2660","#262e70"].forEach((col,i)=>{ c.fillStyle = col; c.fillRect(0, i*HZ/6, CW, HZ/6+1); });
1612
+ // sunburst behind the eyes
1613
+ c.save(); c.beginPath(); c.rect(0,0,CW,HZ); c.clip();
1614
+ for (let i=0;i<32;i+=2){ const a0 = i/32*TAU, a1 = (i+1)/32*TAU; c.beginPath(); c.moveTo(180,84); c.lineTo(180+Math.cos(a0)*520, 84+Math.sin(a0)*520); c.lineTo(180+Math.cos(a1)*520, 84+Math.sin(a1)*520); c.closePath(); c.fillStyle = "rgba(217,180,90,.07)"; c.fill(); }
1615
+ c.restore();
1616
+ // diamond stars
1617
+ for (let n=0;n<34;n++){ const x = rnd()*CW, y = 14 + rnd()*150, r = 1 + rnd()*1.8; c.fillStyle = rnd() < .3 ? PALE : GOLD; c.beginPath(); c.moveTo(x,y-r*1.6); c.lineTo(x+r,y); c.lineTo(x,y+r*1.6); c.lineTo(x-r,y); c.closePath(); c.fill(); }
1618
+ // the one moon, with a bite taken out of it
1619
+ c.save(); c.beginPath(); c.rect(0,0,CW,CH); c.arc(314,50,17,0,TAU); c.clip("evenodd"); c.fillStyle = CREAM; c.beginPath(); c.arc(302,58,24,0,TAU); c.fill(); c.restore();
1620
+ c.strokeStyle = "rgba(217,180,90,.55)"; c.lineWidth = .8; c.beginPath(); c.arc(302,58,31,0,TAU); c.stroke();
1621
+ // his eyes, looking down on the city
1622
+ const eyePath = (cx,cy,w,h)=>{ c.beginPath(); c.moveTo(cx-w,cy); c.quadraticCurveTo(cx,cy-h*1.7,cx+w,cy); c.quadraticCurveTo(cx,cy+h*1.1,cx-w,cy); c.closePath(); };
1623
+ const eye = (cx,cy,w,h,dir)=>{
1624
+ eyePath(cx,cy,w,h); c.fillStyle = CREAM; c.fill();
1625
+ c.save(); c.clip(); const ix = cx + dir*4, iy = cy + h*0.25;
1626
+ c.fillStyle = GOLD; c.beginPath(); c.arc(ix,iy,h*1.0,0,TAU); c.fill();
1627
+ c.fillStyle = VIOLET; c.beginPath(); c.arc(ix,iy,h*0.66,0,TAU); c.fill();
1628
+ c.fillStyle = NAVY; c.beginPath(); c.arc(ix,iy,h*0.32,0,TAU); c.fill();
1629
+ c.fillStyle = PALE; c.beginPath(); c.moveTo(ix-h*.34,iy-h*.42); c.lineTo(ix-h*.2,iy-h*.28); c.lineTo(ix-h*.34,iy-h*.14); c.lineTo(ix-h*.48,iy-h*.28); c.closePath(); c.fill();
1630
+ c.restore();
1631
+ eyePath(cx,cy,w,h); c.strokeStyle = GOLD; c.lineWidth = 2.2; c.stroke();
1632
+ c.lineWidth = 1.4; for (let i=0;i<=8;i++){ const t = i/8, x = cx - w + 2*w*t, y = cy - h*1.7*2*t*(1-t)*.5*2*.5 - 0; const lift = h*(0.85*Math.sin(Math.PI*t)); c.beginPath(); c.moveTo(x, cy - lift*1.05); c.lineTo(x + (t-.5)*10, cy - lift*1.05 - 7 - 4*Math.sin(Math.PI*t)); c.stroke(); }
1633
+ c.lineWidth = 2; c.beginPath(); c.moveTo(cx-w*.9,cy-h*2.6); c.quadraticCurveTo(cx,cy-h*3.6,cx+w*.9,cy-h*2.3); c.stroke();
1634
+ };
1635
+ eye(126,84,54,23,1); eye(234,84,54,23,-1);
1636
+ // searchlights
1637
+ [[52,HZ,-22],[308,HZ,26]].forEach(([x,y,dx])=>{ c.fillStyle = "rgba(255,242,189,.07)"; c.beginPath(); c.moveTo(x-5,y); c.lineTo(x+5,y); c.lineTo(x+dx*7+26,40); c.lineTo(x+dx*7-26,40); c.closePath(); c.fill(); });
1638
+ // the city, stepped like a wedding cake and lit like a party
1639
+ const deco = (x,w,h,steps,spire,col)=>{ let cw = w, cy = HZ; const sh = h/steps;
1640
+ for (let st=0; st<steps; st++){ c.fillStyle = col; c.fillRect(x-cw/2, cy-sh, cw, sh+.5); c.strokeStyle = "rgba(217,180,90,.5)"; c.lineWidth = .7; c.strokeRect(x-cw/2, cy-sh, cw, sh);
1641
+ for (let wy=cy-sh+5; wy<cy-4; wy+=6) for (let wx=x-cw/2+4; wx<x+cw/2-3; wx+=6) if (rnd() < .58){ c.fillStyle = rnd() < .14 ? PALE : GOLD; c.fillRect(wx,wy,2.4,3.2); }
1642
+ cy -= sh; cw *= .72; }
1643
+ if (spire){ c.strokeStyle = GOLD; c.lineWidth = 1.2; c.beginPath(); c.moveTo(x,cy); c.lineTo(x,cy-spire); c.stroke(); c.fillStyle = GOLD; c.beginPath(); c.moveTo(x,cy-spire-7); c.lineTo(x+2.6,cy-spire); c.lineTo(x-2.6,cy-spire); c.closePath(); c.fill(); } };
1644
+ [[30,40,90,3,0],[92,34,120,3,10],[208,36,110,3,0],[268,44,100,3,0],[330,40,130,3,12]].forEach(([x,w,h,st,sp])=>deco(x,w,h,st,sp,"#0d1240"));
1645
+ [[16,34,70,3,0],[62,42,140,4,14],[126,46,170,4,20],[176,32,120,3,0],[240,48,150,4,16],[300,38,88,3,0],[346,30,110,3,8]].forEach(([x,w,h,st,sp])=>deco(x,w,h,st,sp,NAVY));
1646
+ // the waterline
1647
+ c.fillStyle = GOLD; c.fillRect(0,HZ,CW,3); c.fillStyle = "rgba(217,180,90,.5)"; c.fillRect(0,HZ+6,CW,1);
1648
+ // the reflection: not this city. Carcosa, by day, under two suns (drawn upright, then mirrored)
1649
+ c.save(); c.beginPath(); c.rect(0,HZ+3,CW,CH-HZ-3); c.clip(); c.translate(0,2*HZ+3); c.scale(1,-1);
1650
+ const g = c.createLinearGradient(0,60,0,HZ); g.addColorStop(0,"#79c9c4"); g.addColorStop(.6,"#c9dcb0"); g.addColorStop(1,"#f6d88e"); c.fillStyle = g; c.fillRect(0,0,CW,HZ+4);
1651
+ [[112,170,24],[240,132,17]].forEach(([x,y,r])=>{ c.strokeStyle = "rgba(255,244,196,.8)"; c.lineWidth = 1.2; for (let i=0;i<20;i++){ const a = i/20*TAU, r1 = r+3, r2 = r+(i%2?9:16); c.beginPath(); c.moveTo(x+Math.cos(a)*r1,y+Math.sin(a)*r1); c.lineTo(x+Math.cos(a)*r2,y+Math.sin(a)*r2); c.stroke(); }
1652
+ c.fillStyle = PALE; c.beginPath(); c.arc(x,y,r,0,TAU); c.fill(); c.strokeStyle = "#e0a840"; c.lineWidth = 1.5; c.stroke(); });
1653
+ const stone = ["#f1e3c4","#e9cfa6","#dcb9a2","#f4ead2"], LN = "#7a5a3a";
1654
+ const tower = (x,w,h,bend,col)=>{ const top = HZ-h; c.beginPath(); c.moveTo(x-w/2,HZ); c.quadraticCurveTo(x-w/2+bend*.5,HZ-h*.5,x-w*.3+bend,top); c.lineTo(x+w*.3+bend,top); c.quadraticCurveTo(x+w/2+bend*.5,HZ-h*.5,x+w/2,HZ); c.closePath(); c.fillStyle = col; c.fill(); c.strokeStyle = LN; c.lineWidth = 1.1; c.stroke();
1655
+ c.beginPath(); c.moveTo(x-w*.36+bend,top); c.quadraticCurveTo(x-w*.55+bend,top-w*.5,x+bend,top-w*.95); c.quadraticCurveTo(x+w*.55+bend,top-w*.5,x+w*.36+bend,top); c.closePath(); c.fillStyle = "#3f9a96"; c.fill(); c.stroke();
1656
+ for (let y=HZ-14; y>top+8; y-=17){ const t = (HZ-y)/h, wx = x+bend*t*t; c.beginPath(); c.moveTo(wx-2,y); c.lineTo(wx-2,y-5); c.arc(wx,y-5,2,Math.PI,0); c.lineTo(wx+2,y); c.closePath(); c.fillStyle = "#3a2a3e"; c.fill(); } };
1657
+ [[24,20,150,-10],[58,26,210,12],[100,16,120,-6],[262,28,200,10],[304,20,130,-8],[340,22,180,14]].forEach(([x,w,h,b],i)=>tower(x,w,h,b,stone[i%4]));
1658
+ // an arch inside an arch, and a stair that climbs away from the ground
1659
+ c.beginPath(); c.moveTo(118,HZ); c.lineTo(118,HZ-96); c.arc(180,HZ-96,62,Math.PI,0); c.lineTo(242,HZ); c.lineTo(228,HZ); c.lineTo(228,HZ-96); c.arc(180,HZ-96,48,0,Math.PI,true); c.lineTo(132,HZ); c.closePath(); c.fillStyle = "#e2c79c"; c.fill(); c.strokeStyle = LN; c.lineWidth = 1.2; c.stroke();
1660
+ for (let i=0;i<11;i++){ c.fillStyle = i%2 ? "#f6ecd4" : "#e6cba4"; c.fillRect(150+i*8,HZ-172-i*6,11,4); c.strokeRect(150+i*8,HZ-172-i*6,11,4); }
1661
+ [[150,20,250,4]].forEach(([x,w,h,b])=>tower(x,w,h,b,stone[3]));
1662
+ c.restore();
1663
+ // water: tint, broken ripples, fade toward the title
1664
+ c.fillStyle = "rgba(7,11,36,.24)"; c.fillRect(0,HZ+3,CW,CH-HZ-3);
1665
+ for (let y=HZ+8, k=0; y<CH; y+=7+k*.55, k++){ let x = -rnd()*40; while (x < CW){ const len = 20 + rnd()*70, gap = 10 + rnd()*40; c.fillStyle = "rgba(7,11,36,.62)"; c.fillRect(x,y,len,1.5+k*.09); x += len+gap; } }
1666
+ for (let n=0;n<16;n++){ const y = HZ+10+rnd()*190, x = rnd()*CW; c.fillStyle = "rgba(217,180,90,.45)"; c.fillRect(x,y,14+rnd()*36,1); }
1667
+ const fade = c.createLinearGradient(0,CH-110,0,CH); fade.addColorStop(0,"rgba(7,11,36,0)"); fade.addColorStop(1,"rgba(7,11,36,.92)"); c.fillStyle = fade; c.fillRect(0,CH-110,CW,110);
1668
+ // gilded frame with fan corners
1669
+ c.strokeStyle = GOLD; c.lineWidth = 2; c.strokeRect(9,9,CW-18,CH-18); c.lineWidth = .8; c.strokeRect(15,15,CW-30,CH-30);
1670
+ [[9,9,0],[CW-9,9,1],[CW-9,CH-9,2],[9,CH-9,3]].forEach(([x,y,q])=>{ c.save(); c.translate(x,y); c.rotate(q*Math.PI/2); c.lineWidth = 1; for (let r=10;r<=34;r+=8){ c.beginPath(); c.arc(0,0,r,0,Math.PI/2); c.stroke(); } for (let a=0;a<=6;a++){ const t = a/6*Math.PI/2; c.beginPath(); c.moveTo(Math.cos(t)*10,Math.sin(t)*10); c.lineTo(Math.cos(t)*34,Math.sin(t)*34); c.stroke(); } c.restore(); });
1671
+ }
1672
+
1673
+
1674
+ function coverStars(){ let h = ""; for (let i=0;i<34;i++){ const x = (i*37 % 100), y = (i*53 % 62), d = (i%5)*0.6, sz = 1 + (i%3); h += `<i style="left:${x}%;top:${y}%;animation-delay:${d}s;width:${sz}px;height:${sz}px"></i>`; } return h; }
1675
+ function renderCover(){
1676
+ tabs.hidden = true; view.screen = "cover";
1677
+ app.innerHTML = `<section class="cover" id="cover" role="button" tabindex="0" aria-label="Open the book">
1678
+ <div class="cover-art"><canvas id="coverPic" width="360" height="540"></canvas><div class="stars">${coverStars()}</div></div>
1679
+ <h1>The Sovereign in Violet</h1><p class="cover-tap">Tap to open</p></section>`;
1680
+ try { drawCover(document.getElementById("coverPic")); } catch(e){}
1681
+ const go = ()=>renderTitle(); const el = document.getElementById("cover"); el.onclick = go; el.onkeydown = e=>{ if (e.key==="Enter"||e.key===" ") go(); };
1682
+ }
1683
+
1684
+ /* ---------- Game screens ---------- */
1685
+ function renderStoryShell(action, inner){
1686
+ setTimeout(pfxLoading, 0);
1687
+ tabs.hidden = false; setTabs();
1688
+ const last = G.current;
1689
+ app.innerHTML = `<p class="step-kicker">${esc(G.char.name)}${G.mode==="oneshot"?", one-shot":""}</p>
1690
+ ${action && action.kind!=="start" ? `<p class="action">${esc(action.label||action.text)}</p>` : ""}
1691
+ <div id="storyBody"><div class="scene" id="liveScene"></div><div id="liveThinking">${inner}</div><div class="row"><button class="btn-link" id="stopBtn">Stop</button></div></div>`;
1692
+ const sb = document.getElementById("stopBtn"); if (sb) sb.onclick=()=>ctl?.abort();
1693
+ }
1694
+ function setTabs(){ tabs.querySelectorAll("[data-tab]").forEach(b=>{ if(b.dataset.tab===view.tab) b.setAttribute("aria-current","page"); else b.removeAttribute("aria-current"); }); }
1695
+ tabs.querySelectorAll("[data-tab]").forEach(b=>b.onclick=()=>{ view.tab=b.dataset.tab; renderGame(); window.scrollTo(0,0); });
1696
+
1697
+ function renderGame(){
1698
+ view.screen="game"; tabs.hidden=false; setTabs();
1699
+ if (busy && view.tab==="story") return renderStoryShell(currentAction, thinking("The Sovereign considers…"));
1700
+ if (view.tab==="sheet") return renderSheet();
1701
+ if (view.tab==="journal") return renderJournal();
1702
+ if (view.tab==="world") return renderWorld();
1703
+ renderStory();
1704
+ }
1705
+
1706
+ function renderStory(){
1707
+ const cur = G.current;
1708
+ if (!cur){ if (G.pending) return takeTurn(G.pending); return takeTurn({text:"Begin the story.", kind:"start"}); }
1709
+ const last = G.history[G.history.length-1];
1710
+ let html = `<p class="step-kicker">${esc(G.char.name)}${G.mode==="oneshot"?`, one-shot, scene ${G.sceneCount}`:`, chapter ${ROMAN[G.chapter||1]||G.chapter}`}</p>`;
1711
+ if (resumeRecap){ resumeRecap = false; const rc = (G.chronicle||[]).slice(-3); if (rc.length && G.sceneCount > 1) html += `<details class="recap" open><summary>Previously</summary>${rc.map(x=>`<p>${esc(x)}</p>`).join("")}</details>`; }
1712
+ if (G.mode !== "oneshot" && G.sceneCount - (G.chapterStart||0) === 1 && !G.ended && !G.intermission) html += epigraphHtml(quoteFor("open", G, G.chapter||1));
1713
+ if (last && last.action!=="Began the story." && last.action!=="A new chapter.") html += `<p class="action">${esc(last.action)}</p>`;
1714
+ if (last && last.omen) html += `<p class="omen">${esc(last.omen)}</p>`;
1715
+ if (cur.picture) html += picHtml("scenePic", G.sceneCount, cur.picture.caption ? `Plate ${G.sceneCount}. ${cur.picture.caption}` : "");
1716
+ let sceneHtml = paras(cur.text);
1717
+ if (cur.underline){ const u = esc(cur.underline); const at = sceneHtml.indexOf(u); if (u.length > 3 && at >= 0) sceneHtml = sceneHtml.slice(0,at) + `<u class="vu">${u}</u>` + sceneHtml.slice(at+u.length); }
1718
+ const noteGold = (last && last.whisper) ? `<aside class="note gold">${esc(last.whisper)}</aside>` : "";
1719
+ const noteReader = cur.readerNote ? `<aside class="note reader">${esc(cur.readerNote)}${cur.readerBy ? `<span class="sig">— ${esc(cur.readerBy)}</span>` : ""}</aside>` : "";
1720
+ const ps = sceneHtml.split("</p>").filter(x=>x.trim()).map(x=>x+"</p>");
1721
+ if (noteGold) ps.splice(Math.min(1, ps.length), 0, noteGold);
1722
+ if (noteReader) ps.splice(Math.min(ps.length, noteGold ? 3 : 2), 0, noteReader);
1723
+ sceneHtml = ps.join("");
1724
+ html += `<div class="scene">${sceneHtml}</div>`;
1725
+ html += `<p class="folio">— ${G.sceneCount} —</p>`;
1726
+
1727
+ if (cur.reflection && !cur.reflectionDone){
1728
+ html += `<div class="card gold"><h3>A quiet moment</h3><p>${esc(cur.reflection)}</p>
1729
+ <textarea id="reflText" rows="3" placeholder="Write as much or as little as you like. Only you will see this."></textarea>
1730
+ <div class="row" style="margin-top:.6rem"><button class="btn" id="reflKeep">Keep in journal</button><button class="btn-link" id="reflSkip">Let it pass</button></div></div>`;
1731
+ app.innerHTML = html;
1732
+ document.getElementById("reflKeep").onclick=async()=>{ const a=document.getElementById("reflText").value.trim(); G.reflections.push({q:cur.reflection, a, scene:G.sceneCount}); cur.reflectionDone=true; await Store.put(G); renderStory(); };
1733
+ document.getElementById("reflSkip").onclick=async()=>{ G.reflections.push({q:cur.reflection, a:"", scene:G.sceneCount}); cur.reflectionDone=true; await Store.put(G); renderStory(); };
1734
+ return;
1735
+ }
1736
+
1737
+ if (G.state.deathsDoor && !cur.priceScene){
1738
+ html += `<div class="card door"><h3>Death's Door</h3><p>You stand at the brink. You will live, but not unchanged.</p><button class="btn" id="faceBtn">Face what waits</button></div>`;
1739
+ app.innerHTML = html;
1740
+ document.getElementById("faceBtn").onclick=()=>takeTurn({text:"The player stands at the brink and faces what waits there.", label:"You face what waits at the brink.", kind:"face"});
1741
+ return;
1742
+ }
1743
+
1744
+ if (G.intermission){
1745
+ const im = G.intermission;
1746
+ const workLine = im.work ? `<p class="muted">${im.winner === "free" ? "Your work is finished, and it is yours" : "Your work is finished, but it is his"}: ${esc(im.work)}</p>` : "";
1747
+ const line = (im.winner === "free" ? `You held your ground, this time.${im.reward ? ` Your ${im.reward} grows.` : ""}` : `${ACT_TURN[actOf()] || "He gained ground."}</p><p>The world will not forget it: ${esc(im.lasting||"")}`) ;
1748
+ const imTitle = im.winner === "free" ? `Chapter ${ROMAN[im.chapter]||im.chapter} ends` : (ACT_TITLES[actOf()] || `Chapter ${ROMAN[im.chapter]||im.chapter} ends`);
1749
+ const imPic = im.winner === "free" ? null : (actOf() >= 3 ? "court" : "arrival");
1750
+ html += `<div class="card ${im.winner==="free"?"gold":"door"}">${imPic ? tarotHtml("momentPic", imPic) : ""}${im.winner==="free"?"":`<p class="muted small">Chapter ${ROMAN[im.chapter]||im.chapter} ends</p>`}<h3>${imTitle}</h3><p>${line}</p>${workLine}${learnedHtml(im.observed)}${im.chapter===1?`<p class="pfx-voice">Now you've thought of him too.</p>`:""}${epigraphHtml(quoteFor(im.winner==="free"?"free":"him", G, im.chapter))}<p class="muted small">You can let the story rest here, or carry on. A resting story waits for you on the title screen.</p>
1751
+ <div class="row"><button class="btn" id="nextChapter">Continue to Chapter ${ROMAN[G.chapter]||G.chapter}</button><button class="btn-quiet" id="toTitle">Let the story rest here</button></div></div>`;
1752
+ app.innerHTML = html;
1753
+ paintPicture("scenePic", cur && cur.picture);
1754
+ if (imPic) paintMoment("momentPic", imPic);
1755
+ document.getElementById("nextChapter").onclick=async()=>{ G.intermission = null; await Store.put(G); takeTurn({text:`Chapter ${ROMAN[G.chapter]||G.chapter} begins. Some time has passed.`, kind:"chapter"}); };
1756
+ document.getElementById("toTitle").onclick=renderTitle;
1757
+ return;
1758
+ }
1759
+ if (G.ended){
1760
+ const t = G.endingType || (G.lost ? "lost" : "free");
1761
+ const head = ENDING_TITLES[(t === "real" && G.final) ? "outbreak" : t] || "The story ends";
1762
+ const key = (t === "real" && G.final) ? "outbreak" : t;
1763
+ const line = ENDINGS[key];
1764
+ const canContinue = G.mode === "oneshot" && t !== "lost" && t !== "faded";
1765
+ const endPic = {free:"curtain", lost:"host", faded:"understudy", outbreak:"consumed", real:"arrival"}[key];
1766
+ html += `<div class="card ${t==="free"?"gold":"door"}${key==="faded"?" faded-end":""}">${endPic ? tarotHtml("momentPic", endPic) : ""}<h3>${head}</h3><p id="endLine">${(key==="lost"||key==="outbreak") ? "" : line}</p>${key==="faded"?`<p class="pfx-voice">${FADED_VOICE}</p>`:""}${learnedHtml(G.observed)}<p class="pfx-voice">Now you've thought of him too.</p>
1767
+ <div class="row">${canContinue?`<button class="btn" id="toCampaign">Continue as a campaign</button>`:""}<button class="btn-quiet" id="toTitle">Back to title</button></div></div>`;
1768
+ app.innerHTML = html;
1769
+ paintPicture("scenePic", cur && cur.picture);
1770
+ if (endPic) paintMoment("momentPic", endPic);
1771
+ if (key === "lost" || key === "outbreak"){ const el = document.getElementById("endLine"); typeInto(el, lang==="ja" ? (JA[line]||line) : line); }
1772
+ if (canContinue) document.getElementById("toCampaign").onclick=async()=>{ G.mode="campaign"; G.ended=false; G.chapter=1; G.wins={you:0,him:0}; endChapter(t, G.lastLasting); await Store.put(G); renderGame(); };
1773
+ document.getElementById("toTitle").onclick=renderTitle;
1774
+ return;
1775
+ }
1776
+
1777
+ const opts = cur.choices.length ? cur.choices : [{text:"Continue", risk:"none", trait:"Wits"}];
1778
+ const isPrice = cur.priceScene && G.state.deathsDoor;
1779
+ const shown = isPrice ? opts : opts.map(o=>o.price ? {...o, hint:"Price: "+o.price} : o);
1780
+ html += choicePanel(shown, {hints:true, customLabel: isPrice?"Name your own price":"Write your own", placeholder: isPrice?"What will you give up?":"What do you do? (Start with /edit to change the world instead)"});
1781
+ app.innerHTML = html;
1782
+ paintPicture("scenePic", cur.picture);
1783
+ raceHints();
1784
+ pfxStory();
1785
+ wireChoices(shown, async (o, how)=>{
1786
+ if (pfxBusy) return;
1787
+ if (isPrice) return takeTurn({text:o.text, price:o.price||o.text, label:"You pay: "+(o.price||o.text), kind:"door"});
1788
+ if (o.custom){
1789
+ if (/^\/edit\b/i.test(o.text)){ view.tab="world"; renderWorld(o.text.replace(/^\/edit\s*/i,"")); return; }
1790
+ await pfxTypeover();
1791
+ let risk="none", trait="Wits";
1792
+ const go = app.querySelector("[data-custom-go]"); if (go){ go.disabled=true; go.textContent="Reading your intent…"; }
1793
+ try{
1794
+ needSample();
1795
+ const pq = G.pendingProbe && !G.pendingProbe.regret ? G.pendingProbe : null;
1796
+ const j = await sample.json(`A player in a modern cosmic-horror roleplay attempts this action: "${o.text}". Context: ${G.current.text.slice(-600)}\nReply with only JSON {"risk":"none|low|medium|high","trait":"Nerve|Wits|Grit|Charm","gift":"none|shortcut|own"${pq?',"probe":0|1|2':''}} judging how risky it is and which trait it relies on, and whether it draws on the Sovereign's power: "shortcut" if as an escape or easy way out, "own" if bent deliberately toward the player's own ends (their work, someone who matters, facing a fear), "none" if not at all.${pq ? ` Also judge, from what they wrote, how true this statement is for them: "${pq.text}" (2 very true, 1 sometimes, 0 not true).` : ""}`, {modelTier:"quick"});
1797
+ if (pq && [0,1,2].includes(parseInt(j.probe))) o.probeRead = parseInt(j.probe);
1798
+ if (j.gift === "own" || j.gift === "shortcut") o.giftKind = j.gift;
1799
+ if (["none","low","medium","high"].includes(j.risk)) risk=j.risk;
1800
+ if (TRAITS.includes(j.trait)) trait=j.trait;
1801
+ }catch(e){ risk="medium"; trait=pick(TRAITS); }
1802
+ const pr = G.pendingProbe; if (pr) G.pendingProbe = null;
1803
+ return takeTurn({text:o.text, kind:"custom", gift: o.giftKind || false, regretOf: (pr && pr.regret) ? pr : null, regret: (pr && pr.regret) ? "instead" : null,
1804
+ probeOf: (pr && !pr.regret) ? pr : null, probe: (pr && !pr.regret && o.probeRead != null) ? o.probeRead : null, quietOwn: pr ? o.text : null, roll: secretRoll(trait, risk)});
1805
+ }
1806
+ if (how === "choice") await pfxEcho(shown.indexOf(o));
1807
+ const pp = G.pendingProbe;
1808
+ const probeOf = (how === "choice" && o.probe != null && pp && !pp.regret) ? pp : null;
1809
+ const regretOf = (how === "choice" && o.regret && pp && pp.regret) ? pp : null;
1810
+ const surrender = (how === "random" && pp) ? pp : null; // at a quiet moment, letting chance answer means giving up
1811
+ if (G.pendingProbe) G.pendingProbe = null;
1812
+ takeTurn({text:o.text, kind:"choice", gift: o.gift || false, price:o.price||null, probe: probeOf ? o.probe : null, probeOf, regret: regretOf ? o.regret : null, regretOf, surrender, roll: secretRoll(o.trait, o.risk)});
1813
+ });
1814
+ }
1815
+
1816
+ function sheetTraits(){
1817
+ return `<div class="traits">${TRAITS.map(t=>`<div class="trait"><span>${t}</span><span class="pips" aria-label="${t} ${G.char.traits[t]} of 5">${[1,2,3,4,5].map(i=>`<i class="${i<=G.char.traits[t]?'on':''}"></i>`).join("")}</span></div>`).join("")}</div>`;
1818
+ }
1819
+ function renderSheet(){ renderSheetInner(); raceHints(); pfxSheet(); }
1820
+ function renderSheetInner(){
1821
+ const s = G.state, ch = G.char;
1822
+ const list = (arr, fn) => arr.length ? `<ul class="plain">${arr.map(fn).join("")}</ul>` : `<p class="empty">None yet.</p>`;
1823
+ const worksHtml = (G.work || (G.works||[]).length) ? `<h3>Your Work</h3>${G.work?`<p>${esc(G.work)}</p>`:""}${(G.works||[]).length?`<ul class="plain">${G.works.map(w=>`<li><span class="muted small">Chapter ${ROMAN[w.chapter]||w.chapter}</span><br>${esc(w.title)}</li>`).join("")}</ul>`:""}` : "";
1824
+ app.innerHTML = `<h2 style="opacity:${(1 - (s.fade||0)*0.045).toFixed(2)}">${esc(ch.name)}<span class="resolve-light" style="--r:${((s.resolve||0)/10).toFixed(2)}" aria-hidden="true"></span></h2><p class="muted small">${esc(ch.city)}${G.mode!=="oneshot"?` · Chapter ${ROMAN[G.chapter||1]||G.chapter}`:""}</p>
1825
+ ${G.goal?`<h3>What you're fighting for</h3><p>${esc(G.goal)}</p>`:""}${worksHtml}<div class="scene">${paras(ch.portrait)}</div>
1826
+ <h3>Traits</h3>${sheetTraits()}
1827
+ <h3>Condition</h3>
1828
+ <div class="row" style="justify-content:space-between;margin-bottom:.5rem"><span>Wounds</span><span class="track" aria-label="Wounds ${s.wounds} of 5">${[1,2,3,4,5].map(i=>`<i class="${i<=s.wounds?'on':''}"></i>`).join("")}</span></div>
1829
+ <div class="row" style="justify-content:space-between"><span>Stability</span><span class="track stab" aria-label="Stability ${s.stability} of 5">${[1,2,3,4,5].map(i=>`<i class="${i<=s.stability?'on':''}"></i>`).join("")}</span></div>
1830
+ <h3>The pulls within you</h3>
1831
+ ${tset().map(t=>{ const v=s.tensions[t.key]||0; return `<div class="scale${t.main?' main':''}"><div class="ends"><span>${t.left}</span><span>${t.right}</span></div><div class="bar"><span class="dot" style="left:${50+v*5}%"></span></div></div>`; }).join("")}
1832
+ <h3>Before the hour runs out</h3><p class="muted small">Day ${s.days||1}</p>${list(s.aims||[], a=>`<li>${esc(a)}</li>`)}
1833
+ <h3>Gifts of the Sovereign</h3>${list(s.gifts, g=>`<li><b>${esc(g.name)}</b>. ${esc(g.effect)}<br><span class="muted small">Cost: ${esc(g.cost)}</span></li>`)}
1834
+ <h3>Scars and debts</h3>${list(s.scars, x=>`<li>${esc(x)}</li>`)}
1835
+ <h3>Bonds</h3>${list(s.bonds, b=>`<li><b>${esc(b.name)}</b>${b.note?". "+esc(b.note):""}</li>`)}
1836
+ <h3>Carrying</h3>${list(s.items, i=>`<li>${esc(i)}</li>`)}
1837
+ <h3>Background</h3><ul class="plain">${Object.entries(ch.picks).map(([k,v])=>{ const lab=(PILLARS.find(p=>p.key===k)||SPIRAL.find(p=>p.key===k)||{title:k}).title; return `<li><span class="muted small">${esc(lab)}</span><br>${esc(v)}</li>`; }).join("")}</ul>`;
1838
+ }
1839
+ function renderJournal(){
1840
+ const r = G.reflections.filter(x=>x.a);
1841
+ app.innerHTML = `<h2>Journal</h2>
1842
+ <h3>Your reflections</h3>${r.length?`<ul class="plain">${r.map(x=>`<li><span class="muted small">Scene ${x.scene}${x.q?": "+esc(x.q):""}</span><br>${esc(x.a)}</li>`).join("")}</ul>`:`<p class="empty">Quiet moments will appear in the story now and then. What you write there is kept here.</p>`}
1843
+ <h3>Open threads</h3>${G.threads.length?`<ul class="plain">${G.threads.map(t=>`<li>${esc(t)}</li>`).join("")}</ul>`:`<p class="empty">None yet.</p>`}
1844
+ <h3>People</h3>${G.npcs.length?`<ul class="plain">${G.npcs.map(n=>`<li><b>${esc(n.name)}</b>${n.note?". "+esc(n.note):""}</li>`).join("")}</ul>`:`<p class="empty">None yet.</p>`}
1845
+ <h3>The story so far</h3>${G.chronicle.length?`<div class="scene small">${G.chronicle.map(c=>`<p>${esc(c)}</p>`).join("")}</div>`:`<p class="empty">Nothing has happened yet.</p>`}`;
1846
+ }
1847
+ function renderWorld(prefill=""){
1848
+ app.innerHTML = `<h2>World</h2>
1849
+ <h3>Change the world</h3>
1850
+ <p class="muted small">Add or alter a fact of the world: a place, a person, a rule. If a change would work against what this story is for, you'll be told why and can decide. You can also type /edit in the story's own-choice box.</p>
1851
+ <textarea id="editText" rows="3" placeholder="For example: My sister Dana lives two floors below me.">${esc(prefill)}</textarea>
1852
+ <div class="row" style="margin-top:.6rem"><button class="btn" id="editGo">Check and apply</button></div>
1853
+ <div id="editResult"></div>
1854
+ <h3>World facts you've set</h3>
1855
+ ${G.edits.length?`<ul class="plain">${G.edits.map((e,i)=>`<li>${esc(e.canon)}${e.override?` <span class="muted small">(applied despite a concern)</span>`:""}<br><button class="btn-link" data-rm="${i}">Remove</button></li>`).join("")}</ul>`:`<p class="empty">None yet.</p>`}
1856
+ <h3>Story quality</h3>
1857
+ <p class="muted small">Lighter uses less of your Claude usage per scene, with simpler prose.</p>
1858
+ <div class="seg" role="group" aria-label="Story quality"><button data-tier="default" aria-pressed="${tier==="default"}">Richer</button><button data-tier="quick" aria-pressed="${tier==="quick"}">Lighter</button></div>
1859
+ <h3>Journal questions</h3>
1860
+ <p class="muted small">Now and then the story may pause with a gentle question about your own life. Turn this off to just play.</p>
1861
+ <div class="seg" role="group" aria-label="Journal questions"><button data-journal="on" aria-pressed="${journalOn}">On</button><button data-journal="off" aria-pressed="${!journalOn}">Off</button></div>
1862
+ <h3>Can the Sovereign see your journal?</h3>
1863
+ <p class="muted small">What you confide can be your anchor, or his leash.</p>
1864
+ <p class="muted small">Either way, the journal questions continue.</p>
1865
+ <div class="seg" role="group" aria-label="Can the Sovereign see your journal?"><button data-reach="on" aria-pressed="${reachOn}">Let him see</button><button data-reach="off" aria-pressed="${!reachOn}">Keep it from him</button></div>
1866
+ <div class="row" style="margin-top:.8rem"><button class="btn-quiet" id="forgetBtn">Ask him to forget you</button></div>
1867
+ <p class="pfx-voice" id="forgetLine" hidden></p>
1868
+ <p class="muted small" id="forgetNote" hidden>What he carried between your stories has been cleared. Saves already in progress stay as they are.</p>
1869
+ <h3>Page effects</h3>
1870
+ <div class="seg" role="group" aria-label="Page effects"><button data-pfx="on" aria-pressed="${pfxOn}">On</button><button data-pfx="off" aria-pressed="${!pfxOn}">Off</button></div>
1871
+ <h3>Language</h3>
1872
+ ${langSeg()}
1873
+ <h3>Leave</h3><button class="btn-quiet" id="exitBtn">Save and return to title</button>
1874
+ <p class="muted small" style="margin-top:1rem">Your story saves after every scene.</p>`;
1875
+ app.querySelectorAll("[data-rm]").forEach(b=>b.onclick=async()=>{ G.edits.splice(+b.dataset.rm,1); await Store.put(G); renderWorld(); });
1876
+ app.querySelectorAll("[data-tier]").forEach(b=>b.onclick=()=>{ tier=b.dataset.tier; try{localStorage.setItem("vh-tier",tier);}catch(e){} renderWorld(document.getElementById("editText").value); });
1877
+ document.getElementById("exitBtn").onclick=async()=>{ await Store.put(G); renderTitle(); };
1878
+ document.getElementById("editGo").onclick=checkEdit;
1879
+ }
1880
+ async function checkEdit(){
1881
+ const txt = document.getElementById("editText").value.trim();
1882
+ const out = document.getElementById("editResult");
1883
+ if (!txt) return;
1884
+ out.innerHTML = thinking("Weighing the change…");
1885
+ try{
1886
+ needSample();
1887
+ const j = await sample.json(`${themesFor(tset(), G && G.personal)}\n\nThe player of this solo roleplay wants to change the world with this edit: "${txt}"\nCurrent state: ${stateBrief()}\nExisting world facts: ${JSON.stringify(G.edits.map(e=>e.canon))}\nJudge whether the edit works against the hidden purpose. Concerning edits include: removing the costs of either side of a tension, making one side of a tension free or automatic (for example everyone simply loves them, or they can own everything without limit), stripping the Sovereign of his agenda or his honesty, erasing consequences, scars or debts, or granting power without price. Flavor and setting edits (people, places, history, details) are fine, as are edits that make the story harder or more personal.\nReply with only JSON: {"concern":true|false,"reason":"if concern, 1-2 plain, kind sentences addressed to the player (you) explaining what the edit would take away from what the story is helping them explore; else empty","canon":"the edit restated as one short world fact"}`, {modelTier: tier});
1888
+ const canon = String(j.canon || txt);
1889
+ if (j.concern){
1890
+ out.innerHTML = `<div class="card gold"><h3>Before you change this</h3><p>${esc(j.reason)}</p><p class="muted small">Proposed fact: ${esc(canon)}</p><div class="row"><button class="btn" id="applyAnyway">Apply anyway</button><button class="btn-quiet" id="keepWorld">Keep the world as it is</button></div></div>`;
1891
+ document.getElementById("applyAnyway").onclick=async()=>{ G.edits.push({canon, override:true}); await Store.put(G); renderWorld(); };
1892
+ document.getElementById("keepWorld").onclick=()=>{ out.innerHTML=""; };
1893
+ } else {
1894
+ G.edits.push({canon}); await Store.put(G); renderWorld();
1895
+ }
1896
+ }catch(e){
1897
+ out.innerHTML = `<p class="error">${esc(e.code==="unavailable_here"?"This page can't reach Claude here. Open it in the Claude app or claude.ai.":errCopy(e))}</p>`;
1898
+ }
1899
+ }
1900
+
1901
+ /* ---------- Boot ---------- */
1902
+ renderCover();
1903
+ (async()=>{
1904
+ const cfg = engineCfg(), custom = cfg.mode === "custom" && (cfg.provider === "gemini" ? cfg.key : (cfg.base || cfg.key));
1905
+ if (custom) useEngine(buildCustomSample(cfg));
1906
+ if (window.claude && window.claude.use){
1907
+ try{
1908
+ const [sm, d, u] = await Promise.all([custom ? null : claude.use("sample"), claude.use("db"), claude.use("user")]);
1909
+ if (sm) useEngine(sm);
1910
+ db = d;
1911
+ if (u){ try{ uid = await u.id(); }catch(e){} }
1912
+ }catch(e){}
1913
+ }
1914
+ if (view.screen==="title") renderTitle(); else if (view.screen==="cover") renderCover();
1915
+ })();