fuzzyworld

dm / files / public / index.html

index.html

155.3 KB · history · blame

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Solo Dungeon Master</title>
7<link rel="icon" type="image/png" href="dice.png">
8<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
9<style>
10:root {
11 --bg: #0d0d1a;
12 --bg-panel: #11111e;
13 --bg-sidebar: #0c1220;
14 --bg-card: #181826;
15 --bg-dm: #14142a;
16 --bg-player: #1a0f28;
17 --bg-input: #0a0a18;
18 --gold: #c9a84c;
19 --gold-light: #e8c97a;
20 --gold-dim: #7a5c20;
21 --text: #d0be98;
22 --text-dim: #6a5a42;
23 --text-bright: #eedcb8;
24 --border: #1e1e34;
25 --border-gold: #3a2a0e;
26 --hp-full: #2ecc71;
27 --hp-med: #f39c12;
28 --hp-low: #e74c3c;
29 --accent: #6b4fbb;
30}
31
32* { box-sizing: border-box; margin: 0; padding: 0; }
33
34body {
35 background: var(--bg);
36 color: var(--text);
37 font-family: 'Crimson Text', Georgia, serif;
38 font-size: 17px;
39 line-height: 1.6;
40 height: 100vh;
41 overflow: hidden;
42}
43
44/* ── CREATION SCREEN ─────────────────────────────── */
45
46#creation-screen {
47 display: flex;
48 flex-direction: column;
49 align-items: center;
50 justify-content: flex-start;
51 height: 100vh;
52 overflow-y: auto;
53 padding: 40px 20px 60px;
54 background:
55 radial-gradient(ellipse at 50% 0%, rgba(107,79,187,0.18) 0%, transparent 60%),
56 radial-gradient(ellipse at 20% 80%, rgba(201,168,76,0.08) 0%, transparent 50%),
57 var(--bg);
58}
59
60.creation-title {
61 font-family: 'Cinzel', serif;
62 font-size: 3rem;
63 font-weight: 700;
64 color: var(--gold-light);
65 text-align: center;
66 letter-spacing: 0.08em;
67 text-shadow: 0 0 40px rgba(201,168,76,0.4), 0 2px 8px rgba(0,0,0,0.8);
68 margin-bottom: 6px;
69}
70
71.creation-sub {
72 color: var(--text-dim);
73 font-style: italic;
74 font-size: 1.1rem;
75 text-align: center;
76 margin-bottom: 40px;
77 letter-spacing: 0.05em;
78}
79
80.creation-section {
81 width: 100%;
82 max-width: 700px;
83 margin-bottom: 30px;
84}
85
86.section-label {
87 font-family: 'Cinzel', serif;
88 font-size: 0.8rem;
89 letter-spacing: 0.15em;
90 color: var(--gold);
91 text-transform: uppercase;
92 margin-bottom: 12px;
93 padding-bottom: 6px;
94 border-bottom: 1px solid var(--border-gold);
95}
96
97.selection-grid {
98 display: grid;
99 grid-template-columns: repeat(5, 1fr);
100 gap: 8px;
101}
102
103.selection-grid.class-grid {
104 grid-template-columns: repeat(5, 1fr);
105}
106
107.sel-card {
108 display: flex;
109 flex-direction: column;
110 align-items: center;
111 gap: 4px;
112 padding: 12px 8px;
113 background: var(--bg-card);
114 border: 1px solid var(--border);
115 border-radius: 6px;
116 cursor: pointer;
117 transition: all 0.15s;
118 user-select: none;
119}
120
121.sel-card:hover {
122 border-color: var(--gold-dim);
123 background: #1e1e30;
124}
125
126.sel-card.selected {
127 border-color: var(--gold);
128 background: #1e1a0a;
129 box-shadow: 0 0 12px rgba(201,168,76,0.2);
130}
131
132.sel-card .icon { font-size: 1.6rem; }
133.sel-card .name {
134 font-family: 'Cinzel', serif;
135 font-size: 0.65rem;
136 letter-spacing: 0.05em;
137 color: var(--text-dim);
138 text-align: center;
139}
140
141.sel-card.selected .name { color: var(--gold-light); }
142
143.text-fields {
144 display: grid;
145 grid-template-columns: 1fr 1fr;
146 gap: 16px;
147}
148
149.field-group label {
150 display: block;
151 font-family: 'Cinzel', serif;
152 font-size: 0.7rem;
153 letter-spacing: 0.12em;
154 color: var(--gold);
155 text-transform: uppercase;
156 margin-bottom: 6px;
157}
158
159.field-group input {
160 width: 100%;
161 background: var(--bg-input);
162 border: 1px solid var(--border);
163 border-radius: 4px;
164 color: var(--text-bright);
165 font-family: 'Crimson Text', serif;
166 font-size: 1rem;
167 padding: 10px 14px;
168 outline: none;
169 transition: border-color 0.15s;
170}
171
172.field-group input:focus { border-color: var(--gold-dim); }
173.field-group input::placeholder { color: var(--text-dim); font-style: italic; }
174
175.begin-btn {
176 display: block;
177 width: 100%;
178 max-width: 700px;
179 padding: 16px;
180 font-family: 'Cinzel', serif;
181 font-size: 1.1rem;
182 font-weight: 600;
183 letter-spacing: 0.12em;
184 color: #0d0d1a;
185 background: linear-gradient(135deg, var(--gold) 0%, var(--gold-light) 50%, var(--gold) 100%);
186 border: none;
187 border-radius: 6px;
188 cursor: pointer;
189 transition: all 0.2s;
190 text-transform: uppercase;
191 box-shadow: 0 4px 20px rgba(201,168,76,0.3);
192 margin-top: 8px;
193}
194
195.begin-btn:hover:not(:disabled) {
196 transform: translateY(-1px);
197 box-shadow: 0 6px 28px rgba(201,168,76,0.45);
198}
199
200.begin-btn:disabled {
201 opacity: 0.4;
202 cursor: not-allowed;
203}
204
205/* ── GAME SCREEN ────────────────────────────────── */
206
207#game-screen {
208 display: none;
209 height: 100vh;
210 flex-direction: row;
211}
212
213#game-screen.visible { display: flex; }
214
215/* Chat panel */
216.chat-panel {
217 flex: 1;
218 display: flex;
219 flex-direction: column;
220 min-width: 0;
221 background: var(--bg-panel);
222 border-right: 1px solid var(--border);
223}
224
225.chat-header {
226 display: flex;
227 align-items: center;
228 justify-content: space-between;
229 padding: 12px 20px;
230 border-bottom: 1px solid var(--border);
231 background: var(--bg-card);
232 flex-shrink: 0;
233}
234
235.chat-header-title {
236 font-family: 'Cinzel', serif;
237 font-size: 0.85rem;
238 letter-spacing: 0.12em;
239 color: var(--gold);
240 text-transform: uppercase;
241}
242
243.new-game-btn {
244 font-family: 'Cinzel', serif;
245 font-size: 0.65rem;
246 letter-spacing: 0.1em;
247 color: var(--text-dim);
248 background: transparent;
249 border: 1px solid var(--border);
250 border-radius: 4px;
251 padding: 5px 12px;
252 cursor: pointer;
253 transition: all 0.15s;
254 text-transform: uppercase;
255}
256
257.new-game-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
258
259#messages {
260 flex: 1;
261 overflow-y: auto;
262 padding: 20px;
263 display: flex;
264 flex-direction: column;
265 gap: 16px;
266 scrollbar-width: thin;
267 scrollbar-color: var(--border) transparent;
268}
269
270#messages::-webkit-scrollbar { width: 4px; }
271#messages::-webkit-scrollbar-track { background: transparent; }
272#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
273
274/* Message styles */
275.msg { display: flex; flex-direction: column; gap: 4px; }
276
277.msg-dm {
278 border-left: 2px solid var(--gold-dim);
279 background: var(--bg-dm);
280 border-radius: 0 6px 6px 0;
281 padding: 14px 18px;
282}
283
284.msg-player {
285 align-self: flex-end;
286 max-width: 75%;
287 background: var(--bg-player);
288 border: 1px solid #2d1a40;
289 border-radius: 6px 6px 0 6px;
290 padding: 10px 16px;
291 color: #c0a8e0;
292 font-style: italic;
293}
294
295.msg-system {
296 align-self: center;
297 text-align: center;
298 color: var(--gold);
299 font-family: 'Cinzel', serif;
300 font-size: 0.78rem;
301 letter-spacing: 0.1em;
302 padding: 6px 16px;
303 border: 1px solid var(--border-gold);
304 border-radius: 20px;
305 background: rgba(201,168,76,0.05);
306}
307
308.msg-dm p { margin-bottom: 10px; }
309.msg-dm p:last-child { margin-bottom: 0; }
310.msg-dm .options { margin-top: 14px; display: flex; flex-direction: column; gap: 6px; }
311.msg-dm .option {
312 display: flex;
313 gap: 10px;
314 padding: 8px 12px;
315 background: rgba(201,168,76,0.06);
316 border: 1px solid var(--border-gold);
317 border-radius: 4px;
318 cursor: pointer;
319 transition: all 0.15s;
320 align-items: flex-start;
321}
322.msg-dm .option:hover { background: rgba(201,168,76,0.12); border-color: var(--gold-dim); }
323.msg-dm .option .opt-num {
324 font-family: 'Cinzel', serif;
325 font-size: 0.8rem;
326 color: var(--gold);
327 font-weight: 600;
328 min-width: 18px;
329 margin-top: 1px;
330}
331.msg-dm .option .opt-text { color: var(--text-bright); }
332
333.typing-dots {
334 display: flex;
335 gap: 5px;
336 padding: 8px 0 4px;
337 align-items: center;
338}
339.typing-dots span {
340 width: 6px; height: 6px;
341 background: var(--gold-dim);
342 border-radius: 50%;
343 animation: dot-pulse 1.4s infinite ease-in-out;
344}
345.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
346.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
347
348@keyframes dot-pulse {
349 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
350 40% { opacity: 1; transform: scale(1); }
351}
352
353/* Quick actions */
354.quick-actions {
355 display: flex;
356 gap: 8px;
357 padding: 10px 20px;
358 border-top: 1px solid var(--border);
359 flex-wrap: wrap;
360 flex-shrink: 0;
361}
362
363.quick-btn {
364 font-family: 'Crimson Text', serif;
365 font-size: 0.88rem;
366 color: var(--text-dim);
367 background: var(--bg-card);
368 border: 1px solid var(--border);
369 border-radius: 4px;
370 padding: 5px 12px;
371 cursor: pointer;
372 transition: all 0.15s;
373 white-space: nowrap;
374}
375.quick-btn:hover:not(:disabled) { border-color: var(--gold-dim); color: var(--text); }
376.quick-btn:disabled { opacity: 0.4; cursor: not-allowed; }
377
378/* Input area */
379.input-area {
380 display: flex;
381 gap: 10px;
382 padding: 14px 20px;
383 border-top: 1px solid var(--border);
384 background: var(--bg-card);
385 flex-shrink: 0;
386 align-items: flex-end;
387}
388
389#player-input {
390 flex: 1;
391 background: var(--bg-input);
392 border: 1px solid var(--border);
393 border-radius: 6px;
394 color: var(--text-bright);
395 font-family: 'Crimson Text', serif;
396 font-size: 1rem;
397 padding: 10px 14px;
398 outline: none;
399 resize: none;
400 min-height: 44px;
401 max-height: 120px;
402 line-height: 1.5;
403 transition: border-color 0.15s;
404}
405#player-input:focus { border-color: var(--gold-dim); }
406#player-input::placeholder { color: var(--text-dim); font-style: italic; }
407
408#send-btn {
409 font-family: 'Cinzel', serif;
410 font-size: 0.8rem;
411 font-weight: 600;
412 letter-spacing: 0.08em;
413 color: #0d0d1a;
414 background: var(--gold);
415 border: none;
416 border-radius: 6px;
417 padding: 10px 20px;
418 cursor: pointer;
419 transition: all 0.15s;
420 white-space: nowrap;
421 align-self: flex-end;
422}
423#send-btn:hover:not(:disabled) { background: var(--gold-light); }
424#send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
425
426/* ── SIDEBAR ────────────────────────────────────── */
427
428.sidebar {
429 width: 300px;
430 min-width: 300px;
431 display: flex;
432 flex-direction: column;
433 background: var(--bg-sidebar);
434 overflow-y: auto;
435 scrollbar-width: thin;
436 scrollbar-color: var(--border) transparent;
437}
438
439.sidebar::-webkit-scrollbar { width: 3px; }
440.sidebar::-webkit-scrollbar-thumb { background: var(--border); }
441
442.sidebar-section {
443 padding: 16px;
444 border-bottom: 1px solid var(--border);
445}
446
447.sidebar-title {
448 font-family: 'Cinzel', serif;
449 font-size: 0.65rem;
450 letter-spacing: 0.15em;
451 color: var(--gold);
452 text-transform: uppercase;
453 margin-bottom: 12px;
454}
455
456/* Character sheet */
457.char-portrait {
458 display: flex;
459 align-items: center;
460 gap: 12px;
461 margin-bottom: 12px;
462}
463
464.char-icon {
465 font-size: 2.5rem;
466 width: 52px;
467 height: 52px;
468 display: flex;
469 align-items: center;
470 justify-content: center;
471 background: var(--bg-card);
472 border: 1px solid var(--border);
473 border-radius: 8px;
474 flex-shrink: 0;
475}
476
477.char-identity { flex: 1; min-width: 0; }
478
479.char-name {
480 font-family: 'Cinzel', serif;
481 font-size: 0.95rem;
482 color: var(--text-bright);
483 font-weight: 600;
484 white-space: nowrap;
485 overflow: hidden;
486 text-overflow: ellipsis;
487}
488
489.char-race-class {
490 font-size: 0.82rem;
491 color: var(--text-dim);
492 font-style: italic;
493}
494
495/* Character sentence (creation screen) */
496.char-sentence {
497 font-family: 'Cinzel', serif;
498 font-size: 0.85rem;
499 color: var(--text-dim);
500 text-align: center;
501 margin: 0 0 16px;
502 min-height: 1.4em;
503 letter-spacing: 0.04em;
504}
505.char-sentence .picked { color: var(--gold-light); }
506
507/* Compact grid for descriptors/foci (20 items) */
508.selection-grid.compact-grid {
509 grid-template-columns: repeat(5, 1fr);
510}
511.selection-grid.compact-grid .sel-card {
512 padding: 8px 4px;
513}
514.selection-grid.compact-grid .sel-card .icon { font-size: 1.2rem; }
515.selection-grid.compact-grid .sel-card .name { font-size: 0.6rem; }
516
517/* Pool bars (Might/Speed/Intellect) */
518.pool-label-row {
519 display: flex;
520 align-items: baseline;
521 justify-content: space-between;
522 margin-bottom: 3px;
523}
524.pool-label {
525 font-family: 'Cinzel', serif;
526 font-size: 0.6rem;
527 letter-spacing: 0.1em;
528 color: var(--text-dim);
529 text-transform: uppercase;
530}
531.pool-value {
532 font-family: 'Cinzel', serif;
533 font-size: 0.78rem;
534 color: var(--text-bright);
535 font-weight: 600;
536}
537.pool-edge {
538 font-size: 0.62rem;
539 color: var(--text-dim);
540 margin-left: 4px;
541}
542.pool-bar-wrap { margin-bottom: 8px; }
543#might-fill { background: #c0392b; }
544#speed-fill { background: #27ae60; }
545#intellect-fill { background: #6b5fc7; }
546#might-fill.warning, #speed-fill.warning, #intellect-fill.warning { filter: brightness(0.75); }
547#might-fill.danger, #speed-fill.danger, #intellect-fill.danger {
548 filter: brightness(0.6);
549 animation: hp-pulse 1.5s infinite;
550}
551
552/* Damage track badge */
553.damage-track-row {
554 display: flex;
555 align-items: center;
556 gap: 8px;
557 margin: 6px 0 8px;
558 flex-wrap: wrap;
559}
560.dt-badge {
561 display: inline-flex;
562 align-items: center;
563 gap: 5px;
564 font-family: 'Cinzel', serif;
565 font-size: 0.62rem;
566 letter-spacing: 0.1em;
567 text-transform: uppercase;
568 padding: 2px 8px;
569 border-radius: 10px;
570 border: 1px solid;
571}
572.dt-badge.hale { color: #27ae60; border-color: #27ae60; background: rgba(39,174,96,0.08); }
573.dt-badge.impaired { color: #f39c12; border-color: #f39c12; background: rgba(243,156,18,0.1); }
574.dt-badge.debilitated { color: #e74c3c; border-color: #e74c3c; background: rgba(231,76,60,0.1); animation: hp-pulse 1.5s infinite; }
575.dt-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
576.xp-tier-row {
577 display: flex;
578 align-items: center;
579 justify-content: space-between;
580 font-family: 'Cinzel', serif;
581 font-size: 0.72rem;
582 color: var(--text-dim);
583}
584.xp-tier-row .val { color: var(--gold); font-weight: 600; }
585
586/* Cyphers section */
587.cypher-entry {
588 border: 1px solid var(--border);
589 border-radius: 5px;
590 padding: 7px 9px;
591 margin-bottom: 6px;
592 background: var(--bg-card);
593}
594.cypher-name {
595 font-family: 'Cinzel', serif;
596 font-size: 0.68rem;
597 color: var(--gold-light);
598 margin-bottom: 2px;
599}
600.cypher-effect {
601 font-size: 0.75rem;
602 color: var(--text-dim);
603 line-height: 1.4;
604}
605.cypher-use-btn {
606 margin-top: 5px;
607 font-size: 0.65rem;
608 font-family: 'Cinzel', serif;
609 background: none;
610 border: 1px solid var(--border);
611 border-radius: 3px;
612 color: var(--text-dim);
613 cursor: pointer;
614 padding: 2px 8px;
615}
616.cypher-use-btn:hover { border-color: var(--gold-dim); color: var(--gold); }
617.cypher-limit-warn { color: #d97706; font-size: 0.72rem; }
618
619/* Abilities section */
620.ability-entry { border: 1px solid var(--border); border-radius: 5px; padding: 6px 9px; margin-bottom: 5px; background: var(--bg-card); }
621.ability-name { display:block; font-family:'Cinzel',serif; font-size:0.68rem; color:var(--gold-light); margin-bottom:2px; }
622.ability-cost { color:var(--text-dim); font-weight:400; }
623.ability-desc { display:block; font-size:0.75rem; color:var(--text-dim); line-height:1.4; }
624.skill-entry { display:flex; align-items:center; justify-content:space-between; padding:4px 9px; margin-bottom:4px; border-radius:4px; background:var(--bg-card); border:1px solid var(--border); }
625.skill-name { font-size:0.78rem; color:var(--text-main); }
626.skill-rank { font-size:0.65rem; font-weight:700; letter-spacing:0.04em; padding:2px 6px; border-radius:3px; text-transform:uppercase; }
627.skill-rank.trained { background:#14532d; color:#4ade80; }
628.skill-rank.specialized { background:#713f12; color:#fbbf24; }
629.skill-rank.inability { background:#450a0a; color:#f87171; }
630
631/* Advancement row */
632.adv-row { display:flex; align-items:center; gap:8px; margin:4px 0 6px; flex-wrap:wrap; }
633.adv-dots { display:flex; gap:4px; }
634.adv-dot { width:9px; height:9px; border-radius:50%; border:1px solid var(--border); background:transparent; transition:background 0.2s; }
635.adv-dot.done { background:var(--gold); border-color:var(--gold); }
636.adv-label { font-family:'Cinzel',serif; font-size:0.6rem; color:var(--text-dim); flex:1; white-space:nowrap; }
637.spend-xp-btn { font-family:'Cinzel',serif; font-size:0.6rem; background:none; border:1px solid var(--border); border-radius:3px; color:var(--text-dim); cursor:pointer; padding:2px 7px; white-space:nowrap; }
638.spend-xp-btn:not(:disabled):hover { border-color:var(--gold-dim); color:var(--gold); }
639.spend-xp-btn:disabled { opacity:0.4; cursor:default; }
640
641/* Recovery section */
642.rec-pips { display:flex; gap:6px; align-items:center; margin-bottom:8px; }
643.rec-pip { width:11px; height:11px; border-radius:50%; border:1px solid var(--border); background:transparent; transition:background 0.2s; }
644.rec-pip.used { background:var(--gold-dim); border-color:var(--gold); }
645.recovery-roll-btn { width:100%; font-family:'Cinzel',serif; font-size:0.7rem; background:none; border:1px solid var(--border); border-radius:4px; color:var(--text-dim); cursor:pointer; padding:6px 10px; letter-spacing:0.04em; }
646.recovery-roll-btn:not(:disabled):hover { border-color:var(--gold-dim); color:var(--gold); }
647.recovery-roll-btn:disabled { opacity:0.4; cursor:default; }
648
649/* Setting selector */
650.setting-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; }
651.setting-btn { font-family:'Cinzel',serif; font-size:0.65rem; letter-spacing:0.05em; background:var(--bg-card); border:1px solid var(--border); border-radius:4px; color:var(--text-dim); cursor:pointer; padding:6px 12px; flex:1; min-width:80px; transition:border-color 0.15s; }
652.setting-btn:hover { border-color:var(--gold-dim); color:var(--text-bright); }
653.setting-btn.selected { border-color:var(--gold); color:var(--gold); background:rgba(212,175,55,0.08); }
654
655/* Stat row */
656.stat-row {
657 display: flex;
658 align-items: center;
659 justify-content: space-between;
660 margin-bottom: 6px;
661}
662
663.stat-label {
664 font-family: 'Cinzel', serif;
665 font-size: 0.65rem;
666 letter-spacing: 0.1em;
667 color: var(--text-dim);
668 text-transform: uppercase;
669}
670
671.stat-value {
672 font-family: 'Cinzel', serif;
673 font-size: 0.85rem;
674 color: var(--text-bright);
675 font-weight: 600;
676}
677
678.bar-track {
679 width: 100%;
680 height: 8px;
681 background: var(--bg-card);
682 border-radius: 4px;
683 overflow: hidden;
684 margin-bottom: 10px;
685}
686
687.bar-fill {
688 height: 100%;
689 border-radius: 4px;
690 transition: width 0.4s ease, background 0.4s ease;
691}
692
693#hp-fill { background: var(--hp-full); }
694#hp-fill.warning { background: var(--hp-med); }
695#hp-fill.danger {
696 background: var(--hp-low);
697 animation: hp-pulse 1.5s infinite;
698}
699
700@keyframes hp-pulse {
701 0%, 100% { opacity: 1; }
702 50% { opacity: 0.6; }
703}
704
705#xp-fill { background: var(--accent); }
706
707.gold-row {
708 display: flex;
709 align-items: center;
710 gap: 6px;
711 font-family: 'Cinzel', serif;
712 font-size: 0.82rem;
713 color: var(--gold);
714}
715
716/* Inventory */
717#inventory-list {
718 list-style: none;
719 display: flex;
720 flex-direction: column;
721 gap: 4px;
722 max-height: 180px;
723 overflow-y: auto;
724}
725
726#inventory-list::-webkit-scrollbar { width: 2px; }
727#inventory-list::-webkit-scrollbar-thumb { background: var(--border); }
728
729#inventory-list li {
730 font-size: 0.88rem;
731 color: var(--text);
732 padding: 3px 0;
733 border-bottom: 1px solid var(--border);
734 display: flex;
735 align-items: center;
736 gap: 6px;
737}
738
739#inventory-list li::before {
740 content: '◆';
741 color: var(--gold-dim);
742 font-size: 0.5rem;
743 flex-shrink: 0;
744}
745
746/* Dice roller */
747.dice-types {
748 display: grid;
749 grid-template-columns: repeat(7, 1fr);
750 gap: 4px;
751 margin-bottom: 10px;
752}
753
754.die-btn {
755 font-family: 'Cinzel', serif;
756 font-size: 0.68rem;
757 font-weight: 600;
758 color: var(--text-dim);
759 background: var(--bg-card);
760 border: 1px solid var(--border);
761 border-radius: 4px;
762 padding: 6px 2px;
763 cursor: pointer;
764 transition: all 0.15s;
765 text-align: center;
766}
767
768.die-btn:hover { border-color: var(--gold-dim); color: var(--text); }
769.die-btn.selected { border-color: var(--gold); color: var(--gold); background: rgba(201,168,76,0.08); }
770
771.dice-controls {
772 display: flex;
773 gap: 8px;
774 align-items: center;
775 margin-bottom: 10px;
776}
777
778.dice-count-label {
779 font-family: 'Cinzel', serif;
780 font-size: 0.65rem;
781 color: var(--text-dim);
782 text-transform: uppercase;
783 letter-spacing: 0.08em;
784 white-space: nowrap;
785}
786
787#dice-count {
788 width: 52px;
789 background: var(--bg-input);
790 border: 1px solid var(--border);
791 border-radius: 4px;
792 color: var(--text-bright);
793 font-family: 'Cinzel', serif;
794 font-size: 0.9rem;
795 padding: 5px 8px;
796 text-align: center;
797 outline: none;
798}
799
800#dice-count:focus { border-color: var(--gold-dim); }
801
802#roll-btn {
803 flex: 1;
804 font-family: 'Cinzel', serif;
805 font-size: 0.75rem;
806 font-weight: 600;
807 letter-spacing: 0.08em;
808 color: #0d0d1a;
809 background: var(--gold);
810 border: none;
811 border-radius: 4px;
812 padding: 7px 12px;
813 cursor: pointer;
814 transition: background 0.15s;
815}
816
817#roll-btn:hover:not(:disabled) { background: var(--gold-light); }
818#roll-btn:disabled { opacity: 0.5; cursor: not-allowed; }
819
820#dice-result {
821 font-family: 'Cinzel', serif;
822 font-size: 1.1rem;
823 font-weight: 700;
824 text-align: center;
825 color: var(--gold-light);
826 min-height: 24px;
827 letter-spacing: 0.05em;
828}
829
830#dice-result-detail {
831 font-size: 0.78rem;
832 text-align: center;
833 color: var(--text-dim);
834 min-height: 18px;
835 font-style: italic;
836}
837
838/* Level up toast */
839#levelup-toast {
840 display: none;
841 position: fixed;
842 top: 50%;
843 left: 50%;
844 transform: translate(-50%, -50%);
845 z-index: 100;
846 background: linear-gradient(135deg, #1a1800, #2a2200);
847 border: 2px solid var(--gold);
848 border-radius: 12px;
849 padding: 32px 48px;
850 text-align: center;
851 box-shadow: 0 0 60px rgba(201,168,76,0.4), 0 20px 60px rgba(0,0,0,0.8);
852 animation: toast-appear 0.3s ease;
853}
854
855@keyframes toast-appear {
856 from { opacity: 0; transform: translate(-50%, -50%) scale(0.8); }
857 to { opacity: 1; transform: translate(-50%, -50%) scale(1); }
858}
859
860#levelup-toast .toast-icon { font-size: 3rem; margin-bottom: 8px; }
861#levelup-toast .toast-title {
862 font-family: 'Cinzel', serif;
863 font-size: 1.6rem;
864 font-weight: 700;
865 color: var(--gold-light);
866 text-shadow: 0 0 20px rgba(201,168,76,0.6);
867 margin-bottom: 4px;
868}
869#levelup-toast .toast-sub {
870 font-style: italic;
871 color: var(--text-dim);
872 margin-bottom: 20px;
873}
874.attribution {
875 font-size: 0.72rem;
876 color: var(--text-dim);
877 text-align: center;
878}
879.attribution a {
880 color: var(--text-dim);
881 text-decoration: underline;
882 text-underline-offset: 2px;
883}
884.attribution a:hover { color: var(--gold); }
885
886/* ── WORLD NOTES ────────────────────────────────── */
887
888.world-notes-section {
889 border: 2px solid #d97706;
890 border-radius: 8px;
891 padding: 16px;
892 background: rgba(217, 119, 6, 0.06);
893 margin: 20px 0;
894}
895.world-notes-label {
896 font-family: 'Cinzel', serif;
897 font-size: 0.7rem;
898 letter-spacing: 0.14em;
899 text-transform: uppercase;
900 color: #f59e0b;
901 margin-bottom: 6px;
902}
903.world-notes-desc {
904 font-size: 0.78rem;
905 color: var(--text-dim);
906 margin-bottom: 10px;
907 line-height: 1.5;
908}
909.wn-textarea {
910 width: 100%;
911 min-height: 90px;
912 background: rgba(0,0,0,0.35);
913 border: 1px solid #b45309;
914 border-radius: 5px;
915 color: var(--text);
916 font-size: 0.82rem;
917 font-family: 'EB Garamond', serif;
918 padding: 8px 10px;
919 resize: vertical;
920 box-sizing: border-box;
921 line-height: 1.5;
922}
923.wn-textarea:focus { outline: none; border-color: #f59e0b; }
924.wn-textarea::placeholder { color: #6b5a3a; font-style: italic; }
925.wn-save-btn {
926 margin-top: 10px;
927 width: 100%;
928 padding: 8px;
929 background: #92400e;
930 border: 1px solid #d97706;
931 border-radius: 5px;
932 color: #fde68a;
933 font-family: 'Cinzel', serif;
934 font-size: 0.72rem;
935 letter-spacing: 0.1em;
936 cursor: pointer;
937 transition: background 0.15s;
938}
939.wn-save-btn:hover { background: #b45309; }
940.wn-save-btn:disabled { opacity: 0.4; cursor: default; }
941
942/* Sidebar world notes — overrides */
943.sidebar-section.wn-sidebar {
944 border-bottom: 1px solid #7c3b00;
945 background: rgba(217, 119, 6, 0.05);
946 outline: 2px solid #d97706;
947 outline-offset: -2px;
948}
949.sidebar-section.wn-sidebar .sidebar-title { color: #f59e0b; }
950
951/* Chat event banner when notes are applied mid-game */
952.world-notes-event {
953 margin: 12px 0;
954 padding: 12px 14px;
955 border: 2px solid #d97706;
956 border-radius: 8px;
957 background: rgba(217, 119, 6, 0.08);
958}
959.wne-title {
960 font-family: 'Cinzel', serif;
961 font-size: 0.7rem;
962 letter-spacing: 0.12em;
963 text-transform: uppercase;
964 color: #f59e0b;
965 margin-bottom: 6px;
966}
967.wne-body {
968 font-size: 0.82rem;
969 color: #d4b896;
970 white-space: pre-wrap;
971 line-height: 1.5;
972}
973
974/* ── SHARE MODAL ────────────────────────────────── */
975
976#share-modal {
977 display: none;
978 position: fixed;
979 inset: 0;
980 z-index: 400;
981 background: rgba(0,0,0,0.75);
982 align-items: center;
983 justify-content: center;
984}
985#share-modal.open { display: flex; }
986
987.share-inner {
988 background: linear-gradient(135deg, #14140a, #1e1c0a);
989 border: 1px solid var(--gold-dim);
990 border-radius: 10px;
991 padding: 28px 32px;
992 width: 480px;
993 max-width: 95vw;
994 box-shadow: 0 0 60px rgba(201,168,76,0.2), 0 20px 60px rgba(0,0,0,0.8);
995}
996
997.share-title {
998 font-family: 'Cinzel', serif;
999 font-size: 1.1rem;
1000 font-weight: 600;
1001 color: var(--gold-light);
1002 margin-bottom: 20px;
1003 letter-spacing: 0.06em;
1004}
1005
1006.share-row {
1007 display: flex;
1008 gap: 10px;
1009 align-items: flex-end;
1010 margin-bottom: 14px;
1011}
1012
1013.share-url-box {
1014 font-family: monospace;
1015 font-size: 0.75rem;
1016 background: var(--bg-input);
1017 border: 1px solid var(--border);
1018 border-radius: 4px;
1019 padding: 10px 12px;
1020 color: var(--text);
1021 word-break: break-all;
1022 margin-bottom: 6px;
1023 user-select: all;
1024 cursor: text;
1025}
1026
1027.share-note {
1028 font-size: 0.78rem;
1029 color: var(--text-dim);
1030 font-style: italic;
1031 margin-bottom: 14px;
1032}
1033
1034.share-btn {
1035 font-family: 'Cinzel', serif;
1036 font-size: 0.78rem;
1037 font-weight: 600;
1038 letter-spacing: 0.08em;
1039 cursor: pointer;
1040 border-radius: 4px;
1041 padding: 9px 18px;
1042 transition: all 0.15s;
1043}
1044
1045.share-btn-primary {
1046 color: #0d0d1a;
1047 background: var(--gold);
1048 border: none;
1049}
1050.share-btn-primary:hover { background: var(--gold-light); }
1051.share-btn-secondary {
1052 color: var(--text-dim);
1053 background: transparent;
1054 border: 1px solid var(--border);
1055}
1056.share-btn-secondary:hover { border-color: var(--gold-dim); color: var(--text); }
1057
1058.share-link-trigger {
1059 font-family: 'Cinzel', serif;
1060 font-size: 0.65rem;
1061 letter-spacing: 0.1em;
1062 color: var(--text-dim);
1063 background: transparent;
1064 border: 1px solid var(--border);
1065 border-radius: 4px;
1066 padding: 5px 12px;
1067 cursor: pointer;
1068 transition: all 0.15s;
1069 text-transform: uppercase;
1070 margin-top: 10px;
1071}
1072.share-link-trigger:hover { border-color: var(--gold-dim); color: var(--gold); }
1073
1074/* ── SETUP MODAL ────────────────────────────────── */
1075
1076#setup-modal {
1077 display: none;
1078 position: fixed;
1079 inset: 0;
1080 z-index: 300;
1081 background: rgba(0,0,0,0.92);
1082 align-items: center;
1083 justify-content: center;
1084 padding: 20px;
1085}
1086#setup-modal.open { display: flex; }
1087
1088.setup-inner {
1089 background: linear-gradient(135deg, #14140a, #1e1c0a);
1090 border: 1px solid var(--gold-dim);
1091 border-radius: 12px;
1092 padding: 36px 40px;
1093 width: 480px;
1094 max-width: 100%;
1095 max-height: 90vh;
1096 overflow-y: auto;
1097 box-shadow: 0 0 80px rgba(201,168,76,0.15), 0 20px 60px rgba(0,0,0,0.8);
1098}
1099
1100.setup-logo {
1101 font-family: 'Cinzel', serif;
1102 font-size: 1.4rem;
1103 font-weight: 700;
1104 color: var(--gold-light);
1105 text-align: center;
1106 letter-spacing: 0.06em;
1107 margin-bottom: 6px;
1108}
1109
1110.setup-sub {
1111 font-style: italic;
1112 color: var(--text-dim);
1113 font-size: 0.95rem;
1114 text-align: center;
1115 margin-bottom: 24px;
1116}
1117
1118/* ── VERSION MISMATCH MODAL ─────────────────────── */
1119
1120#version-modal {
1121 display: none;
1122 position: fixed;
1123 inset: 0;
1124 z-index: 500;
1125 background: rgba(0,0,0,0.85);
1126 align-items: center;
1127 justify-content: center;
1128}
1129#version-modal.open { display: flex; }
1130.version-inner {
1131 background: linear-gradient(135deg, #0e0e1a, #141420);
1132 border: 2px solid #d97706;
1133 border-radius: 10px;
1134 padding: 32px;
1135 width: 480px;
1136 max-width: 95vw;
1137 box-shadow: 0 0 60px rgba(217,119,6,0.2), 0 20px 60px rgba(0,0,0,0.8);
1138}
1139.version-title {
1140 font-family: 'Cinzel', serif;
1141 font-size: 1rem;
1142 font-weight: 600;
1143 color: #f59e0b;
1144 margin-bottom: 12px;
1145 letter-spacing: 0.06em;
1146}
1147.version-body {
1148 font-size: 0.9rem;
1149 color: var(--text-dim);
1150 line-height: 1.6;
1151 margin-bottom: 24px;
1152}
1153.version-actions { display: flex; gap: 10px; flex-wrap: wrap; }
1154
1155/* ── ADVANCEMENT MODAL ──────────────────────────── */
1156
1157#advancement-modal {
1158 display: none; position: fixed; inset: 0; z-index: 400;
1159 background: rgba(0,0,0,0.75); align-items: center; justify-content: center;
1160}
1161#advancement-modal.open { display: flex; }
1162.adv-inner {
1163 background: linear-gradient(135deg, #0a0a14, #0e0e1e);
1164 border: 1px solid var(--gold-dim); border-radius: 10px;
1165 padding: 28px 32px; width: 440px; max-width: 95vw;
1166 box-shadow: 0 0 60px rgba(201,168,76,0.15), 0 20px 60px rgba(0,0,0,0.8);
1167}
1168.adv-title { font-family:'Cinzel',serif; font-size:1rem; font-weight:600; color:var(--gold-light); margin-bottom:18px; letter-spacing:0.06em; }
1169.adv-option { border:1px solid var(--border); border-radius:6px; padding:10px 14px; margin-bottom:8px; cursor:pointer; color:var(--text-dim); font-size:0.85rem; transition:border-color 0.15s,color 0.15s; }
1170.adv-option:hover { border-color:var(--gold-dim); color:var(--text); }
1171.adv-option.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.08); }
1172.adv-option.unavailable { opacity:0.35; cursor:default; pointer-events:none; }
1173.adv-stat-btns { display:flex; gap:8px; margin-top:10px; }
1174.adv-stat-btn { flex:1; font-family:'Cinzel',serif; font-size:0.72rem; border:1px solid var(--border); border-radius:4px; padding:7px 4px; background:none; color:var(--text-dim); cursor:pointer; letter-spacing:0.04em; }
1175.adv-stat-btn:hover { border-color:var(--gold-dim); color:var(--gold); }
1176.adv-stat-btn.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.1); }
1177.adv-skill-input { width:100%; box-sizing:border-box; background:var(--bg-input); border:1px solid var(--border); border-radius:4px; padding:8px 12px; color:var(--text); font-size:0.85rem; margin-top:10px; }
1178.adv-skill-input:focus { outline:none; border-color:var(--gold-dim); }
1179.adv-skill-list { margin-top:10px; display:flex; flex-direction:column; gap:6px; max-height:160px; overflow-y:auto; }
1180.adv-skill-item { border:1px solid var(--border); border-radius:4px; padding:7px 12px; cursor:pointer; color:var(--text-dim); font-size:0.82rem; display:flex; justify-content:space-between; align-items:center; }
1181.adv-skill-item:hover { border-color:var(--gold-dim); color:var(--text); }
1182.adv-skill-item.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.08); }
1183.adv-footer { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; }
1184.adv-sub-label { font-size:0.8rem; color:var(--text-dim); margin-bottom:2px; }
1185
1186/* ── REPORT MODAL ───────────────────────────────── */
1187
1188#report-modal {
1189 display: none;
1190 position: fixed;
1191 inset: 0;
1192 z-index: 200;
1193 background: rgba(0,0,0,0.75);
1194 align-items: center;
1195 justify-content: center;
1196}
1197#report-modal.open { display: flex; }
1198
1199.report-inner {
1200 background: linear-gradient(135deg, #0e0e1a, #141420);
1201 border: 1px solid rgba(107,79,187,0.4);
1202 border-radius: 10px;
1203 padding: 28px 32px;
1204 width: 520px;
1205 max-width: 95vw;
1206 max-height: 88vh;
1207 overflow-y: auto;
1208 box-shadow: 0 0 60px rgba(107,79,187,0.15), 0 20px 60px rgba(0,0,0,0.8);
1209}
1210
1211.report-title {
1212 font-family: 'Cinzel', serif;
1213 font-size: 1.1rem;
1214 font-weight: 600;
1215 color: var(--text-bright);
1216 margin-bottom: 20px;
1217 letter-spacing: 0.06em;
1218}
1219
1220.report-debug-box {
1221 font-family: monospace;
1222 font-size: 0.7rem;
1223 line-height: 1.5;
1224 background: var(--bg-input);
1225 border: 1px solid var(--border);
1226 border-radius: 4px;
1227 padding: 10px 12px;
1228 color: var(--text-dim);
1229 white-space: pre-wrap;
1230 word-break: break-all;
1231 max-height: 180px;
1232 overflow-y: auto;
1233 margin-bottom: 16px;
1234}
1235
1236.report-actions {
1237 display: flex;
1238 gap: 8px;
1239 flex-wrap: wrap;
1240 margin-top: 4px;
1241}
1242
1243/* access code row */
1244.access-code-label {
1245 font-family: 'Cinzel', serif;
1246 font-size: 0.7rem;
1247 letter-spacing: 0.12em;
1248 color: var(--gold);
1249 text-transform: uppercase;
1250 margin-bottom: 6px;
1251}
1252
1253#levelup-toast button {
1254 font-family: 'Cinzel', serif;
1255 font-size: 0.85rem;
1256 letter-spacing: 0.1em;
1257 color: #0d0d1a;
1258 background: var(--gold);
1259 border: none;
1260 border-radius: 6px;
1261 padding: 10px 28px;
1262 cursor: pointer;
1263}
1264</style>
1265</head>
1266<body>
1267
1268<!-- ── CHARACTER CREATION ───────────────────── -->
1269<div id="creation-screen" style="display:none">
1270 <div class="creation-title"><img src="dice.png" alt="" style="height:0.85em;vertical-align:middle;margin-right:0.2em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div>
1271 <div class="creation-sub">Powered by Claude — your story begins here</div>
1272 <p style="font-size:0.85rem;color:var(--text-dim);max-width:560px;margin:10px auto 0;line-height:1.6;text-align:center">
1273 An AI-powered solo tabletop RPG running entirely in your browser. Build your character below, then Claude becomes your Game Master — narration, rules, and consequences — for a full <a href="https://cyphersrd.quest" target="_blank" rel="noopener" style="color:var(--gold-dim)">Cypher System</a> adventure. Your API key never leaves your device. <a href="https://code.fuzzyworld.net/dm/-/blob/main/README.md" target="_blank" rel="noopener" style="color:var(--gold-dim)">README →</a>
1274 </p>
1275
1276 <p class="char-sentence" id="char-sentence">I am a <em>___</em> <em>___</em> who <em>___</em></p>
1277
1278 <div class="creation-section">
1279 <div class="section-label">Descriptor — who are you?</div>
1280 <div class="selection-grid compact-grid" id="descriptor-grid"></div>
1281 </div>
1282
1283 <div class="creation-section">
1284 <div class="section-label">Type — what do you do?</div>
1285 <div class="selection-grid" id="type-grid"></div>
1286 </div>
1287
1288 <div class="creation-section">
1289 <div class="section-label">Focus — what makes you special?</div>
1290 <div class="selection-grid compact-grid" id="focus-grid"></div>
1291 </div>
1292
1293 <div class="creation-section">
1294 <div class="section-label">Your Character</div>
1295 <div class="text-fields">
1296 <div class="field-group">
1297 <label>Character Name (optional)</label>
1298 <input type="text" id="char-name" placeholder="e.g. Lyra Dawnseeker" maxlength="40">
1299 </div>
1300 <div class="field-group">
1301 <label>Adventure Theme (optional)</label>
1302 <input type="text" id="adventure-theme" placeholder="e.g. haunted sea, ancient ruins" maxlength="60">
1303 </div>
1304 </div>
1305 </div>
1306
1307 <div class="creation-section">
1308 <div class="section-label">Setting</div>
1309 <div class="setting-row" id="setting-row">
1310 <button class="setting-btn selected" data-setting="fantasy">⚔️ Fantasy</button>
1311 <button class="setting-btn" data-setting="numenera">🔮 Numenera</button>
1312 <button class="setting-btn" data-setting="modern">🌆 Modern</button>
1313 <button class="setting-btn" data-setting="scifi">🚀 Sci-Fi</button>
1314 </div>
1315 </div>
1316
1317 <div class="world-notes-section">
1318 <div class="world-notes-label">📜 World Notes <span style="font-size:0.65rem;opacity:0.6;text-transform:none;letter-spacing:0;font-family:sans-serif">(optional)</span></div>
1319 <p class="world-notes-desc">Background lore, house rules, world physics — anything Claude should treat as absolute truth. Written as GM, not player. Keep it out of the story itself.</p>
1320 <textarea class="wn-textarea" id="creation-world-notes" placeholder="e.g. Gravity is half of Earth normal. Magic draws from crystallised starlight and leaves frostburn scars. The empire collapsed 50 years ago and is now a patchwork of city-states…"></textarea>
1321 </div>
1322
1323 <button class="begin-btn" id="begin-btn" disabled>Begin Adventure</button>
1324
1325 <p style="margin-top:12px;text-align:center">
1326 <button id="change-key-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0" type="button">⚙ Change API key</button>
1327 </p>
1328
1329 <p class="attribution">
1330 <a href="https://code.fuzzyworld.net/dm/" target="_blank" rel="noopener">Source &amp; README</a> ·
1331 <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> from
1332 <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a>
1333 </p>
1334</div>
1335
1336<!-- ── GAME SCREEN ──────────────────────────── -->
1337<div id="game-screen">
1338
1339 <div class="chat-panel">
1340 <div class="chat-header">
1341 <div class="chat-header-title" id="header-title">Solo Dungeon Master</div>
1342 <div style="display:flex;gap:8px">
1343 <button class="new-game-btn" id="report-btn">🐛 Report</button>
1344 <button class="new-game-btn" id="new-game-btn">✦ New Game</button>
1345 </div>
1346 </div>
1347
1348 <div id="messages"></div>
1349
1350 <div class="quick-actions" id="quick-actions">
1351 <button class="quick-btn" data-action="I take a moment to assess the situation — scanning for threats, opportunities, and anything unusual.">👁 Assess</button>
1352 <button class="quick-btn" data-action="I take a recovery roll to restore my pools. Please narrate a brief rest and tell me which pool benefits most.">🎲 Recovery roll</button>
1353 <button class="quick-btn" data-action="I check my equipment and cyphers, taking stock of what I have available.">🎒 Check gear</button>
1354 <button class="quick-btn" data-action="I carefully search the area for hidden things — secrets, traps, valuables, or clues.">🔍 Search</button>
1355 <button class="quick-btn" data-action="I try to talk my way through this — using whatever approach seems most likely to work.">💬 Talk</button>
1356 </div>
1357
1358 <div class="input-area">
1359 <textarea id="player-input" placeholder="What do you do?" rows="2"></textarea>
1360 <button id="send-btn">⚔ Send</button>
1361 </div>
1362 </div>
1363
1364 <div class="sidebar">
1365 <!-- Character Sheet -->
1366 <div class="sidebar-section">
1367 <div class="sidebar-title">Character Sheet</div>
1368 <div class="char-portrait">
1369 <div class="char-icon" id="char-icon">⚔️</div>
1370 <div class="char-identity">
1371 <div class="char-name" id="char-name-display">—</div>
1372 <div class="char-race-class" id="char-identity-sub">—</div>
1373 </div>
1374 </div>
1375
1376 <div class="pool-bar-wrap">
1377 <div class="pool-label-row">
1378 <span class="pool-label">Might <span class="pool-edge" id="might-edge"></span></span>
1379 <span class="pool-value" id="might-display">—</span>
1380 </div>
1381 <div class="bar-track"><div class="bar-fill" id="might-fill" style="width:100%"></div></div>
1382 </div>
1383
1384 <div class="pool-bar-wrap">
1385 <div class="pool-label-row">
1386 <span class="pool-label">Speed <span class="pool-edge" id="speed-edge"></span></span>
1387 <span class="pool-value" id="speed-display">—</span>
1388 </div>
1389 <div class="bar-track"><div class="bar-fill" id="speed-fill" style="width:100%"></div></div>
1390 </div>
1391
1392 <div class="pool-bar-wrap">
1393 <div class="pool-label-row">
1394 <span class="pool-label">Intellect <span class="pool-edge" id="intellect-edge"></span></span>
1395 <span class="pool-value" id="intellect-display">—</span>
1396 </div>
1397 <div class="bar-track"><div class="bar-fill" id="intellect-fill" style="width:100%"></div></div>
1398 </div>
1399
1400 <div class="damage-track-row">
1401 <span class="dt-badge hale" id="dt-badge"><span class="dt-dot"></span><span id="dt-label">Hale</span></span>
1402 <span class="xp-tier-row" style="flex:1;margin-left:8px">
1403 <span>XP <span class="val" id="xp-display">0</span></span>
1404 <span>Tier <span class="val" id="tier-display">I</span></span>
1405 </span>
1406 </div>
1407
1408 <div class="adv-row">
1409 <span class="adv-dots" id="adv-dots"></span>
1410 <span class="adv-label" id="adv-label">Steps to Tier II</span>
1411 <button class="spend-xp-btn" id="spend-xp-btn" disabled>Spend 4 XP</button>
1412 </div>
1413
1414 <div class="gold-row">
1415 <span>🪙</span>
1416 <span id="shins-display">10 Shins</span>
1417 </div>
1418 </div>
1419
1420 <!-- Cyphers -->
1421 <div class="sidebar-section">
1422 <div class="sidebar-title" id="cyphers-title">Cyphers (0/2)</div>
1423 <div id="cyphers-list"><span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">No cyphers yet.</span></div>
1424 </div>
1425
1426 <!-- Abilities -->
1427 <div class="sidebar-section">
1428 <div class="sidebar-title">Abilities</div>
1429 <div id="abilities-list"></div>
1430 </div>
1431
1432 <!-- Skills -->
1433 <div class="sidebar-section">
1434 <div class="sidebar-title">Skills</div>
1435 <div id="skills-list"></div>
1436 </div>
1437
1438 <!-- Equipment -->
1439 <div class="sidebar-section">
1440 <div class="sidebar-title">Equipment</div>
1441 <ul id="inventory-list"></ul>
1442 </div>
1443
1444 <!-- Recovery -->
1445 <div class="sidebar-section">
1446 <div class="sidebar-title">Recovery</div>
1447 <div class="rec-pips" id="rec-pips"></div>
1448 <button class="recovery-roll-btn" id="recovery-roll-btn" disabled>🎲 Recovery Roll (1d6 + Tier)</button>
1449 </div>
1450
1451 <!-- Session Cost -->
1452 <div class="sidebar-section">
1453 <div class="sidebar-title">Session Cost</div>
1454 <div class="stat-row">
1455 <span class="stat-label">Last turn</span>
1456 <span class="stat-value" id="cost-last-turn">—</span>
1457 </div>
1458 <div class="stat-row">
1459 <span class="stat-label">Total</span>
1460 <span class="stat-value" id="cost-total">$0.0000</span>
1461 </div>
1462 <p style="font-size:0.65rem;color:var(--text-dim);margin-top:4px;font-style:italic">Estimates · <a href="https://www.anthropic.com/pricing" target="_blank" rel="noopener" style="color:var(--text-dim)">verify pricing</a></p>
1463 </div>
1464
1465 <!-- Dice Roller -->
1466 <div class="sidebar-section">
1467 <div class="sidebar-title">Dice Roller</div>
1468 <div class="dice-types" id="dice-types"></div>
1469 <div class="dice-controls">
1470 <span class="dice-count-label">Count:</span>
1471 <input type="number" id="dice-count" value="1" min="1" max="10">
1472 <button id="roll-btn">🎲 Roll</button>
1473 </div>
1474 <div id="dice-result"></div>
1475 <div id="dice-result-detail"></div>
1476 <p class="attribution" style="margin-top:10px">
1477 <a href="https://code.fuzzyworld.net/dm/" target="_blank" rel="noopener">Source &amp; README</a> ·
1478 <a href="https://www.flaticon.com/authors/icongeek26" target="_blank" rel="noopener">Dice icon by Icongeek26</a> /
1479 <a href="https://www.flaticon.com/" target="_blank" rel="noopener">Flaticon</a>
1480 </p>
1481 </div>
1482
1483 <div class="sidebar-section wn-sidebar">
1484 <div class="sidebar-title">📜 World Notes</div>
1485 <p class="world-notes-desc" style="margin-bottom:8px">Meta context — world laws, lore, constraints — applied outside the narrative.</p>
1486 <textarea class="wn-textarea" id="world-notes-textarea" placeholder="e.g. Reduced gravity. Magic leaves frostburn scars…" style="min-height:80px"></textarea>
1487 <button class="wn-save-btn" id="world-notes-save-btn">Apply to World ↵</button>
1488 </div>
1489 </div>
1490</div>
1491
1492<!-- Version mismatch modal -->
1493<!-- Advancement modal -->
1494<div id="advancement-modal">
1495 <div class="adv-inner">
1496 <div class="adv-title">⚡ Spend 4 XP — Choose Advancement</div>
1497 <div id="adv-options">
1498 <div class="adv-option" data-opt="pool">📈 Increase a pool max by 4</div>
1499 <div class="adv-option" data-opt="edge">⚔️ Add +1 Edge to a stat</div>
1500 <div class="adv-option" data-opt="train">🎓 Train a new skill</div>
1501 <div class="adv-option" data-opt="specialize">⭐ Specialize in a trained skill</div>
1502 <div class="adv-option" data-opt="other">✨ Other improvement</div>
1503 </div>
1504 <div id="adv-sub"></div>
1505 <div class="adv-footer">
1506 <button class="share-btn share-btn-secondary" id="adv-cancel-btn">Cancel</button>
1507 <button class="share-btn" id="adv-confirm-btn" disabled>Confirm</button>
1508 </div>
1509 </div>
1510</div>
1511
1512<div id="version-modal">
1513 <div class="version-inner">
1514 <div class="version-title">📜 Game Format Updated</div>
1515 <p class="version-body">
1516 The app has been updated and your saved adventure uses an older format that can no longer be loaded.
1517 You can download your adventure log as a Markdown file before it's cleared, or start fresh right away.
1518 </p>
1519 <div class="version-actions">
1520 <button class="wn-save-btn" style="width:auto;padding:10px 20px" id="version-download-btn">⬇ Download adventure log</button>
1521 <button class="share-btn share-btn-secondary" id="version-discard-btn">Start fresh</button>
1522 </div>
1523 </div>
1524</div>
1525
1526<!-- Report Issue modal -->
1527<div id="report-modal">
1528 <div class="report-inner">
1529 <div class="report-title">🐛 Report an Issue</div>
1530
1531 <div class="field-group" style="margin-bottom:14px">
1532 <label>What went wrong? <span style="color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">(optional — describe what happened)</span></label>
1533 <textarea id="report-desc" rows="3" placeholder="e.g. The game froze after I chose option 2…" autocomplete="off"
1534 style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none;resize:vertical;margin-top:6px"></textarea>
1535 </div>
1536
1537 <div style="font-family:'Cinzel',serif;font-size:0.65rem;letter-spacing:0.12em;color:var(--text-dim);text-transform:uppercase;margin-bottom:6px">Debug info (included automatically)</div>
1538 <div class="report-debug-box" id="report-debug-box"></div>
1539
1540 <div class="report-actions">
1541 <button class="share-btn share-btn-primary" id="report-email-btn">📧 Email — no account needed</button>
1542 <button class="share-btn share-btn-secondary" id="report-copy-btn">📋 Copy info</button>
1543 <button class="share-btn share-btn-secondary" id="report-close-btn">Cancel</button>
1544 </div>
1545 <p style="font-size:0.72rem;color:var(--text-dim);margin-top:10px;font-style:italic">
1546 Email needs no account. Copy info if you would rather send it another way.
1547 </p>
1548 </div>
1549</div>
1550
1551<!-- Setup / key entry modal (shown before game on first visit) -->
1552<div id="setup-modal">
1553 <div class="setup-inner">
1554
1555 <!-- Own-key panel -->
1556 <div id="setup-own-panel">
1557 <div class="setup-logo"><img src="dice.png" alt="" style="height:1em;vertical-align:middle;margin-right:0.3em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div>
1558 <p class="setup-sub">Powered by Claude — your story begins here</p>
1559 <p style="font-size:0.8rem;color:var(--text-dim);margin:0 0 18px;line-height:1.6">
1560 An AI-powered solo RPG in your browser. Claude acts as your Game Master for a full <a href="https://cyphersrd.quest" target="_blank" rel="noopener" style="color:var(--gold-dim)">Cypher System</a> adventure — narration, rules, and consequences all generated live. <a href="https://code.fuzzyworld.net/dm/-/blob/main/README.md" target="_blank" rel="noopener" style="color:var(--gold-dim)">README →</a>
1561 </p>
1562
1563 <div style="font-family:'Cinzel',serif;font-size:0.75rem;letter-spacing:0.12em;color:var(--gold);text-transform:uppercase;margin-bottom:8px">Anthropic API Key</div>
1564 <div class="field-group">
1565 <input type="password" id="setup-key-input" placeholder="sk-ant-api03-…" autocomplete="off" spellcheck="false">
1566 </div>
1567 <div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;min-height:18px">
1568 <span id="setup-key-saved-badge" style="font-size:0.75rem;color:var(--hp-full);display:none">✓ Saved on this device</span>
1569 <button id="setup-forget-btn" style="font-size:0.72rem;color:var(--text-dim);background:none;border:none;cursor:pointer;text-decoration:underline;text-underline-offset:2px;padding:0;display:none" type="button">Forget saved key</button>
1570 </div>
1571 <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic">
1572 Sent directly to Anthropic — never forwarded elsewhere. Saved to this device so you only enter it once.
1573 Get one free at <a href="https://console.anthropic.com/" target="_blank" rel="noopener" style="color:var(--gold-dim)">console.anthropic.com</a>
1574 — pay-as-you-go, a typical session costs a few cents.
1575 </p>
1576
1577 <div style="margin-top:18px;border-top:1px solid var(--border);padding-top:16px">
1578 <button id="setup-share-toggle" class="share-link-trigger" style="margin-top:0">🔗 Share this app (optional)</button>
1579 <div id="setup-share-panel" style="display:none;margin-top:14px">
1580 <p id="setup-share-http-warning" style="display:none;font-size:0.78rem;color:var(--hp-low);background:rgba(231,76,60,0.08);border:1px solid rgba(231,76,60,0.25);border-radius:4px;padding:8px 12px;margin-bottom:12px">
1581 ⚠️ Running on HTTP — using a simplified cipher. For stronger encryption, generate links from the deployed HTTPS site.
1582 </p>
1583 <div class="field-group" style="margin-bottom:12px">
1584 <label>Access Code <span style="color:var(--text-dim);font-weight:400;text-transform:none;letter-spacing:0">(passphrase your users will enter)</span></label>
1585 <input type="text" id="setup-pass-input" placeholder="e.g. goblin-treasure-42" autocomplete="off" spellcheck="false" style="width:100%;background:var(--bg-input);border:1px solid var(--border);border-radius:4px;color:var(--text-bright);font-family:'Crimson Text',serif;font-size:1rem;padding:10px 14px;outline:none">
1586 </div>
1587 <div class="share-row">
1588 <button class="share-btn share-btn-primary" id="setup-share-generate-btn">Generate</button>
1589 </div>
1590 <div id="setup-share-result" style="display:none;margin-top:12px">
1591 <div style="font-family:'Cinzel',serif;font-size:0.65rem;letter-spacing:0.12em;color:var(--gold);text-transform:uppercase;margin-bottom:6px">Share this URL</div>
1592 <div class="share-url-box" id="setup-share-url-display"></div>
1593 <div class="share-row" style="margin-bottom:6px">
1594 <button class="share-btn share-btn-primary" id="setup-share-copy-btn">Copy URL</button>
1595 </div>
1596 <p class="share-note">⚠️ Share the access code <em>separately</em> — by text, verbally, etc. The link alone is not enough.</p>
1597 </div>
1598 </div>
1599 </div>
1600
1601 <button class="begin-btn" id="setup-continue-btn" disabled style="margin-top:24px;width:100%">Continue →</button>
1602 </div>
1603
1604 <!-- Shared-key panel -->
1605 <div id="setup-shared-panel" style="display:none">
1606 <div class="setup-logo"><img src="dice.png" alt="" style="height:1em;vertical-align:middle;margin-right:0.3em;filter:invert(1) sepia(1) saturate(2) hue-rotate(5deg) brightness(0.9)"> Solo Dungeon Master</div>
1607 <p class="setup-sub">You received a special link to this app.<br>Enter your access code to continue.</p>
1608
1609 <div style="font-family:'Cinzel',serif;font-size:0.75rem;letter-spacing:0.12em;color:var(--gold);text-transform:uppercase;margin:20px 0 8px">Access Code</div>
1610 <div class="field-group">
1611 <input type="password" id="setup-passphrase-input" placeholder="your passphrase" autocomplete="off" spellcheck="false">
1612 </div>
1613 <p style="font-size:0.78rem;color:var(--text-dim);margin-top:6px;font-style:italic">
1614 Enter the access code you received alongside this link.
1615 </p>
1616
1617 <button class="begin-btn" id="setup-unlock-btn" disabled style="margin-top:24px;width:100%">Unlock →</button>
1618 </div>
1619
1620 </div>
1621</div>
1622
1623<!-- Level up toast -->
1624<div id="levelup-toast">
1625 <div class="toast-icon">⭐</div>
1626 <div class="toast-title" id="toast-title">LEVEL UP!</div>
1627 <div class="toast-sub" id="toast-sub">You have grown stronger.</div>
1628 <button onclick="document.getElementById('levelup-toast').style.display='none'">Continue</button>
1629</div>
1630
1631<script>
1632// ── ERROR BUFFER ──────────────────────────────────────
1633// Capture recent console.error calls so they can be included in bug reports.
1634const _recentErrors = [];
1635{
1636 const _orig = console.error.bind(console);
1637 console.error = (...args) => {
1638 _orig(...args);
1639 const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
1640 _recentErrors.push({ t: new Date().toISOString(), msg });
1641 if (_recentErrors.length > 30) _recentErrors.shift();
1642 };
1643}
1644
1645// ── CRYPTO ────────────────────────────────────────────
1646// Two implementations: AES-GCM (requires secure context) and a pure-JS
1647// XOR stream cipher fallback for plain HTTP. Blobs are prefixed with
1648// "A:" (AES-GCM) or "X:" (XOR) so decryption always picks the right path.
1649
1650// -- Pure-JS fallback (HTTP / non-secure contexts) ---------------------
1651// FNV-1a hash used for key derivation + XOR stream cipher.
1652// Adequate for casual access control; weaker than AES-GCM.
1653function fnv32(s) {
1654 let h = 2166136261;
1655 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
1656 return h >>> 0;
1657}
1658
1659function xorEncrypt(text, passphrase) {
1660 // Stretch the key with 8000 rounds to slow brute-force
1661 let seed = fnv32(passphrase + ':dm:0');
1662 for (let i = 1; i <= 8000; i++) seed = fnv32(seed + ':' + passphrase + ':' + i);
1663
1664 const data = new TextEncoder().encode(text);
1665 const out = new Uint8Array(4 + data.length);
1666 const tag = fnv32(passphrase + ':dm:verify');
1667 out[0] = tag >> 24 & 0xFF; out[1] = tag >> 16 & 0xFF;
1668 out[2] = tag >> 8 & 0xFF; out[3] = tag & 0xFF;
1669
1670 let state = seed;
1671 for (let i = 0; i < data.length; i++) {
1672 state = fnv32(state.toString(16) + ':' + i);
1673 out[4 + i] = data[i] ^ (state & 0xFF);
1674 }
1675 return 'X:' + btoa(String.fromCharCode(...out))
1676 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
1677}
1678
1679function xorDecrypt(blob, passphrase) {
1680 const b64 = blob.slice(2).replace(/-/g, '+').replace(/_/g, '/');
1681 const data = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
1682
1683 const expected = fnv32(passphrase + ':dm:verify');
1684 const actual = (data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3]) >>> 0;
1685 if (actual !== expected) throw new Error('Invalid access code');
1686
1687 let seed = fnv32(passphrase + ':dm:0');
1688 for (let i = 1; i <= 8000; i++) seed = fnv32(seed + ':' + passphrase + ':' + i);
1689
1690 const result = new Uint8Array(data.length - 4);
1691 let state = seed;
1692 for (let i = 0; i < result.length; i++) {
1693 state = fnv32(state.toString(16) + ':' + i);
1694 result[i] = data[4 + i] ^ (state & 0xFF);
1695 }
1696 return new TextDecoder().decode(result);
1697}
1698
1699// -- AES-GCM via Web Crypto (HTTPS / localhost) ------------------------
1700async function deriveAesKey(passphrase, salt, usage) {
1701 const enc = new TextEncoder();
1702 const raw = await crypto.subtle.importKey('raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
1703 return crypto.subtle.deriveKey(
1704 { name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' },
1705 raw,
1706 { name: 'AES-GCM', length: 256 },
1707 false,
1708 [usage]
1709 );
1710}
1711
1712async function aesEncrypt(text, passphrase) {
1713 const enc = new TextEncoder();
1714 const salt = crypto.getRandomValues(new Uint8Array(16));
1715 const iv = crypto.getRandomValues(new Uint8Array(12));
1716 const key = await deriveAesKey(passphrase, salt, 'encrypt');
1717 const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text));
1718 const blob = new Uint8Array(16 + 12 + ct.byteLength);
1719 blob.set(salt, 0); blob.set(iv, 16); blob.set(new Uint8Array(ct), 28);
1720 return 'A:' + btoa(String.fromCharCode(...blob))
1721 .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
1722}
1723
1724async function aesDecrypt(blob, passphrase) {
1725 const raw = blob.slice(2).replace(/-/g, '+').replace(/_/g, '/');
1726 const data = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
1727 const key = await deriveAesKey(passphrase, data.slice(0, 16), 'decrypt');
1728 const pt = await crypto.subtle.decrypt(
1729 { name: 'AES-GCM', iv: data.slice(16, 28) }, key, data.slice(28));
1730 return new TextDecoder().decode(pt);
1731}
1732
1733// -- localStorage obfuscation ('L:' prefix, 10k iterations, origin-derived key) --
1734// Not secret — anyone reading the source can decrypt — but keeps the raw key
1735// off-screen in DevTools → Application → localStorage.
1736const _LSPASS = 'dm-local:' + location.origin;
1737
1738async function encryptForStorage(key) {
1739 if (!window.isSecureContext) return xorEncrypt(key, _LSPASS);
1740 const enc = new TextEncoder();
1741 const salt = crypto.getRandomValues(new Uint8Array(16));
1742 const iv = crypto.getRandomValues(new Uint8Array(12));
1743 const mat = await crypto.subtle.importKey('raw', enc.encode(_LSPASS), 'PBKDF2', false, ['deriveKey']);
1744 const aesK = await crypto.subtle.deriveKey(
1745 { name: 'PBKDF2', salt, iterations: 10000, hash: 'SHA-256' },
1746 mat, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
1747 const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesK, enc.encode(key));
1748 const blob = new Uint8Array(16 + 12 + ct.byteLength);
1749 blob.set(salt, 0); blob.set(iv, 16); blob.set(new Uint8Array(ct), 28);
1750 return 'L:' + btoa(String.fromCharCode(...blob)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
1751}
1752
1753async function decryptFromStorage(stored) {
1754 if (!stored) return null;
1755 if (!stored.startsWith('L:') && !stored.startsWith('X:')) return stored; // legacy plaintext
1756 if (stored.startsWith('X:')) return xorDecrypt(stored, _LSPASS);
1757 if (!window.isSecureContext) return null;
1758 const raw = stored.slice(2).replace(/-/g, '+').replace(/_/g, '/');
1759 const data = Uint8Array.from(atob(raw), c => c.charCodeAt(0));
1760 const enc = new TextEncoder();
1761 const mat = await crypto.subtle.importKey('raw', enc.encode(_LSPASS), 'PBKDF2', false, ['deriveKey']);
1762 const aesK = await crypto.subtle.deriveKey(
1763 { name: 'PBKDF2', salt: data.slice(0, 16), iterations: 10000, hash: 'SHA-256' },
1764 mat, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
1765 try {
1766 const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: data.slice(16, 28) }, aesK, data.slice(28));
1767 return new TextDecoder().decode(pt);
1768 } catch { return null; }
1769}
1770
1771// -- Public API --------------------------------------------------------
1772async function encryptApiKey(apiKey, passphrase) {
1773 return window.isSecureContext ? aesEncrypt(apiKey, passphrase) : xorEncrypt(apiKey, passphrase);
1774}
1775
1776async function decryptApiKey(blob, passphrase) {
1777 if (blob.startsWith('X:')) return xorDecrypt(blob, passphrase);
1778 // 'A:' prefix or legacy (no prefix) — both use AES-GCM
1779 const b = blob.startsWith('A:') ? blob : 'A:' + blob;
1780 if (!window.isSecureContext)
1781 throw new Error('This link uses strong encryption and requires HTTPS to open. Open it from https://apps.fuzzyworld.net/dm/.');
1782 return aesDecrypt(b, passphrase);
1783}
1784
1785function getSharedBlob() {
1786 const params = new URLSearchParams(location.hash.slice(1));
1787 return params.get('k') || null;
1788}
1789
1790// ── DATA ──────────────────────────────────────────────
1791
1792// ── CYPHER SYSTEM DATA (CSRD) ─────────────────────────
1793// Source: Cypher System Reference Document, Cypher System Open License
1794// © Monte Cook Games — montecookgames.com/cypher-system-open-license
1795
1796const TYPES = [
1797 { name: 'Warrior', icon: '⚔️',
1798 pools: { might: 10, speed: 10, intellect: 8 },
1799 edge: { might: 1, speed: 0, intellect: 0 },
1800 cypherLimit: 2,
1801 note: 'Practiced with all weapons. Might Edge 1.',
1802 equipment: ['Weapon of choice', 'Light armor', "Explorer's pack", 'Moderate-priced item'],
1803 startingAbilities: [
1804 { name: 'Practiced with All Weapons', cost: null, description: 'You can use any weapon without penalty.' },
1805 { name: 'Physical Skills', cost: null, description: 'Trained in two physical skills of your choice (e.g. climbing, swimming, jumping, running).' },
1806 { name: 'Bash', cost: '1 Might', description: 'Your attack inflicts 2 additional points of damage.' },
1807 ],
1808 startingSkills: [
1809 { name: 'Climbing', rank: 'trained' },
1810 { name: 'Swimming', rank: 'trained' },
1811 ] },
1812 { name: 'Adept', icon: '🔮',
1813 pools: { might: 7, speed: 9, intellect: 12 },
1814 edge: { might: 0, speed: 0, intellect: 1 },
1815 cypherLimit: 3,
1816 note: 'Masters esoteries (special abilities). Intellect Edge 1. Cypher Limit 3.',
1817 equipment: ['Clothing', 'Book of lore', 'Two expensive items', 'Moderate-priced item'],
1818 startingAbilities: [
1819 { name: 'Practiced with Light Weapons', cost: null, description: 'You can use light weapons without penalty.' },
1820 { name: 'Magical Training', cost: null, description: 'Trained in all esoteries as if they were skills.' },
1821 { name: 'Hedge Magic', cost: '1 Intellect', description: 'Perform a minor magical trick — small light, move a tiny object, clean something, etc.' },
1822 { name: 'Ward', cost: null, description: 'A constant magical field grants you +1 Armor at all times.' },
1823 ],
1824 startingSkills: [
1825 { name: 'Esoteries', rank: 'trained' },
1826 ] },
1827 { name: 'Explorer', icon: '🧭',
1828 pools: { might: 10, speed: 9, intellect: 9 },
1829 edge: { might: 1, speed: 0, intellect: 0 },
1830 cypherLimit: 2,
1831 note: 'Balanced adventurer; light/medium weapons. Might Edge 1.',
1832 equipment: ['Weapon of choice', 'Light armor', 'Exploration gear', 'Moderate-priced item'],
1833 startingAbilities: [
1834 { name: 'Practiced with Light and Medium Weapons', cost: null, description: 'You can use light and medium weapons without penalty.' },
1835 { name: 'Practiced in Armor', cost: null, description: 'Wear light armor without any Speed Effort cost.' },
1836 { name: 'Danger Sense', cost: null, description: 'Your initiative tasks are eased.' },
1837 { name: 'Physical Skills', cost: null, description: 'Trained in two physical skills of your choice.' },
1838 ],
1839 startingSkills: [
1840 { name: 'Initiative', rank: 'trained' },
1841 { name: 'Climbing', rank: 'trained' },
1842 { name: 'Swimming', rank: 'trained' },
1843 ] },
1844 { name: 'Speaker', icon: '🗣️',
1845 pools: { might: 8, speed: 9, intellect: 11 },
1846 edge: { might: 0, speed: 0, intellect: 1 },
1847 cypherLimit: 2,
1848 note: 'Social and deceptive abilities. Intellect Edge 1.',
1849 equipment: ['Light weapon', 'Stylish clothing', 'Two expensive items', 'Moderate-priced item'],
1850 startingAbilities: [
1851 { name: 'Practiced with Light Weapons', cost: null, description: 'You can use light weapons without penalty.' },
1852 { name: 'Spin Identity', cost: '2+ Intellect', description: 'Convince others you are someone you are not; lasts the duration of the interaction.' },
1853 { name: 'Interaction Skills', cost: null, description: 'Trained in deception, persuasion, and intimidation.' },
1854 { name: 'Babel', cost: null, description: 'After listening to any spoken language briefly, you can understand it and make yourself understood.' },
1855 ],
1856 startingSkills: [
1857 { name: 'Deception', rank: 'trained' },
1858 { name: 'Persuasion', rank: 'trained' },
1859 { name: 'Intimidation', rank: 'trained' },
1860 ] },
1861];
1862
1863const DESCRIPTORS = [
1864 { name: 'Appealing', icon: '😊', poolBonus: { speed: 2 }, note: 'Your presence puts others at ease. Trained in pleasant social interactions.',
1865 startingSkills: [{ name: 'Pleasant social interactions', rank: 'trained' }] },
1866 { name: 'Brash', icon: '😤', poolBonus: { speed: 2 }, note: 'Act first, think later. Trained in initiative; inability in patience tasks.',
1867 startingSkills: [{ name: 'Initiative', rank: 'trained' }, { name: 'Tasks requiring patience', rank: 'inability' }] },
1868 { name: 'Calm', icon: '🧘', poolBonus: { intellect: 2 }, note: 'Unshakeable under pressure. Trained in resisting persuasion and panic.',
1869 startingSkills: [{ name: 'Resisting persuasion', rank: 'trained' }] },
1870 { name: 'Charming', icon: '💫', poolBonus: { intellect: 2 }, note: 'Hard to dislike and easy to trust. Trained in persuasion and performance.',
1871 startingSkills: [{ name: 'Persuasion', rank: 'trained' }, { name: 'Performance', rank: 'trained' }] },
1872 { name: 'Clever', icon: '🧠', poolBonus: { intellect: 2 }, note: 'Skilled at lies, trickery, and assessing danger.',
1873 startingSkills: [{ name: 'Deception', rank: 'trained' }, { name: 'Assessing danger', rank: 'trained' }] },
1874 { name: 'Creative', icon: '🎨', poolBonus: { intellect: 2 }, note: 'Finds unexpected solutions. Trained in all creative tasks (art, music, writing).',
1875 startingSkills: [{ name: 'Creative tasks', rank: 'trained' }] },
1876 { name: 'Empathic', icon: '💗', poolBonus: { intellect: 2 }, note: 'Reads emotional states with ease. Trained in sensing motives and first-aid.',
1877 startingSkills: [{ name: 'Sensing motives', rank: 'trained' }, { name: 'First aid', rank: 'trained' }] },
1878 { name: 'Fast', icon: '💨', poolBonus: { speed: 2 }, note: 'Can move and act in the same round.',
1879 startingSkills: [] },
1880 { name: 'Graceful', icon: '🌸', poolBonus: { speed: 2 }, note: 'Trained in balance, performance, and Speed defense.',
1881 startingSkills: [{ name: 'Balance', rank: 'trained' }, { name: 'Performance', rank: 'trained' }, { name: 'Speed defense', rank: 'trained' }] },
1882 { name: 'Hardy', icon: '🪨', poolBonus: { might: 4 }, note: 'Functions one step better on the damage track.',
1883 startingSkills: [] },
1884 { name: 'Honorable', icon: '🛡️', poolBonus: { might: 2 }, note: 'Trained in pleasant social interactions and detecting lies.',
1885 startingSkills: [{ name: 'Pleasant social interactions', rank: 'trained' }, { name: 'Detecting lies', rank: 'trained' }] },
1886 { name: 'Impulsive', icon: '⚡', poolBonus: { speed: 2 }, note: 'You go first and think later. Trained in initiative and Speed defense; inability in careful deliberation.',
1887 startingSkills: [{ name: 'Initiative', rank: 'trained' }, { name: 'Speed defense', rank: 'trained' }, { name: 'Careful deliberation', rank: 'inability' }] },
1888 { name: 'Inquisitive', icon: '🔍', poolBonus: { intellect: 4 }, note: 'Trained in learning, geography, and history.',
1889 startingSkills: [{ name: 'Learning', rank: 'trained' }, { name: 'Geography', rank: 'trained' }, { name: 'History', rank: 'trained' }] },
1890 { name: 'Intelligent', icon: '📚', poolBonus: { intellect: 2 }, note: 'Trained in one knowledge area and memorization.',
1891 startingSkills: [{ name: 'Memorization', rank: 'trained' }] },
1892 { name: 'Intuitive', icon: '✨', poolBonus: { intellect: 2 }, note: 'Trained in perception; can act out of turn once per cycle.',
1893 startingSkills: [{ name: 'Perception', rank: 'trained' }] },
1894 { name: 'Kind', icon: '🕊️', poolBonus: { intellect: 2 }, note: 'Genuine warmth that opens doors. Trained in healing and social tasks.',
1895 startingSkills: [{ name: 'Healing', rank: 'trained' }, { name: 'Social interactions', rank: 'trained' }] },
1896 { name: 'Learned', icon: '🎓', poolBonus: { intellect: 4 }, note: 'A walking library. Trained in two knowledge areas of your choice.',
1897 startingSkills: [] },
1898 { name: 'Loyal', icon: '🤝', poolBonus: { might: 2 }, note: 'Your allies inspire you. Trained in tasks that help companions directly.',
1899 startingSkills: [{ name: 'Helping companions', rank: 'trained' }] },
1900 { name: 'Lucky', icon: '🍀', poolBonus: {}, note: 'Gains a 3-point Luck Pool; bonuses to rerolls.',
1901 startingSkills: [] },
1902 { name: 'Mighty', icon: '💪', poolBonus: { might: 4 }, note: 'Trained in lifting/throwing; +1 to recovery rolls.',
1903 startingSkills: [{ name: 'Lifting', rank: 'trained' }, { name: 'Throwing', rank: 'trained' }] },
1904 { name: 'Mysterious', icon: '🌑', poolBonus: {}, note: 'Trained in stealth and resisting interrogation.',
1905 startingSkills: [{ name: 'Stealth', rank: 'trained' }, { name: 'Resisting interrogation', rank: 'trained' }] },
1906 { name: 'Mystical', icon: '🌙', poolBonus: { intellect: 2 }, note: 'Can detect and cast minor magic; trained in the supernatural.',
1907 startingSkills: [{ name: 'Supernatural knowledge', rank: 'trained' }] },
1908 { name: 'Perceptive', icon: '👁️', poolBonus: { intellect: 2 }, note: 'Trained in noticing details and recalling trivia.',
1909 startingSkills: [{ name: 'Noticing details', rank: 'trained' }, { name: 'Recalling trivia', rank: 'trained' }] },
1910 { name: 'Resilient', icon: '🌳', poolBonus: { might: 2, intellect: 2 }, note: 'Trained in both Might and Intellect defense; extra recovery roll.',
1911 startingSkills: [{ name: 'Might defense', rank: 'trained' }, { name: 'Intellect defense', rank: 'trained' }] },
1912 { name: 'Rugged', icon: '🏔️', poolBonus: { might: 2, speed: 2 }, note: 'Built for hard conditions. Light armor without penalties; trained in navigation.',
1913 startingSkills: [{ name: 'Navigation', rank: 'trained' }] },
1914 { name: 'Sharp-Eyed', icon: '🦅', poolBonus: {}, note: 'Trained in initiative and perception; can spot a foe\'s weakness.',
1915 startingSkills: [{ name: 'Initiative', rank: 'trained' }, { name: 'Perception', rank: 'trained' }] },
1916 { name: 'Stealthy', icon: '🌫️', poolBonus: { speed: 2 }, note: 'Trained in stealth, trickery, and illusions.',
1917 startingSkills: [{ name: 'Stealth', rank: 'trained' }, { name: 'Trickery', rank: 'trained' }] },
1918 { name: 'Strong', icon: '🪓', poolBonus: { might: 4 }, note: 'Trained in breaking objects and jumping.',
1919 startingSkills: [{ name: 'Breaking objects', rank: 'trained' }, { name: 'Jumping', rank: 'trained' }] },
1920 { name: 'Strong-Willed',icon: '🔮', poolBonus: { intellect: 4 }, note: 'Trained in resisting mental effects and concentration.',
1921 startingSkills: [{ name: 'Resisting mental effects', rank: 'trained' }, { name: 'Concentration', rank: 'trained' }] },
1922 { name: 'Swift', icon: '🐆', poolBonus: { speed: 4 }, note: 'Trained in running and escape; movement is never restricted.',
1923 startingSkills: [{ name: 'Running', rank: 'trained' }, { name: 'Escaping', rank: 'trained' }] },
1924 { name: 'Tough', icon: '🦾', poolBonus: { might: 2 }, note: '+1 Armor; +1 to recovery rolls.',
1925 startingSkills: [] },
1926 { name: 'Vicious', icon: '🩸', poolBonus: { might: 2 }, note: 'You fight to hurt. Attacks deal 1 additional point of damage; inability in pleasant social interactions.',
1927 startingSkills: [{ name: 'Pleasant social interactions', rank: 'inability' }] },
1928 { name: 'Virtuous', icon: '⭐', poolBonus: { intellect: 2 }, note: 'Trained in resisting temptation and corruption; allies trust you deeply.',
1929 startingSkills: [{ name: 'Resisting temptation', rank: 'trained' }] },
1930 { name: 'Weird', icon: '🌀', poolBonus: { intellect: 2 }, note: 'Trained in supernatural knowledge; senses nearby strange phenomena.',
1931 startingSkills: [{ name: 'Supernatural knowledge', rank: 'trained' }] },
1932];
1933
1934// settings: which genres this focus fits. 'all' = any setting.
1935// tier1: the Tier 1 ability granted by this focus.
1936const FOCI = [
1937 { name: 'Abides in Stone', icon: '🗿', settings: ['fantasy','numenera'], playstyle: 'Your body can merge with rock; you are patient, enduring, and terrifying in close combat.',
1938 tier1: { name: 'Stone Body', cost: null, description: 'Your skin has a stone-like quality, granting +1 Armor at all times. You cannot be pushed or knocked prone.' } },
1939 { name: 'Acts Without Consequence', icon: '😏', settings: ['all'], playstyle: 'Rules, ethics, and consequences never seem to stick to you — a charmed manipulator.',
1940 tier1: { name: 'Verbal Misdirection', cost: '2 Intellect', description: 'Distract a foe with rapid banter; their next action is hindered.' } },
1941 { name: 'Battles Robots', icon: '🤖', settings: ['numenera','scifi'], playstyle: 'Specialised in locating and destroying mechanical threats of all kinds.',
1942 tier1: { name: 'Machine Vulnerabilities',cost: null, description: 'Trained in all tasks involving finding and exploiting weaknesses in machines and robots.' } },
1943 { name: 'Carries a Quiver', icon: '🏹', settings: ['all'], playstyle: 'Master archer who never misses and always has the right arrow for the moment.',
1944 tier1: { name: 'Archery', cost: null, description: 'Ranged attacks with bows are eased.' } },
1945 { name: 'Commands Mental Powers', icon: '🧿', settings: ['all'], playstyle: 'Wields psychic force — telepathy, telekinesis, and mind-shattering blasts.',
1946 tier1: { name: 'Telepathic', cost: null, description: 'Communicate silently with any creature within short range that you can see.' } },
1947 { name: 'Controls Beasts', icon: '🦁', settings: ['fantasy','numenera','modern'], playstyle: 'Communicate with and command animals, from pets to apex predators.',
1948 tier1: { name: 'Beast Companion', cost: null, description: 'A level 2 creature becomes your loyal companion and follows your commands.' } },
1949 { name: 'Crafts Illusions', icon: '🎭', settings: ['fantasy','numenera'], playstyle: 'Fashion images from light so real they deceive all senses.',
1950 tier1: { name: 'Minor Illusion', cost: '1 Intellect', description: 'Create a small, silent image of any object or scene within short range. It lasts until you move or dismiss it.' } },
1951 { name: 'Crafts Unique Objects', icon: '⚙️', settings: ['fantasy','numenera','scifi'], playstyle: 'Inventor of strange gadgets, gear, and one-of-a-kind contraptions.',
1952 tier1: { name: 'Tinker', cost: '1 Intellect', description: 'Spend 10 minutes modifying a device; it functions one level higher for one specific task.' } },
1953 { name: 'Dances with Dark Matter', icon: '🌌', settings: ['numenera','scifi'], playstyle: 'Shape the unseen fabric of reality into shields, weapons, and impossible structures.',
1954 tier1: { name: 'Shroud of Darkness', cost: '2 Intellect', description: 'Wrap yourself in dark matter; gain +1 Armor and your appearance becomes difficult to perceive.' } },
1955 { name: 'Defends the Weak', icon: '🛡️', settings: ['all'], playstyle: 'Selfless protector who interposes and shields those who cannot protect themselves.',
1956 tier1: { name: 'Courageous', cost: null, description: 'Morale and fear-based attacks on allies within short range of you are hindered.' } },
1957 { name: 'Doesn\'t Do Much', icon: '😴', settings: ['all'], playstyle: 'Outwardly lazy — secretly formidable. Passive abilities accumulate into devastating effect.',
1958 tier1: { name: 'Hang Back', cost: null, description: 'When you take no action on your turn, your next action is eased.' } },
1959 { name: 'Employs Magnetism', icon: '🧲', settings: ['numenera','scifi'], playstyle: 'Attract, repel, and reshape metal at will; a terrifying force on any battlefield.',
1960 tier1: { name: 'Repel Metal', cost: '2 Intellect', description: 'Hurl a metal object within short range away from you; if aimed at a foe, it deals 4 points of damage.' } },
1961 { name: 'Entertains', icon: '🎶', settings: ['all'], playstyle: 'Your art — music, acrobatics, oration — captivates crowds and shapes hearts.',
1962 tier1: { name: 'Performer', cost: null, description: 'All performance tasks (music, acting, oration, acrobatics) are eased.' } },
1963 { name: 'Exists Partially Out of Phase', icon: '👻', settings: ['numenera','scifi'], playstyle: 'Slip through walls and dodge attacks by stepping slightly outside reality.',
1964 tier1: { name: 'Phase Sprint', cost: '2 Speed', description: 'Move up to a long distance as a single action by stepping partially out of phase with reality.' } },
1965 { name: 'Explores Dark Places', icon: '🕯️', settings: ['all'], playstyle: 'Treasure hunter and delver of forgotten ruins, always one step from disaster.',
1966 tier1: { name: 'Eyes Adjusted', cost: null, description: 'You can see in dim light as well as you do in normal light.' } },
1967 { name: 'Fights Dirty', icon: '🤛', settings: ['all'], playstyle: 'Kicks, bites, throws sand — any tactic that wins is a valid tactic.',
1968 tier1: { name: 'Dirty Fighter', cost: null, description: 'When you deal damage you may also blind, trip, or disorient your foe as part of the same action.' } },
1969 { name: 'Fights With Panache', icon: '🎪', settings: ['all'], playstyle: 'Swashbuckling daredevil who wins through flamboyant style over brute force.',
1970 tier1: { name: 'Evasion', cost: null, description: 'Speed defense tasks are eased.' } },
1971 { name: 'Focuses Mind Over Matter', icon: '🔵', settings: ['all'], playstyle: 'Forged by mental discipline; your will shapes the physical world around you.',
1972 tier1: { name: 'Telekinesis', cost: '2 Intellect', description: 'Move an object up to 25 lbs within short range to any other location within short range.' } },
1973 { name: 'Fuses Flesh and Steel', icon: '🦾', settings: ['numenera','scifi'], playstyle: 'Cybernetic enhancements blur the line between human and machine.',
1974 tier1: { name: 'Weaponization', cost: null, description: 'One of your cybernetic components functions as a medium weapon that cannot be disarmed.' } },
1975 { name: 'Fuses Mind and Machine', icon: '💻', settings: ['numenera','scifi'], playstyle: 'Interface directly with computers, drones, and AI — your mind is the terminal.',
1976 tier1: { name: 'Machine Affinity', cost: null, description: 'All tasks involving machines, computers, and electronics are eased.' } },
1977 { name: 'Grows to Towering Heights',icon: '🗼', settings: ['numenera','scifi','fantasy'], playstyle: 'Expand to enormous size, crushing opposition beneath your feet.',
1978 tier1: { name: 'Enlarge', cost: '2 Might', description: 'Grow to twice your size; melee attacks deal 2 additional points of damage for one minute.' } },
1979 { name: 'Howls at the Moon', icon: '🐺', settings: ['fantasy','numenera'], playstyle: 'Transforms into a fearsome beast — power beyond control, control beyond reach.',
1980 tier1: { name: 'Beast Form', cost: '3 Might', description: 'Transform into a beast; gain +6 to your Might pool and claws that inflict 4 points of damage.' } },
1981 { name: 'Hunts', icon: '🦌', settings: ['all'], playstyle: 'Stalking hunter who tracks and takes down a chosen quarry across any terrain.',
1982 tier1: { name: 'Tracker', cost: null, description: 'All tasks involving tracking prey or following a trail are eased.' } },
1983 { name: 'Infiltrates', icon: '🕵️', settings: ['all'], playstyle: 'Uses subtlety, guile, and disguise to enter impossible places unseen.',
1984 tier1: { name: 'Stealth', cost: null, description: 'Tasks involving hiding and sneaking are eased.' } },
1985 { name: 'Interprets the Law', icon: '⚖️', settings: ['all'], playstyle: 'Expert in rules, codes, and loopholes — the letter of the law is your weapon.',
1986 tier1: { name: 'Legal Expertise', cost: null, description: 'All tasks involving law, contracts, government, and courtroom procedure are eased.' } },
1987 { name: 'Is Licensed to Carry', icon: '🔫', settings: ['modern','scifi'], playstyle: 'Expert marksman trained in firearms, their maintenance, and lethal application.',
1988 tier1: { name: 'Gunner', cost: null, description: 'Attacks with firearms are eased.' } },
1989 { name: 'Leads', icon: '👑', settings: ['all'], playstyle: 'Natural commander who earns loyalty and turns individuals into a deadly unit.',
1990 tier1: { name: 'Good Advice', cost: null, description: 'Spend a round advising an ally; their next action is eased.' } },
1991 { name: 'Lives in the Wilderness', icon: '🌲', settings: ['fantasy','numenera','modern'], playstyle: 'Survivalist who reads terrain, weather, and prey — thrives where others perish.',
1992 tier1: { name: 'Wild Practicality', cost: null, description: 'All tasks involving survival, navigation, and finding food and water in the wild are eased.' } },
1993 { name: 'Looks for Trouble', icon: '😈', settings: ['all'], playstyle: 'Brawler who wades into any conflict and relishes the chaos of open combat.',
1994 tier1: { name: 'Fists of Fury', cost: null, description: 'Unarmed attacks deal 4 points of damage instead of the usual 2.' } },
1995 { name: 'Masters Defense', icon: '🛡️', settings: ['all'], playstyle: 'Immovable wall who absorbs punishment and keeps allies safe.',
1996 tier1: { name: 'Shield Master', cost: null, description: 'Speed defense tasks are eased when wielding a shield.' } },
1997 { name: 'Masters Spells', icon: '📜', settings: ['fantasy','numenera'], playstyle: 'Memorizes and rapidly deploys a repertoire of arcane spells.',
1998 tier1: { name: 'Onslaught', cost: '1 Intellect', description: 'Project a ray of force at a target within long range for 4 points of Intellect damage.' } },
1999 { name: 'Masters the Swarm', icon: '🐝', settings: ['fantasy','numenera','scifi'], playstyle: 'Commands insects, vermin, or nanites as an extension of your will.',
2000 tier1: { name: 'Influence Swarm', cost: '2 Intellect', description: 'Direct a nearby swarm of creatures to distract or harass one foe within short range for one minute.' } },
2001 { name: 'Masters Weaponry', icon: '🗡️', settings: ['all'], playstyle: 'Grandmaster of one weapon type — you wield it better than anyone alive.',
2002 tier1: { name: 'Weapon Master', cost: null, description: 'Attacks with your chosen weapon type are eased.' } },
2003 { name: 'Metes Out Justice', icon: '⚖️', settings: ['all'], playstyle: 'Vigilante who punishes the guilty and rights wrongs outside official channels.',
2004 tier1: { name: 'Designation', cost: null, description: 'Declare a target guilty or innocent; attacks against the guilty and tasks protecting the innocent are eased.' } },
2005 { name: 'Moves Like a Cat', icon: '🐈', settings: ['all'], playstyle: 'Acrobat and parkour artist; gravity is a suggestion, not a rule.',
2006 tier1: { name: 'Contortionist', cost: null, description: 'All tasks involving flexibility, balance, and physical dexterity are eased.' } },
2007 { name: 'Moves Like the Wind', icon: '🌬️', settings: ['all'], playstyle: 'Blur of speed — strikes before enemies react and vanishes before they recover.',
2008 tier1: { name: 'Sprint', cost: null, description: 'You can move up to a long distance as a single action.' } },
2009 { name: 'Murders', icon: '🩸', settings: ['all'], playstyle: 'Assassin specialising in lethal surprise and untraceable eliminations.',
2010 tier1: { name: 'Surprise Attack', cost: null, description: 'Attacks against unaware foes are eased and deal 4 additional points of damage.' } },
2011 { name: 'Needs No Weapon', icon: '👊', settings: ['all'], playstyle: 'Your body is the only weapon you need — fists, feet, and iron will.',
2012 tier1: { name: 'Unarmed Fighting Style',cost: null, description: 'You are trained in unarmed attacks, and they deal 4 points of damage.' } },
2013 { name: 'Never Says Die', icon: '💀', settings: ['all'], playstyle: 'Unstoppable survivor who keeps fighting long past the point of reason.',
2014 tier1: { name: 'Ignore the Pain', cost: '2 Might', description: 'Shrug off harm; your next action is not hindered by the damage you just received.' } },
2015 { name: 'Operates Undercover', icon: '🎭', settings: ['all'], playstyle: 'Deep-cover operative; multiple identities, zero trace, infinite patience.',
2016 tier1: { name: 'Investigate', cost: null, description: 'Trained in stealth, disguise, and gathering information discreetly.' } },
2017 { name: 'Performs Feats of Strength',icon: '🏋️', settings: ['all'], playstyle: 'Immense physical power applied with precision — lift, throw, and crush at will.',
2018 tier1: { name: 'Athlete', cost: null, description: 'All tasks involving feats of strength — lifting, breaking, and throwing — are eased.' } },
2019 { name: 'Pilots Starcraft', icon: '🚀', settings: ['numenera','scifi'], playstyle: 'Born in the cockpit; fly, fight, and fix any spacecraft under any conditions.',
2020 tier1: { name: 'Pilot', cost: null, description: 'All tasks involving piloting spacecraft and flying vehicles are eased.' } },
2021 { name: 'Plays Too Many Games', icon: '♟️', settings: ['all'], playstyle: 'Master tactician whose life is one long game — always thinking three moves ahead.',
2022 tier1: { name: 'Strategize', cost: null, description: 'Spend a round planning; one ally\'s next action is eased.' } },
2023 { name: 'Rages', icon: '😡', settings: ['all'], playstyle: 'Berserker who trades control for devastating fury, unstoppable in the heat of battle.',
2024 tier1: { name: 'Frenzy', cost: '1 Might', description: 'Enter a battle frenzy; attacks deal 1 additional point of damage, but Speed defense is hindered.' } },
2025 { name: 'Rides the Lightning', icon: '⚡', settings: ['all'], playstyle: 'Creates and discharges electrical power to shock, stun, and energize.',
2026 tier1: { name: 'Shock', cost: '1 Intellect', description: 'Discharge electricity at a foe within short range for 1 point of damage that ignores Armor.' } },
2027 { name: 'Runs Away', icon: '🏃', settings: ['all'], playstyle: 'Escape artist who knows when to fight and when to vanish — and survives because of it.',
2028 tier1: { name: 'Fleet of Foot', cost: null, description: 'Running and escape tasks are eased; you can move and take a different action on the same turn.' } },
2029 { name: 'Sailed Beneath the Jolly Roger', icon: '☠️', settings: ['fantasy','modern'], playstyle: 'Pirate captain: seafaring, swashbuckling, plundering — and proud of every bit of it.',
2030 tier1: { name: 'Sea Legs', cost: null, description: 'All tasks aboard ships and in water are eased. You have a positive reputation among sailors and pirates.' } },
2031 { name: 'Scavenges', icon: '♻️', settings: ['all'], playstyle: 'Improviser who finds tools, weapons, and value where others see only junk.',
2032 tier1: { name: 'Scrounge', cost: '1 Intellect', description: 'Search an area for 10 minutes to find a useful piece of moderately-priced equipment from available junk.' } },
2033 { name: 'Sees Beyond', icon: '🔮', settings: ['fantasy','numenera'], playstyle: 'Peer into futures, pasts, and hidden truths — visions come unbidden and change everything.',
2034 tier1: { name: 'Premonition', cost: '2 Intellect', description: 'Ask the GM one yes/no question about the immediate future at this location. The answer must be accurate.' } },
2035 { name: 'Separates Mind From Body', icon: '🕯️', settings: ['all'], playstyle: 'Astral projection lets your mind travel freely while your body waits in safety.',
2036 tier1: { name: 'Psychic Projection', cost: '2 Intellect', description: 'Project your consciousness up to 1 mile, perceiving the environment but unable to interact with it.' } },
2037 { name: 'Shreds the Walls of the World', icon: '🌀', settings: ['numenera','scifi','fantasy'], playstyle: 'Tear open dimensional rifts; step between spaces or hurl enemies into the void.',
2038 tier1: { name: 'Phase Sight', cost: null, description: 'You can perceive objects and creatures that exist slightly out of phase with reality.' } },
2039 { name: 'Shrinks to Microscopic Size', icon: '🔬', settings: ['numenera','scifi'], playstyle: 'Reduce to molecular scale to infiltrate, sabotage, and strike unseen.',
2040 tier1: { name: 'Shrink', cost: '1 Might', description: 'Reduce to 6 inches tall. Hiding is eased, attacks against you are hindered, and you can fit through tiny spaces.' } },
2041 { name: 'Siphons Power', icon: '🌊', settings: ['fantasy','numenera','scifi'], playstyle: 'Drain energy from enemies, machines, or the environment and redirect it as your own.',
2042 tier1: { name: 'Drain Creature', cost: '2 Intellect', description: 'Drain 3 points from a creature\'s Might or Speed pool and add them to any of your own pools.' } },
2043 { name: 'Slays Monsters', icon: '🐉', settings: ['fantasy','numenera'], playstyle: 'Dedicated monster hunter with encyclopaedic knowledge of creature weaknesses.',
2044 tier1: { name: 'Monster Lore', cost: null, description: 'Trained in all tasks involving knowledge of creatures and combat against them.' } },
2045 { name: 'Solves Mysteries', icon: '🔎', settings: ['all'], playstyle: 'Master of deduction who reads evidence to reconstruct hidden truths.',
2046 tier1: { name: 'Investigate Scene', cost: '3 Intellect', description: 'Study an area for one minute; learn one accurate clue about what happened there.' } },
2047 { name: 'Speaks for the Land', icon: '🌿', settings: ['fantasy','numenera'], playstyle: 'Voice of nature itself; plants, winds, and stones obey your call.',
2048 tier1: { name: 'Wilderness Lore', cost: null, description: 'Trained in all tasks involving nature, plants, animals, and natural phenomena.' } },
2049 { name: 'Stands Like a Bastion', icon: '🏰', settings: ['all'], playstyle: 'An unmovable point of order amid chaos — your resolve is physically manifest.',
2050 tier1: { name: 'Fortified Position', cost: '1 Might', description: 'Anchor yourself; you cannot be moved or knocked prone and attacks against you are hindered until your next turn.' } },
2051 { name: 'Takes Animal Shape', icon: '🦊', settings: ['fantasy','numenera'], playstyle: 'Shapeshifter who borrows the bodies of beasts at will.',
2052 tier1: { name: 'Animal Shape', cost: '3 Might', description: 'Take the form of a large animal, gaining its natural attacks, movement mode, and senses.' } },
2053 { name: 'Talks to Machines', icon: '🖥️', settings: ['numenera','scifi'], playstyle: 'Converse with and command technology as naturally as breathing.',
2054 tier1: { name: 'Machine Language', cost: null, description: 'Communicate with machines and computers as naturally as speaking; basic queries are always understood.' } },
2055 { name: 'Throws with Deadly Accuracy', icon: '🎯', settings: ['all'], playstyle: 'Any object becomes a lethal projectile in your hands; range is not a problem.',
2056 tier1: { name: 'Precision', cost: null, description: 'All tasks involving thrown weapons or objects as weapons are eased.' } },
2057 { name: 'Thunders', icon: '🔊', settings: ['all'], playstyle: 'Sonic blasts shatter stone, stun enemies, and shake the air for miles.',
2058 tier1: { name: 'Thundering Voice', cost: '2 Might', description: 'Your shout stuns all creatures within short range; their next actions are hindered.' } },
2059 { name: 'Travels Through Time', icon: '⏳', settings: ['numenera','scifi','fantasy'], playstyle: 'Skip forward, reach back, and observe branching moments of history.',
2060 tier1: { name: 'Temporal Viewer', cost: '2 Intellect', description: 'Observe up to one hour into the past or future at your current location.' } },
2061 { name: 'Wears a Sheen of Ice', icon: '❄️', settings: ['fantasy','numenera','scifi'], playstyle: 'Commands the wintry power of cold, frost, and absolute zero.',
2062 tier1: { name: 'Ice Armor', cost: '2 Intellect', description: 'Coat yourself in ice, gaining +2 Armor for one hour.' } },
2063 { name: 'Wields Power with Precision', icon: '🎯', settings: ['all'], playstyle: 'Your special abilities are calibrated to a razor edge — max effect, minimal collateral.',
2064 tier1: { name: 'Trained Without Armor', cost: null, description: 'Speed defense tasks are eased when wearing no armor.' } },
2065 { name: 'Wields Two Weapons', icon: '🤺', settings: ['all'], playstyle: 'Dual-wielder who overwhelms enemies with relentless twin-blade attacks.',
2066 tier1: { name: 'Dual Light Wield', cost: null, description: 'You can wield two light weapons simultaneously without penalty.' } },
2067 { name: 'Works Miracles', icon: '💫', settings: ['fantasy','numenera'], playstyle: 'Healer and restorer who defies fatal wounds and brings the near-dead back.',
2068 tier1: { name: 'Healing Touch', cost: '2 Intellect', description: 'Restore 1d6 points to any pool of a creature you touch.' } },
2069 { name: 'Works the Back Alleys', icon: '🌆', settings: ['all'], playstyle: 'Street-level operative: fences, fixers, and the shadow economy are your domain.',
2070 tier1: { name: 'Underworld Contacts', cost: null, description: 'Trained in stealth and streetwise tasks; you have a fixer or contact in most settlements.' } },
2071 { name: 'Worships the Lunar Nodes', icon: '🌕', settings: ['fantasy','numenera'], playstyle: 'Moon-bound cultist whose power waxes and wanes with the celestial cycle.',
2072 tier1: { name: 'Blessing of the Moon', cost: '2 Intellect', description: 'Grant yourself or a touched ally +2 to any pool temporarily, lasting until the next dawn.' } },
2073 { name: 'Would Rather Be Reading', icon: '📖', settings: ['all'], playstyle: 'Bookish scholar who always knows exactly the right fact at exactly the right moment.',
2074 tier1: { name: 'Know It All', cost: null, description: 'Trained in two areas of knowledge of your choice; all tasks for recalling information are eased.' } },
2075];
2076
2077const DIE_TYPES = [4, 6, 8, 10, 12, 20, 100];
2078
2079const UPDATE_TOOL = {
2080 name: 'update_character',
2081 description: 'Update character stats based on narrative events. Call BEFORE writing narrative text.',
2082 input_schema: {
2083 type: 'object',
2084 properties: {
2085 might_damage: { type: 'number', description: 'Points of damage to Might pool. 0 if none.' },
2086 speed_damage: { type: 'number', description: 'Points of damage to Speed pool. 0 if none.' },
2087 intellect_damage: { type: 'number', description: 'Points of damage to Intellect pool. 0 if none.' },
2088 might_restore: { type: 'number', description: 'Points restored to Might pool (recovery, healing). 0 if none.' },
2089 speed_restore: { type: 'number', description: 'Points restored to Speed pool. 0 if none.' },
2090 intellect_restore:{ type: 'number', description: 'Points restored to Intellect pool. 0 if none.' },
2091 xp_gain: { type: 'number', description: 'XP awarded (intrusions, discoveries). 0 if none.' },
2092 shins_change: { type: 'number', description: 'Shins (currency) delta. Negative=spent. 0 if unchanged.' },
2093 cyphers_add: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, level: { type: 'number' }, effect: { type: 'string' } }, required: ['name','level','effect'] }, description: 'Cyphers found.' },
2094 cyphers_remove: { type: 'array', items: { type: 'string' }, description: 'Names of cyphers used or lost.' },
2095 equipment_add: { type: 'array', items: { type: 'string' }, description: 'Equipment gained.' },
2096 equipment_remove: { type: 'array', items: { type: 'string' }, description: 'Equipment lost.' },
2097 skills_add: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, rank: { type: 'string', enum: ['trained','specialized','inability'] } }, required: ['name','rank'] }, description: 'Skills to grant or upgrade. Use to reflect training earned during play. Rank: trained, specialized, or inability. Two trained in the same skill auto-promotes to specialized.' },
2098 tier_advance: { type: 'boolean', description: 'True if character advances a Tier.' },
2099 reason: { type: 'string', description: 'Brief reason for the change.' },
2100 },
2101 required: ['might_damage','speed_damage','intellect_damage','might_restore','speed_restore','intellect_restore','xp_gain','shins_change','cyphers_add','cyphers_remove','equipment_add','equipment_remove','skills_add','tier_advance','reason'],
2102 },
2103};
2104
2105function buildSystemPrompt(character, theme, notes) {
2106 const c = character;
2107 const dt = c.damageTrack || 'hale';
2108 const setting = SETTINGS[c.setting] || SETTINGS.fantasy;
2109 return `You are a masterful Game Master running a solo Cypher System adventure (CSRD rules).
2110
2111CHARACTER: ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus}
2112${(() => { const f = FOCI.find(x => x.name === c.focus); return f ? `FOCUS STYLE: ${f.playstyle}` : ''; })()}
2113SETTING: ${setting.label}${theme ? ` | THEME: ${theme}` : ''}
2114Currency: ${setting.currency} | Special abilities: ${setting.magic}
2115${setting.tone}
2116
2117STAT POOLS
2118• Might: ${c.might.pool}/${c.might.max} (Edge ${c.might.edge})
2119• Speed: ${c.speed.pool}/${c.speed.max} (Edge ${c.speed.edge})
2120• Intellect: ${c.intellect.pool}/${c.intellect.max} (Edge ${c.intellect.edge})
2121• Damage track: ${dt.toUpperCase()} | Effort: ${c.tier} | XP: ${c.xp} | Shins: ${c.shins}
2122• Cyphers: ${(c.cyphers||[]).length}/${c.cypherLimit}${(c.cyphers||[]).length ? ' — ' + c.cyphers.map(cy=>`${cy.name} (Lv${cy.level})`).join(', ') : ''}
2123
2124ABILITIES
2125${(c.abilities||[]).length ? (c.abilities).map(a => `• ${a.name}${a.cost ? ` [${a.cost}]` : ''} — ${a.description}`).join('\n') : '• None.'}
2126${(c.skills||[]).length ? `\nSKILLS\n${c.skills.map(s => `• ${s.name} (${s.rank})`).join('\n')}` : ''}
2127CYPHER SYSTEM RULES — TASK RESOLUTION
2128You set difficulty 0–10; target number = difficulty × 3; player rolls d20.
2129Roll ≥ target = success. Special results: 1 = fumble/free GM Intrusion; 17–18 = minor effect; 19–20 = major effect.
2130Reducing difficulty: each asset (tool, positioning, help) −1 (max 2 from assets); trained skill −1; specialized skill −2; inability +1.
2131Effort: player may apply Effort to reduce difficulty by 1 per level; each Effort level costs 3 from a pool minus that pool's Edge.
2132
2133DAMAGE
2134Typical weapon damage: light 2 pts, medium 4 pts, heavy 6 pts. Damage depletes Might pool by default (Speed or Intellect if specified).
2135Damage track:
2136• Hale — all pools above 0, no penalties.
2137• Impaired — one pool at 0; all tasks hindered (difficulty +1).
2138• Debilitated — two pools at 0; can only move or take defensive actions.
2139• Dead — all pools at 0.
2140
2141RECOVERY ROLLS
2142Player rolls 1d6 + Tier and adds result to any one pool. Four recoveries per cycle (1 action → 10 min → 1 hour → 10 hours), then must rest to reset.
2143
2144GM INTRUSIONS
2145Occasionally introduce a complication: "GM Intrusion — [describe]. Accept for 2 XP (give 1 to another character), or spend 1 XP to refuse." On a player's natural 1, you may offer a free intrusion (they receive 1 XP; refusing is free).
2146
2147XP & ADVANCEMENT
2148XP is earned through intrusions and discoveries; 1 XP can be spent to reroll or refuse an intrusion.
2149To advance: spend 4 XP and gain one improvement (increase a pool max by 4, gain +1 Edge, train a skill, or another improvement). After 4 improvements, increase Tier (max Tier 6).
2150
2151CYPHERS
2152One-use items with powerful effects. This character's limit is ${c.cypherLimit}. When the character finds one, describe it and its effect. Remind the player when at their limit — carrying too many cyphers is unstable.
2153
2154STAT TRACKING — call update_character BEFORE writing narrative whenever: pools change, shins change, XP is awarded, items/cyphers are gained or lost, or Tier advances.
2155
2156NARRATIVE RULES
2157• Write 2–4 vivid, immersive paragraphs per turn.
2158• Give NPCs distinct voices and motivations.
2159• Make choices matter — show consequences immediately.
2160• End every turn with 2–3 numbered options OR "What do you do?"
2161
2162TONE: Cinematic. Dangerous. Immersive. Legendary.${notes ? `\n\nWORLD NOTES — ABSOLUTE TRUTHS\nThe GM has defined the following facts about this world. Treat them as immutable:\n\n${notes}` : ''}`;
2163}
2164
2165// ── STATE ─────────────────────────────────────────────
2166
2167const SETTINGS = {
2168 fantasy: { label: 'Classic Fantasy', currency: 'gold coins', magic: 'sorcery and spells', tone: 'Classic sword-and-sorcery: dungeons, dragons, ruins, taverns.' },
2169 numenera: { label: 'Ninth World (Numenera)',currency: 'shins', magic: 'esoteries and numenera', tone: 'Far future: civilisation built atop 8 prior worlds. Incomprehensible technology masquerades as magic. Strange mutations, ancient ruins, weird creatures.' },
2170 modern: { label: 'Modern Thriller', currency: 'dollars', magic: 'powers and abilities', tone: 'Contemporary world: urban environments, secret organisations, street-level action.' },
2171 scifi: { label: 'Science Fiction', currency: 'credits', magic: 'cybernetics and tech', tone: 'Space opera or near-future: starships, aliens, megacorps, advanced technology.' },
2172};
2173
2174let selectedDescriptor = null;
2175let selectedType = null;
2176let selectedFocus = null;
2177let selectedSetting = 'fantasy';
2178let gameState = null;
2179let conversationHistory = [];
2180let isBusy = false;
2181let currentApiKey = null;
2182let worldNotes = '';
2183
2184// ── IDs ───────────────────────────────────────────────
2185// player_id persists across games; game_id is fresh each adventure.
2186// Both are sent as metadata.user_id so they appear in the Anthropic
2187// usage dashboard for usage analytics.
2188
2189function randomId() {
2190 return Array.from(crypto.getRandomValues(new Uint8Array(8)), b => b.toString(16).padStart(2, '0')).join('');
2191}
2192
2193function getOrCreatePlayerId() {
2194 let id = localStorage.getItem('dm_player_id');
2195 if (!id) { id = randomId(); localStorage.setItem('dm_player_id', id); }
2196 return id;
2197}
2198
2199// Pricing in USD per 1M tokens — verify at anthropic.com/pricing
2200const MODEL_PRICING = {
2201 'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 },
2202 'claude-sonnet-4-5': { input: 3.00, output: 15.00 },
2203 'claude-opus-4-7': { input: 15.00, output: 75.00 },
2204};
2205
2206let sessionCost = 0;
2207let lastTurnCost = 0;
2208
2209function updateCostDisplay() {
2210 const fmt = n => `$${n.toFixed(4)}`;
2211 document.getElementById('cost-last-turn').textContent = lastTurnCost === 0 ? '—' : fmt(lastTurnCost);
2212 document.getElementById('cost-total').textContent = fmt(sessionCost);
2213}
2214
2215// ── BUG REPORT ────────────────────────────────────────
2216
2217function buildDebugInfo(userDesc) {
2218 const lines = [];
2219 if (userDesc) { lines.push(`User description:\n${userDesc}`, ''); }
2220 lines.push('--- App ---');
2221 lines.push(`Time: ${new Date().toISOString()}`);
2222 lines.push(`URL: ${location.origin}${location.pathname}`);
2223 lines.push(`Secure context: ${window.isSecureContext}`);
2224
2225 if (gameState) {
2226 const c = gameState.character;
2227 lines.push('', '--- Session ---');
2228 lines.push(`Player ID: p_${getOrCreatePlayerId()}`);
2229 if (gameState.gameId) lines.push(`Game ID: g_${gameState.gameId}`);
2230 lines.push(`Character: ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus}`);
2231 lines.push(`Might: ${c.might?.pool}/${c.might?.max} Speed: ${c.speed?.pool}/${c.speed?.max} Intellect: ${c.intellect?.pool}/${c.intellect?.max} XP: ${c.xp}`);
2232 lines.push(`Turns: ${conversationHistory.length} Cost: $${sessionCost.toFixed(5)}`);
2233 }
2234
2235 lines.push('', '--- Browser ---');
2236 lines.push(navigator.userAgent);
2237
2238 if (_recentErrors.length > 0) {
2239 lines.push('', '--- Recent errors ---');
2240 _recentErrors.forEach(e => lines.push(`[${e.t}]\n${e.msg}`));
2241 }
2242
2243 return lines.join('\n');
2244}
2245
2246// ── SESSION PERSISTENCE ───────────────────────────────
2247// Increment SAVE_VERSION on any breaking change to gameState shape or
2248// session format. tryResumeSession discards saves from older versions.
2249const SAVE_VERSION = 3;
2250
2251function saveSession() {
2252 if (!gameState) return;
2253 const { apiKey: _, ...stateToSave } = gameState;
2254 try {
2255 localStorage.setItem('dm_session', JSON.stringify({
2256 version: SAVE_VERSION,
2257 gameState: stateToSave,
2258 conversationHistory,
2259 messagesHTML: document.getElementById('messages').innerHTML,
2260 sessionCost,
2261 worldNotes,
2262 }));
2263 } catch (e) {
2264 console.warn('Session save failed (storage full?):', e);
2265 }
2266}
2267
2268async function tryResumeSession() {
2269 if (!currentApiKey) return false;
2270 const sessionRaw = localStorage.getItem('dm_session');
2271 if (!sessionRaw) return false;
2272
2273 let session;
2274 try { session = JSON.parse(sessionRaw); } catch { return false; }
2275
2276 if (session.version !== SAVE_VERSION) {
2277 console.log(`Save version mismatch (saved ${session.version ?? 'none'}, current ${SAVE_VERSION}) — offering export.`);
2278 showVersionMismatchModal(session);
2279 return true; // returning true suppresses the creation screen until the modal is dismissed
2280 }
2281
2282 gameState = { ...session.gameState, apiKey: currentApiKey };
2283 conversationHistory = session.conversationHistory || [];
2284
2285 document.getElementById('creation-screen').style.display = 'none';
2286 document.getElementById('game-screen').classList.add('visible');
2287 document.getElementById('messages').innerHTML = session.messagesHTML || '';
2288 document.getElementById('header-title').textContent =
2289 `${gameState.character.name} — ${gameState.character.descriptor} ${gameState.character.type}`;
2290
2291 sessionCost = session.sessionCost || 0;
2292 lastTurnCost = 0;
2293 worldNotes = session.worldNotes || '';
2294
2295 updateSidebar();
2296 buildDiceRoller();
2297 updateCostDisplay();
2298 syncWorldNotesSidebar();
2299
2300 const msgEl = document.getElementById('messages');
2301 msgEl.scrollTop = msgEl.scrollHeight;
2302
2303 console.log(`▶ Resumed session — ${conversationHistory.length} messages, Tier ${gameState.character.tier} ${gameState.character.name}, $${sessionCost.toFixed(5)} spent`);
2304 return true;
2305}
2306
2307// ── VERSION MISMATCH ──────────────────────────────
2308
2309function generateAdventureMarkdown(session) {
2310 const c = session.gameState?.character;
2311 const lines = ['# Solo Dungeon Master — Adventure Log', ''];
2312
2313 if (c) {
2314 // Cypher format (v2+)
2315 if (c.type) {
2316 lines.push(`**Character:** ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus}`);
2317 lines.push(`**Might:** ${c.might?.pool}/${c.might?.max} · **Speed:** ${c.speed?.pool}/${c.speed?.max} · **Intellect:** ${c.intellect?.pool}/${c.intellect?.max}`);
2318 lines.push(`**XP:** ${c.xp} · **Shins:** ${c.shins}`);
2319 if (c.cyphers?.length) lines.push(`**Cyphers:** ${c.cyphers.map(cy=>`${cy.name} (Lv${cy.level})`).join(', ')}`);
2320 if (c.equipment?.length) lines.push(`**Equipment:** ${c.equipment.join(', ')}`);
2321 } else {
2322 // Legacy D&D format (v0/v1)
2323 lines.push(`**Character:** ${c.name} — Level ${c.level} ${c.race} ${c.class}`);
2324 lines.push(`**HP:** ${c.hp}/${c.maxHp} · **XP:** ${c.xp} · **Gold:** ${c.gold} GP`);
2325 if (c.inventory?.length) lines.push(`**Inventory:** ${c.inventory.join(', ')}`);
2326 }
2327 }
2328 if (session.sessionCost) {
2329 lines.push(`**Session cost:** $${session.sessionCost.toFixed(4)}`);
2330 }
2331 if (session.worldNotes) {
2332 lines.push('', '## World Notes', '', session.worldNotes);
2333 }
2334
2335 lines.push('', '---', '', '## Adventure');
2336
2337 for (const msg of (session.conversationHistory || [])) {
2338 if (!msg.content || typeof msg.content !== 'string') continue;
2339 const speaker = msg.role === 'user' ? `**${c?.name || 'You'}**` : '**DM**';
2340 lines.push('', `${speaker}: ${msg.content}`);
2341 }
2342
2343 return lines.join('\n');
2344}
2345
2346function showVersionMismatchModal(session) {
2347 const modal = document.getElementById('version-modal');
2348 modal.classList.add('open');
2349
2350 document.getElementById('version-download-btn').onclick = () => {
2351 const md = generateAdventureMarkdown(session);
2352 const blob = new Blob([md], { type: 'text/markdown' });
2353 const url = URL.createObjectURL(blob);
2354 const a = document.createElement('a');
2355 const charName = session.gameState?.character?.name?.replace(/\s+/g, '-') || 'adventure';
2356 a.href = url;
2357 a.download = `${charName}-adventure-log.md`;
2358 a.click();
2359 URL.revokeObjectURL(url);
2360 };
2361
2362 const dismiss = () => {
2363 modal.classList.remove('open');
2364 localStorage.removeItem('dm_session');
2365 document.getElementById('creation-screen').style.display = '';
2366 };
2367 document.getElementById('version-discard-btn').onclick = dismiss;
2368}
2369
2370// ── CREATION SCREEN ───────────────────────────────────
2371
2372function updateCharSentence() {
2373 const d = selectedDescriptor?.name || '<em>___</em>';
2374 const t = selectedType?.name || '<em>___</em>';
2375 const f = selectedFocus?.name || '<em>___</em>';
2376 const wrap = n => `<span class="picked">${n}</span>`;
2377 document.getElementById('char-sentence').innerHTML =
2378 `I am a ${selectedDescriptor ? wrap(d) : d} ${selectedType ? wrap(t) : t} who ${selectedFocus ? wrap(f) : f}`;
2379}
2380
2381function makeSelGrid(gridId, items, getCurrent, setCurrent) {
2382 const grid = document.getElementById(gridId);
2383 grid.innerHTML = '';
2384 items.forEach(item => {
2385 const card = document.createElement('div');
2386 card.className = 'sel-card' + (getCurrent()?.name === item.name ? ' selected' : '');
2387 card.innerHTML = `<span class="icon">${item.icon}</span><span class="name">${item.name}</span>`;
2388 card.title = item.note || item.playstyle || '';
2389 card.addEventListener('click', () => {
2390 grid.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected'));
2391 card.classList.add('selected');
2392 setCurrent(item);
2393 updateCharSentence();
2394 checkBeginReady();
2395 });
2396 grid.appendChild(card);
2397 });
2398}
2399
2400function filteredFoci() {
2401 return FOCI.filter(f => f.settings.includes('all') || f.settings.includes(selectedSetting));
2402}
2403
2404function rebuildFocusGrid() {
2405 const filtered = filteredFoci();
2406 if (selectedFocus && !filtered.find(f => f.name === selectedFocus.name)) {
2407 selectedFocus = null;
2408 }
2409 makeSelGrid('focus-grid', filtered, () => selectedFocus, v => { selectedFocus = v; });
2410 updateCharSentence();
2411 checkBeginReady();
2412}
2413
2414async function buildCreationScreen() {
2415 makeSelGrid('descriptor-grid', DESCRIPTORS, () => selectedDescriptor, v => { selectedDescriptor = v; });
2416 makeSelGrid('type-grid', TYPES, () => selectedType, v => { selectedType = v; });
2417 rebuildFocusGrid();
2418 document.getElementById('begin-btn').addEventListener('click', () => beginAdventure());
2419 document.getElementById('change-key-btn').addEventListener('click', () => openSetupModal(getSharedBlob() ? 'shared' : 'own'));
2420}
2421
2422function checkBeginReady() {
2423 document.getElementById('begin-btn').disabled = !(selectedDescriptor && selectedType && selectedFocus);
2424}
2425
2426function buildStartingPools() {
2427 const t = selectedType;
2428 const d = selectedDescriptor;
2429 const bonus = d.poolBonus || {};
2430 return {
2431 might: { pool: t.pools.might + (bonus.might || 0), max: t.pools.might + (bonus.might || 0), edge: t.edge.might },
2432 speed: { pool: t.pools.speed + (bonus.speed || 0), max: t.pools.speed + (bonus.speed || 0), edge: t.edge.speed },
2433 intellect: { pool: t.pools.intellect + (bonus.intellect || 0), max: t.pools.intellect + (bonus.intellect || 0), edge: t.edge.intellect },
2434 };
2435}
2436
2437function mergeSkills(typeSkills, descriptorSkills) {
2438 const rankOrder = { inability: 0, trained: 1, specialized: 2 };
2439 const merged = {};
2440 for (const s of [...typeSkills, ...descriptorSkills]) {
2441 const key = s.name.toLowerCase();
2442 if (!merged[key]) {
2443 merged[key] = { ...s };
2444 } else if (merged[key].rank === 'trained' && s.rank === 'trained') {
2445 merged[key] = { ...s, rank: 'specialized' };
2446 } else if (rankOrder[s.rank] > rankOrder[merged[key].rank]) {
2447 merged[key] = { ...s };
2448 }
2449 }
2450 return Object.values(merged);
2451}
2452
2453async function beginAdventure() {
2454 if (!currentApiKey) { openSetupModal(getSharedBlob() ? 'shared' : 'own'); return; }
2455 const apiKey = currentApiKey;
2456
2457 const name = document.getElementById('char-name').value.trim() || `The ${selectedType.name}`;
2458 const theme = document.getElementById('adventure-theme').value.trim();
2459 worldNotes = document.getElementById('creation-world-notes').value.trim();
2460
2461 const pools = buildStartingPools();
2462
2463 gameState = {
2464 apiKey,
2465 gameId: randomId(),
2466 character: {
2467 name,
2468 descriptor: selectedDescriptor.name,
2469 type: selectedType.name,
2470 focus: selectedFocus.name,
2471 tier: 1,
2472 effort: 1,
2473 might: pools.might,
2474 speed: pools.speed,
2475 intellect: pools.intellect,
2476 damageTrack: 'hale',
2477 recoveryUsed: 0,
2478 advancementSteps: 0,
2479 cypherLimit: selectedType.cypherLimit,
2480 cyphers: [],
2481 skills: mergeSkills(selectedType.startingSkills || [], selectedDescriptor.startingSkills || []),
2482 abilities: [
2483 ...(selectedType.startingAbilities || []),
2484 ...(selectedFocus.tier1 ? [selectedFocus.tier1] : []),
2485 ],
2486 equipment: [...selectedType.equipment],
2487 shins: 10,
2488 xp: 0,
2489 setting: selectedSetting,
2490 },
2491 theme,
2492 icon: selectedType.icon,
2493 };
2494
2495 conversationHistory = [];
2496
2497 document.getElementById('creation-screen').style.display = 'none';
2498 document.getElementById('game-screen').classList.add('visible');
2499
2500 updateSidebar();
2501 buildDiceRoller();
2502 syncWorldNotesSidebar();
2503
2504 document.getElementById('header-title').textContent =
2505 `${name} — ${selectedDescriptor.name} ${selectedType.name}`;
2506
2507 const opening = `A new adventure begins. I am ${name} — a ${selectedDescriptor.name} ${selectedType.name} who ${selectedFocus.name}${theme ? `, setting out into ${theme}` : ''}. Introduce the world and set the opening scene.`;
2508 sendMessage(opening, false);
2509}
2510
2511// ── SIDEBAR ───────────────────────────────────────────
2512
2513function updateSidebar() {
2514 const c = gameState.character;
2515
2516 document.getElementById('char-icon').textContent = gameState.icon;
2517 document.getElementById('char-name-display').textContent = c.name;
2518 document.getElementById('char-identity-sub').textContent = `${c.descriptor} ${c.type} · ${c.focus}`;
2519
2520 // Pool bars
2521 const pools = ['might', 'speed', 'intellect'];
2522 pools.forEach(stat => {
2523 const pool = c[stat];
2524 const pct = pool.max > 0 ? Math.max(0, (pool.pool / pool.max) * 100) : 0;
2525 document.getElementById(`${stat}-display`).textContent = `${pool.pool} / ${pool.max}`;
2526 document.getElementById(`${stat}-edge`).textContent = pool.edge > 0 ? `Edge ${pool.edge}` : '';
2527 const fill = document.getElementById(`${stat}-fill`);
2528 fill.style.width = pct + '%';
2529 fill.className = 'bar-fill';
2530 if (pct <= 25) fill.classList.add('danger');
2531 else if (pct <= 50) fill.classList.add('warning');
2532 });
2533
2534 // Damage track
2535 const badge = document.getElementById('dt-badge');
2536 badge.className = `dt-badge ${c.damageTrack}`;
2537 document.getElementById('dt-label').textContent =
2538 c.damageTrack.charAt(0).toUpperCase() + c.damageTrack.slice(1);
2539
2540 // XP and Tier
2541 document.getElementById('xp-display').textContent = c.xp;
2542 const tierNumerals = ['I','II','III','IV','V','VI'];
2543 const tierIdx = (c.tier || 1) - 1;
2544 document.getElementById('tier-display').textContent = tierNumerals[tierIdx] || c.tier;
2545
2546 // Advancement dots
2547 const steps = c.advancementSteps || 0;
2548 const advDots = document.getElementById('adv-dots');
2549 advDots.innerHTML = '';
2550 for (let i = 0; i < 4; i++) {
2551 const dot = document.createElement('span');
2552 dot.className = 'adv-dot' + (i < steps ? ' done' : '');
2553 advDots.appendChild(dot);
2554 }
2555 const nextTierLabel = c.tier < 6 ? `Tier ${tierNumerals[tierIdx + 1] || c.tier + 1}` : 'Max Tier';
2556 document.getElementById('adv-label').textContent = `→ ${nextTierLabel}`;
2557 const spendBtn = document.getElementById('spend-xp-btn');
2558 spendBtn.disabled = isBusy || c.xp < 4 || c.tier >= 6;
2559
2560 // Shins (show setting currency label if available)
2561 const settingDef = SETTINGS[c.setting] || SETTINGS.fantasy;
2562 const currencyName = settingDef.currency.split(' ')[0];
2563 document.getElementById('shins-display').textContent = `${c.shins} ${currencyName.charAt(0).toUpperCase() + currencyName.slice(1)}`;
2564
2565 // Cyphers
2566 const cypherList = document.getElementById('cyphers-list');
2567 document.getElementById('cyphers-title').textContent =
2568 `Cyphers (${c.cyphers.length}/${c.cypherLimit})`;
2569 if (c.cyphers.length === 0) {
2570 cypherList.innerHTML = '<span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">No cyphers yet.</span>';
2571 } else {
2572 cypherList.innerHTML = '';
2573 c.cyphers.forEach(cypher => {
2574 const div = document.createElement('div');
2575 div.className = 'cypher-entry';
2576 div.innerHTML = `<span class="cypher-name">${cypher.name} <small>(lvl ${cypher.level})</small></span>`
2577 + `<span class="cypher-effect">${cypher.effect}</span>`;
2578 const useBtn = document.createElement('button');
2579 useBtn.className = 'cypher-use-btn';
2580 useBtn.textContent = 'Use';
2581 useBtn.addEventListener('click', () => useCypher(cypher.name));
2582 div.appendChild(useBtn);
2583 cypherList.appendChild(div);
2584 });
2585 }
2586 if (c.cyphers.length >= c.cypherLimit) {
2587 cypherList.classList.add('cypher-limit-warn');
2588 } else {
2589 cypherList.classList.remove('cypher-limit-warn');
2590 }
2591
2592 // Abilities
2593 const abList = document.getElementById('abilities-list');
2594 abList.innerHTML = '';
2595 if (c.abilities?.length) {
2596 c.abilities.forEach(ab => {
2597 const div = document.createElement('div');
2598 div.className = 'ability-entry';
2599 div.innerHTML = `<span class="ability-name">${ab.name}${ab.cost ? ` <span class="ability-cost">(${ab.cost})</span>` : ''}</span>`
2600 + `<span class="ability-desc">${ab.description}</span>`;
2601 abList.appendChild(div);
2602 });
2603 } else {
2604 abList.innerHTML = '<span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">None yet.</span>';
2605 }
2606
2607 // Skills
2608 const skillsEl = document.getElementById('skills-list');
2609 skillsEl.innerHTML = '';
2610 if (c.skills?.length) {
2611 c.skills.forEach(sk => {
2612 const div = document.createElement('div');
2613 div.className = 'skill-entry';
2614 div.innerHTML = `<span class="skill-name">${sk.name}</span>`
2615 + `<span class="skill-rank ${sk.rank}">${sk.rank}</span>`;
2616 skillsEl.appendChild(div);
2617 });
2618 } else {
2619 skillsEl.innerHTML = '<span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">No skills yet.</span>';
2620 }
2621
2622 // Equipment
2623 const list = document.getElementById('inventory-list');
2624 list.innerHTML = '';
2625 (c.equipment || []).forEach(item => {
2626 const li = document.createElement('li');
2627 li.textContent = item;
2628 list.appendChild(li);
2629 });
2630
2631 // Recovery pips
2632 const recoveryUsed = c.recoveryUsed || 0;
2633 const pipsEl = document.getElementById('rec-pips');
2634 const recoveryTips = ['1 action', '10 minutes', '1 hour', '10 hours'];
2635 pipsEl.innerHTML = '';
2636 for (let i = 0; i < 4; i++) {
2637 const pip = document.createElement('span');
2638 pip.className = 'rec-pip' + (i < recoveryUsed ? ' used' : '');
2639 pip.title = recoveryTips[i];
2640 pipsEl.appendChild(pip);
2641 }
2642 const recBtn = document.getElementById('recovery-roll-btn');
2643 if (recoveryUsed >= 4) {
2644 recBtn.disabled = isBusy;
2645 recBtn.textContent = '🛌 Full rest (reset slots)';
2646 } else {
2647 recBtn.disabled = isBusy;
2648 recBtn.textContent = '🎲 Recovery Roll (1d6 + Tier)';
2649 }
2650}
2651
2652function applyStatUpdate(update) {
2653 const c = gameState.character;
2654
2655 // Pool damage / restore
2656 const pools = ['might', 'speed', 'intellect'];
2657 pools.forEach(stat => {
2658 const dmg = update[`${stat}_damage`] || 0;
2659 const rst = update[`${stat}_restore`] || 0;
2660 if (dmg) c[stat].pool = Math.max(0, c[stat].pool - dmg);
2661 if (rst) c[stat].pool = Math.min(c[stat].max, c[stat].pool + rst);
2662 });
2663
2664 // Recalculate damage track
2665 const zeroPools = pools.filter(s => c[s].pool === 0).length;
2666 if (zeroPools === 0) c.damageTrack = 'hale';
2667 else if (zeroPools === 1) c.damageTrack = 'impaired';
2668 else if (zeroPools === 2) c.damageTrack = 'debilitated';
2669
2670 // XP and shins
2671 if (update.xp_gain) c.xp = (c.xp || 0) + update.xp_gain;
2672 if (update.shins_change) c.shins = Math.max(0, (c.shins || 0) + update.shins_change);
2673
2674 // Recovery used (Claude marks a slot; actual roll happens via the sidebar button)
2675 if (update.recovery_used) {
2676 c.recoveryUsed = Math.min(4, (c.recoveryUsed || 0) + 1);
2677 }
2678
2679 // Cyphers
2680 if (update.cyphers_add?.length) {
2681 c.cyphers.push(...update.cyphers_add);
2682 if (c.cyphers.length > c.cypherLimit) {
2683 addSystemMessage(`⚠️ You are carrying ${c.cyphers.length} cyphers — over your limit of ${c.cypherLimit}! Use one before it causes instability.`);
2684 }
2685 }
2686 if (update.cyphers_remove?.length) {
2687 update.cyphers_remove.forEach(name => {
2688 const idx = c.cyphers.findIndex(cy => cy.name.toLowerCase().includes(name.toLowerCase()));
2689 if (idx !== -1) c.cyphers.splice(idx, 1);
2690 });
2691 }
2692
2693 // Skills
2694 if (update.skills_add?.length) {
2695 const rankOrder = { inability: 0, trained: 1, specialized: 2 };
2696 update.skills_add.forEach(({ name, rank }) => {
2697 if (!name || !rank) return;
2698 const existing = (c.skills || []).find(s => s.name.toLowerCase() === name.toLowerCase());
2699 if (existing) {
2700 if (existing.rank === 'trained' && rank === 'trained') {
2701 existing.rank = 'specialized';
2702 } else if (rankOrder[rank] > rankOrder[existing.rank]) {
2703 existing.rank = rank;
2704 }
2705 } else {
2706 c.skills = [...(c.skills || []), { name, rank }];
2707 }
2708 });
2709 }
2710
2711 // Equipment
2712 if (update.equipment_add?.length) c.equipment.push(...update.equipment_add);
2713 if (update.equipment_remove?.length) {
2714 update.equipment_remove.forEach(item => {
2715 const idx = c.equipment.findIndex(e => e.toLowerCase().includes(item.toLowerCase()));
2716 if (idx !== -1) c.equipment.splice(idx, 1);
2717 });
2718 }
2719
2720 // Tier advance
2721 let tieredUp = false;
2722 if (update.tier_advance && c.tier < 6) {
2723 c.tier++;
2724 c.effort = c.tier;
2725 tieredUp = true;
2726 }
2727
2728 updateSidebar();
2729
2730 if (update.reason) {
2731 addSystemMessage(`⚡ ${update.reason}`);
2732 }
2733
2734 if (tieredUp) {
2735 showTierUp(c.tier);
2736 }
2737
2738 // Death: all three pools at 0
2739 if (pools.every(s => c[s].pool === 0)) {
2740 addSystemMessage('💀 All your pools have been depleted. Your adventure ends here... for now.');
2741 setBusy(true);
2742 }
2743}
2744
2745function showTierUp(tier) {
2746 const tierNumerals = ['I','II','III','IV','V','VI'];
2747 const toast = document.getElementById('levelup-toast');
2748 document.getElementById('toast-title').textContent = `TIER ${tierNumerals[tier - 1] || tier}!`;
2749 document.getElementById('toast-sub').textContent = `${gameState.character.name} has grown in power. New abilities await.`;
2750 toast.style.display = 'block';
2751 setTimeout(() => {
2752 if (toast.style.display === 'block') toast.style.display = 'none';
2753 }, 6000);
2754}
2755
2756function useCypher(name) {
2757 if (isBusy || !gameState) return;
2758 const c = gameState.character;
2759 const cypher = c.cyphers.find(cy => cy.name === name);
2760 if (!cypher) return;
2761 c.cyphers = c.cyphers.filter(cy => cy.name !== name);
2762 updateSidebar();
2763 saveSession();
2764 sendMessage(`I activate my ${cypher.name} cypher (level ${cypher.level}): ${cypher.effect}`, true);
2765}
2766
2767let _advChoice = null;
2768
2769function openAdvancementModal() {
2770 if (isBusy || !gameState) return;
2771 const c = gameState.character;
2772 if (c.xp < 4 || c.tier >= 6) return;
2773 _advChoice = null;
2774 document.querySelectorAll('.adv-option').forEach(el => el.classList.remove('selected'));
2775 document.getElementById('adv-sub').innerHTML = '';
2776 document.getElementById('adv-confirm-btn').disabled = true;
2777 const trainedSkills = (c.skills || []).filter(s => s.rank === 'trained');
2778 document.querySelector('.adv-option[data-opt="specialize"]')
2779 .classList.toggle('unavailable', trainedSkills.length === 0);
2780 document.getElementById('advancement-modal').classList.add('open');
2781}
2782
2783function selectAdvOption(opt) {
2784 _advChoice = { type: opt };
2785 document.querySelectorAll('.adv-option').forEach(el => el.classList.remove('selected'));
2786 document.querySelector(`.adv-option[data-opt="${opt}"]`).classList.add('selected');
2787 const sub = document.getElementById('adv-sub');
2788 const confirmBtn = document.getElementById('adv-confirm-btn');
2789 confirmBtn.disabled = true;
2790 const c = gameState.character;
2791
2792 if (opt === 'pool' || opt === 'edge') {
2793 const label = opt === 'pool' ? 'Which pool to increase by 4:' : 'Which stat to gain +1 Edge:';
2794 const hint = opt === 'edge'
2795 ? `<div style="font-size:0.72rem;color:var(--text-dim);margin-top:6px">Current: Might ${c.might.edge} · Speed ${c.speed.edge} · Intellect ${c.intellect.edge}</div>`
2796 : '';
2797 sub.innerHTML = `<div class="adv-sub-label">${label}</div>
2798 <div class="adv-stat-btns">
2799 <button class="adv-stat-btn" data-stat="might">Might</button>
2800 <button class="adv-stat-btn" data-stat="speed">Speed</button>
2801 <button class="adv-stat-btn" data-stat="intellect">Intellect</button>
2802 </div>${hint}`;
2803 sub.querySelectorAll('.adv-stat-btn').forEach(btn => {
2804 btn.addEventListener('click', () => {
2805 sub.querySelectorAll('.adv-stat-btn').forEach(b => b.classList.remove('selected'));
2806 btn.classList.add('selected');
2807 _advChoice.stat = btn.dataset.stat;
2808 confirmBtn.disabled = false;
2809 });
2810 });
2811 } else if (opt === 'train') {
2812 sub.innerHTML = `<div class="adv-sub-label">Skill name to train (or upgrade inability → trained):</div>
2813 <input class="adv-skill-input" id="adv-skill-name" type="text" placeholder="e.g. Climbing, Stealth, History…" maxlength="60">`;
2814 const input = document.getElementById('adv-skill-name');
2815 input.addEventListener('input', () => { confirmBtn.disabled = input.value.trim().length === 0; });
2816 setTimeout(() => input.focus(), 50);
2817 } else if (opt === 'specialize') {
2818 const trained = (c.skills || []).filter(s => s.rank === 'trained');
2819 sub.innerHTML = `<div class="adv-sub-label">Choose a trained skill to specialize in:</div>
2820 <div class="adv-skill-list">${trained.map(s =>
2821 `<div class="adv-skill-item" data-skill="${s.name}">${s.name}<span class="skill-rank trained" style="font-size:0.6rem">TRAINED</span></div>`
2822 ).join('')}</div>`;
2823 sub.querySelectorAll('.adv-skill-item').forEach(item => {
2824 item.addEventListener('click', () => {
2825 sub.querySelectorAll('.adv-skill-item').forEach(i => i.classList.remove('selected'));
2826 item.classList.add('selected');
2827 _advChoice.skillName = item.dataset.skill;
2828 confirmBtn.disabled = false;
2829 });
2830 });
2831 } else if (opt === 'other') {
2832 sub.innerHTML = `<div class="adv-sub-label">Describe your improvement (Claude will narrate it):</div>
2833 <input class="adv-skill-input" id="adv-other-desc" type="text" placeholder="e.g. Gain a contact in the city…" maxlength="120">`;
2834 const input = document.getElementById('adv-other-desc');
2835 input.addEventListener('input', () => { confirmBtn.disabled = input.value.trim().length === 0; });
2836 setTimeout(() => input.focus(), 50);
2837 }
2838}
2839
2840function confirmAdvancement() {
2841 if (!_advChoice || !gameState) return;
2842 const c = gameState.character;
2843 if (c.xp < 4 || c.tier >= 6) return;
2844 c.xp -= 4;
2845 c.advancementSteps = (c.advancementSteps || 0) + 1;
2846 const tierNumerals = ['I','II','III','IV','V','VI'];
2847 let msg = '';
2848 const { type, stat } = _advChoice;
2849
2850 if (type === 'pool' && stat) {
2851 c[stat].max += 4;
2852 c[stat].pool = Math.min(c[stat].pool + 4, c[stat].max);
2853 msg = `📈 ${stat[0].toUpperCase() + stat.slice(1)} pool max increased by 4 (now ${c[stat].max}).`;
2854 } else if (type === 'edge' && stat) {
2855 c[stat].edge += 1;
2856 msg = `⚔️ ${stat[0].toUpperCase() + stat.slice(1)} Edge increased to ${c[stat].edge}.`;
2857 } else if (type === 'train') {
2858 const name = document.getElementById('adv-skill-name')?.value.trim();
2859 if (!name) { c.xp += 4; c.advancementSteps -= 1; return; }
2860 const existing = (c.skills || []).find(s => s.name.toLowerCase() === name.toLowerCase());
2861 if (existing) {
2862 if (existing.rank === 'inability') { existing.rank = 'trained'; msg = `🎓 ${existing.name} reduced from Inability to Trained.`; }
2863 else if (existing.rank === 'trained') { existing.rank = 'specialized'; msg = `⭐ ${existing.name} upgraded from Trained to Specialized.`; }
2864 else { msg = `🎓 Already Specialized in ${existing.name}.`; }
2865 } else {
2866 c.skills = [...(c.skills || []), { name, rank: 'trained' }];
2867 msg = `🎓 Now trained in ${name}.`;
2868 }
2869 } else if (type === 'specialize' && _advChoice.skillName) {
2870 const skill = (c.skills || []).find(s => s.name === _advChoice.skillName);
2871 if (skill) { skill.rank = 'specialized'; msg = `⭐ ${skill.name} upgraded from Trained to Specialized.`; }
2872 } else if (type === 'other') {
2873 const desc = document.getElementById('adv-other-desc')?.value.trim();
2874 if (!desc) { c.xp += 4; c.advancementSteps -= 1; return; }
2875 msg = `✨ Advancement: ${desc}`;
2876 }
2877
2878 document.getElementById('advancement-modal').classList.remove('open');
2879 _advChoice = null;
2880
2881 if (c.advancementSteps >= 4) {
2882 c.advancementSteps = 0;
2883 c.tier = Math.min(6, c.tier + 1);
2884 c.effort = c.tier;
2885 updateSidebar(); saveSession();
2886 showTierUp(c.tier);
2887 addSystemMessage(`${msg}\n⚡ All 4 advancements complete — Tier ${tierNumerals[c.tier - 1]}!`);
2888 } else {
2889 updateSidebar(); saveSession();
2890 addSystemMessage(`${msg} (Step ${c.advancementSteps}/4 — ${4 - c.advancementSteps} more to Tier ${tierNumerals[c.tier] || c.tier + 1}.)`);
2891 }
2892}
2893
2894function rollRecovery() {
2895 if (isBusy || !gameState) return;
2896 const c = gameState.character;
2897 const used = c.recoveryUsed || 0;
2898
2899 if (used >= 4) {
2900 // Full rest: reset slots
2901 c.recoveryUsed = 0;
2902 updateSidebar();
2903 saveSession();
2904 addSystemMessage('🛌 Full rest taken — recovery slots reset.');
2905 return;
2906 }
2907
2908 const dieRoll = Math.floor(Math.random() * 6) + 1;
2909 const total = dieRoll + (c.tier || 1);
2910
2911 // Add to most depleted pool (lowest current value)
2912 const statNames = ['might', 'speed', 'intellect'];
2913 const target = statNames.reduce((lowest, s) => c[s].pool < c[lowest].pool ? s : lowest, statNames[0]);
2914 const before = c[target].pool;
2915 c[target].pool = Math.min(c[target].max, c[target].pool + total);
2916 const gained = c[target].pool - before;
2917
2918 c.recoveryUsed = used + 1;
2919
2920 // Recalculate damage track
2921 const zeroPools = statNames.filter(s => c[s].pool === 0).length;
2922 if (zeroPools === 0) c.damageTrack = 'hale';
2923 else if (zeroPools === 1) c.damageTrack = 'impaired';
2924 else c.damageTrack = 'debilitated';
2925
2926 updateSidebar();
2927 saveSession();
2928
2929 const recoveryLabels = ['1-action', '10-minute', '1-hour', '10-hour'];
2930 const label = recoveryLabels[c.recoveryUsed - 1];
2931 const statLabel = target.charAt(0).toUpperCase() + target.slice(1);
2932 addSystemMessage(`🎲 ${label} recovery — 1d6 (${dieRoll}) + Tier ${c.tier} = **${total}** → +${gained} ${statLabel} (now ${c[target].pool}/${c[target].max}).${c.recoveryUsed >= 4 ? ' All recovery slots used — click again to take a full rest.' : ''}`);
2933}
2934
2935// ── DICE ROLLER ───────────────────────────────────────
2936
2937let selectedDie = 20;
2938
2939function buildDiceRoller() {
2940 const container = document.getElementById('dice-types');
2941 container.innerHTML = '';
2942 DIE_TYPES.forEach(d => {
2943 const btn = document.createElement('button');
2944 btn.className = 'die-btn' + (d === selectedDie ? ' selected' : '');
2945 btn.textContent = `d${d}`;
2946 btn.addEventListener('click', () => {
2947 document.querySelectorAll('.die-btn').forEach(b => b.classList.remove('selected'));
2948 btn.classList.add('selected');
2949 selectedDie = d;
2950 });
2951 container.appendChild(btn);
2952 });
2953
2954 document.getElementById('roll-btn').addEventListener('click', rollDice);
2955}
2956
2957// ── WORLD NOTES ───────────────────────────────────────
2958
2959function syncWorldNotesSidebar() {
2960 const ta = document.getElementById('world-notes-textarea');
2961 if (ta) ta.value = worldNotes;
2962}
2963
2964function addWorldNotesEvent(notes) {
2965 const msgs = document.getElementById('messages');
2966 const banner = document.createElement('div');
2967 banner.className = 'world-notes-event';
2968 const title = document.createElement('div');
2969 title.className = 'wne-title';
2970 title.textContent = '📜 World Notes Updated';
2971 banner.appendChild(title);
2972 const body = document.createElement('div');
2973 body.className = 'wne-body';
2974 body.textContent = notes || '(cleared)';
2975 banner.appendChild(body);
2976 msgs.appendChild(banner);
2977 msgs.scrollTop = msgs.scrollHeight;
2978}
2979
2980async function applyWorldNotes() {
2981 const ta = document.getElementById('world-notes-textarea');
2982 worldNotes = ta ? ta.value.trim() : '';
2983 saveSession();
2984 addWorldNotesEvent(worldNotes);
2985 if (worldNotes) {
2986 const metaMsg = `[GM WORLD UPDATE] The world context has been updated by the GM outside the narrative. New world notes: "${worldNotes}". Acknowledge this shift in 2–3 immersive sentences — describe a subtle change felt in the world, a cosmic tremor, or a moment of heightened awareness that the character perceives. Then invite the player to continue.`;
2987 await sendMessage(metaMsg, false);
2988 }
2989}
2990
2991document.getElementById('world-notes-save-btn').addEventListener('click', () => {
2992 if (!gameState) return;
2993 applyWorldNotes();
2994});
2995
2996document.getElementById('spend-xp-btn').addEventListener('click', openAdvancementModal);
2997document.querySelectorAll('.adv-option').forEach(opt => {
2998 opt.addEventListener('click', () => { if (!opt.classList.contains('unavailable')) selectAdvOption(opt.dataset.opt); });
2999});
3000document.getElementById('adv-cancel-btn').addEventListener('click', () => {
3001 document.getElementById('advancement-modal').classList.remove('open');
3002});
3003document.getElementById('adv-confirm-btn').addEventListener('click', confirmAdvancement);
3004document.getElementById('advancement-modal').addEventListener('click', e => {
3005 if (e.target === document.getElementById('advancement-modal'))
3006 document.getElementById('advancement-modal').classList.remove('open');
3007});
3008document.getElementById('recovery-roll-btn').addEventListener('click', () => rollRecovery());
3009
3010document.getElementById('setting-row').addEventListener('click', e => {
3011 const btn = e.target.closest('.setting-btn');
3012 if (!btn) return;
3013 document.querySelectorAll('.setting-btn').forEach(b => b.classList.remove('selected'));
3014 btn.classList.add('selected');
3015 selectedSetting = btn.dataset.setting;
3016 rebuildFocusGrid();
3017});
3018
3019function rollDice() {
3020 if (isBusy) return;
3021 const count = Math.min(10, Math.max(1, parseInt(document.getElementById('dice-count').value, 10) || 1));
3022 const rolls = Array.from({ length: count }, () => Math.floor(Math.random() * selectedDie) + 1);
3023 const total = rolls.reduce((a, b) => a + b, 0);
3024
3025 document.getElementById('dice-result').textContent = `${total}`;
3026 document.getElementById('dice-result-detail').textContent =
3027 count > 1 ? `${count}d${selectedDie}: [${rolls.join(', ')}]` : `d${selectedDie}`;
3028
3029 const rollMsg = count > 1
3030 ? `🎲 I roll ${count}d${selectedDie} — [${rolls.join(', ')}] = **${total}**`
3031 : `🎲 I roll a d${selectedDie} — **${total}**`;
3032
3033 sendMessage(rollMsg, true);
3034}
3035
3036// ── CHAT ─────────────────────────────────────────────
3037
3038function addSystemMessage(text) {
3039 const el = document.createElement('div');
3040 el.className = 'msg msg-system';
3041 el.textContent = text;
3042 document.getElementById('messages').appendChild(el);
3043 scrollToBottom();
3044}
3045
3046function addPlayerMessage(text) {
3047 const el = document.createElement('div');
3048 el.className = 'msg msg-player';
3049 el.textContent = text;
3050 document.getElementById('messages').appendChild(el);
3051 scrollToBottom();
3052 return el;
3053}
3054
3055function addDMMessage() {
3056 const el = document.createElement('div');
3057 el.className = 'msg msg-dm';
3058 document.getElementById('messages').appendChild(el);
3059
3060 const dots = document.createElement('div');
3061 dots.className = 'typing-dots';
3062 dots.innerHTML = '<span></span><span></span><span></span>';
3063 el.appendChild(dots);
3064
3065 scrollToBottom();
3066 return el;
3067}
3068
3069function updateDMMessage(el, rawText) {
3070 el.style.whiteSpace = 'pre-wrap';
3071 el.textContent = rawText;
3072 scrollToBottom();
3073}
3074
3075function finalizeDMMessage(el, rawText) {
3076 el.style.whiteSpace = '';
3077 el.innerHTML = formatDMText(rawText);
3078 // Make clickable options
3079 el.querySelectorAll('.option').forEach(opt => {
3080 opt.addEventListener('click', () => {
3081 if (!isBusy) sendMessage(opt.dataset.text, true);
3082 });
3083 });
3084 scrollToBottom();
3085}
3086
3087function formatDMText(raw) {
3088 const paragraphs = raw.split(/\n\n+/);
3089 let options = [];
3090 let mainParas = [];
3091
3092 paragraphs.forEach(para => {
3093 // Detect numbered option lists (lines like "1. ..." "2. ..." etc.)
3094 const lines = para.split('\n');
3095 const isOptionBlock = lines.every(l => /^\d+\.\s/.test(l.trim()) || l.trim() === '');
3096 if (isOptionBlock && lines.filter(l => /^\d+\.\s/.test(l.trim())).length >= 2) {
3097 options = lines.filter(l => /^\d+\.\s/.test(l.trim()));
3098 } else {
3099 mainParas.push(para);
3100 }
3101 });
3102
3103 let html = mainParas.map(p => {
3104 if (!p.trim()) return '';
3105 let text = p
3106 .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
3107 .replace(/\*(.+?)\*/g, '<em>$1</em>')
3108 .replace(/\n/g, '<br>');
3109 return `<p>${text}</p>`;
3110 }).join('');
3111
3112 if (options.length > 0) {
3113 const optHtml = options.map(line => {
3114 const m = line.match(/^(\d+)\.\s+(.+)$/);
3115 if (!m) return '';
3116 return `<div class="option" data-text="${escHtml(line)}">
3117 <span class="opt-num">${m[1]}.</span>
3118 <span class="opt-text">${m[2]}</span>
3119 </div>`;
3120 }).join('');
3121 html += `<div class="options">${optHtml}</div>`;
3122 }
3123
3124 return html;
3125}
3126
3127function escHtml(s) {
3128 return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3129}
3130
3131function scrollToBottom() {
3132 const msgs = document.getElementById('messages');
3133 msgs.scrollTop = msgs.scrollHeight;
3134}
3135
3136// ── MAIN SEND ─────────────────────────────────────────
3137
3138async function sendMessage(text, showAsPlayer = true) {
3139 if (isBusy || !text.trim()) return;
3140
3141 setBusy(true);
3142
3143 if (showAsPlayer) addPlayerMessage(text);
3144
3145 const userMsg = { role: 'user', content: text };
3146 conversationHistory.push(userMsg);
3147
3148 const dmEl = addDMMessage();
3149 let rawText = '';
3150
3151 try {
3152 let apiMessages = [...conversationHistory];
3153 let statUpdate = null;
3154 let firstChunk = true;
3155 let loopIteration = 0;
3156 let turnInputTokens = 0;
3157 let turnOutputTokens = 0;
3158
3159 while (true) {
3160 loopIteration++;
3161 const keyHint = gameState.apiKey.slice(0, 10) + '…' + gameState.apiKey.slice(-4);
3162 const requestBody = {
3163 model: 'claude-haiku-4-5-20251001',
3164 max_tokens: 1500,
3165 stream: true,
3166 system: buildSystemPrompt(gameState.character, gameState.theme, worldNotes),
3167 tools: [UPDATE_TOOL],
3168 messages: apiMessages,
3169 metadata: { user_id: `p_${getOrCreatePlayerId()}:g_${gameState.gameId}` },
3170 };
3171
3172 console.group(`🎲 DM API call #${loopIteration} (turn ${conversationHistory.length})`);
3173 console.log('key:', keyHint);
3174 console.log('messages:', apiMessages.length);
3175 console.log('model:', requestBody.model);
3176 console.log('secure context:', window.isSecureContext);
3177 console.groupEnd();
3178
3179 let response;
3180 try {
3181 response = await fetch('https://api.anthropic.com/v1/messages', {
3182 method: 'POST',
3183 headers: {
3184 'Content-Type': 'application/json',
3185 'x-api-key': gameState.apiKey,
3186 'anthropic-version': '2023-06-01',
3187 'anthropic-dangerous-direct-browser-access': 'true',
3188 },
3189 body: JSON.stringify(requestBody),
3190 });
3191 } catch (fetchErr) {
3192 // fetch() itself threw — CORS preflight rejected or network down
3193 console.error('🔴 fetch() threw (CORS / network):', fetchErr);
3194 throw new Error(
3195 `Network error — could not reach Anthropic. Likely causes:\n` +
3196 `• Invalid or revoked API key\n` +
3197 `• No internet connection\n` +
3198 `• CORS preflight blocked (check console)\n` +
3199 `(${fetchErr.message})`
3200 );
3201 }
3202
3203 console.log(`↩ Response: HTTP ${response.status} ${response.statusText}`);
3204
3205 if (!response.ok) {
3206 let errBody = {};
3207 try { errBody = await response.json(); } catch { /* CORS may block reading error body */ }
3208 console.error('🔴 API error body:', errBody);
3209 const msg = errBody.error?.message || errBody.message;
3210 throw new Error(
3211 msg
3212 ? `Anthropic API error (${response.status}): ${msg}`
3213 : `Anthropic returned HTTP ${response.status}. ` +
3214 `Check your API key and account credits. (CORS may be hiding the full error — open Network tab)`
3215 );
3216 }
3217
3218 const reader = response.body.getReader();
3219 const decoder = new TextDecoder();
3220 let buf = '';
3221 let blocks = {};
3222 let stopReason = null;
3223 let eventCount = 0;
3224
3225 while (true) {
3226 const { done, value } = await reader.read();
3227 if (done) break;
3228
3229 buf += decoder.decode(value, { stream: true });
3230 const lines = buf.split('\n');
3231 buf = lines.pop() ?? '';
3232
3233 for (const line of lines) {
3234 if (!line.startsWith('data: ')) continue;
3235 let data;
3236 try { data = JSON.parse(line.slice(6)); } catch { continue; }
3237 eventCount++;
3238
3239 if (data.type === 'message_start') {
3240 turnInputTokens += data.message?.usage?.input_tokens ?? 0;
3241 } else if (data.type === 'content_block_start') {
3242 blocks[data.index] = { ...data.content_block, _buf: '' };
3243 console.log(` ▶ block_start [${data.index}] type=${data.content_block.type}`);
3244 } else if (data.type === 'content_block_delta') {
3245 const block = blocks[data.index];
3246 if (!block) continue;
3247 if (data.delta.type === 'text_delta') {
3248 if (firstChunk) { dmEl.innerHTML = ''; firstChunk = false; }
3249 block._buf += data.delta.text;
3250 rawText += data.delta.text;
3251 updateDMMessage(dmEl, rawText);
3252 } else if (data.delta.type === 'input_json_delta') {
3253 block._buf += data.delta.partial_json;
3254 }
3255 } else if (data.type === 'content_block_stop') {
3256 const block = blocks[data.index];
3257 if (block?.type === 'tool_use') {
3258 try { block.input = JSON.parse(block._buf || '{}'); } catch { block.input = {}; }
3259 console.log(` 🛠 tool_use: ${block.name}`, block.input);
3260 }
3261 } else if (data.type === 'message_delta') {
3262 stopReason = data.delta.stop_reason;
3263 turnOutputTokens += data.usage?.output_tokens ?? 0;
3264 console.log(` ✓ message_delta stop_reason=${stopReason} events=${eventCount}`);
3265 } else if (data.type === 'error') {
3266 console.error(' 🔴 stream error event:', data);
3267 }
3268 }
3269 }
3270
3271 const allBlocks = Object.entries(blocks)
3272 .sort(([a], [b]) => Number(a) - Number(b))
3273 .map(([, b]) => b);
3274
3275 const toolBlocks = allBlocks.filter(b => b.type === 'tool_use');
3276 if (toolBlocks.length > 0) statUpdate = toolBlocks[toolBlocks.length - 1].input;
3277
3278 if (stopReason !== 'tool_use' || toolBlocks.length === 0) break;
3279
3280 console.log(` ↺ tool_use loop: adding tool_result and continuing`);
3281 const assistantContent = allBlocks.map(b =>
3282 b.type === 'text'
3283 ? { type: 'text', text: b._buf }
3284 : { type: 'tool_use', id: b.id, name: b.name, input: b.input }
3285 );
3286
3287 apiMessages = [
3288 ...apiMessages,
3289 { role: 'assistant', content: assistantContent },
3290 {
3291 role: 'user',
3292 content: toolBlocks.map(tb => ({
3293 type: 'tool_result',
3294 tool_use_id: tb.id,
3295 content: 'Character stats updated successfully.',
3296 })),
3297 },
3298 ];
3299 }
3300
3301 finalizeDMMessage(dmEl, rawText);
3302 if (statUpdate) applyStatUpdate(statUpdate);
3303 conversationHistory.push({ role: 'assistant', content: rawText });
3304
3305 const pricing = MODEL_PRICING['claude-haiku-4-5-20251001'] ?? { input: 0.80, output: 4.00 };
3306 lastTurnCost = (turnInputTokens * pricing.input + turnOutputTokens * pricing.output) / 1_000_000;
3307 sessionCost += lastTurnCost;
3308 updateCostDisplay();
3309 console.log(`✅ Turn complete — ${turnInputTokens}in/${turnOutputTokens}out tokens, turn $${lastTurnCost.toFixed(5)}, session $${sessionCost.toFixed(5)}`);
3310
3311 saveSession();
3312
3313 } catch (err) {
3314 console.error('🔴 sendMessage error:', err);
3315 const lines = err.message.split('\n');
3316 const debugInfo = [
3317 `Error: ${err.message}`,
3318 `Time: ${new Date().toISOString()}`,
3319 `Secure context: ${window.isSecureContext}`,
3320 `Browser: ${navigator.userAgent}`,
3321 ].join('\n');
3322 dmEl.innerHTML =
3323 `<span style="color:#e74c3c">⚠️ ${lines.map(escHtml).join('<br>')}</span>` +
3324 `<br><a href="#" class="debug-copy-link" style="font-size:0.78rem;color:var(--text-dim);text-decoration:underline">📋 Copy error details</a>`;
3325 dmEl.querySelector('.debug-copy-link').addEventListener('click', e => {
3326 e.preventDefault();
3327 navigator.clipboard.writeText(debugInfo).then(() => { e.target.textContent = '✓ Copied!'; });
3328 });
3329 conversationHistory.pop();
3330 }
3331
3332 setBusy(false);
3333}
3334
3335function setBusy(busy) {
3336 isBusy = busy;
3337 document.getElementById('send-btn').disabled = busy;
3338 document.getElementById('roll-btn').disabled = busy;
3339 document.getElementById('player-input').disabled = busy;
3340 document.querySelectorAll('.quick-btn').forEach(b => b.disabled = busy);
3341}
3342
3343// ── EVENT LISTENERS ───────────────────────────────────
3344
3345document.getElementById('send-btn').addEventListener('click', () => {
3346 const input = document.getElementById('player-input');
3347 const text = input.value.trim();
3348 if (text) {
3349 input.value = '';
3350 input.style.height = '';
3351 sendMessage(text, true);
3352 }
3353});
3354
3355document.getElementById('player-input').addEventListener('keydown', e => {
3356 if (e.key === 'Enter' && !e.shiftKey) {
3357 e.preventDefault();
3358 document.getElementById('send-btn').click();
3359 }
3360});
3361
3362document.getElementById('player-input').addEventListener('input', function() {
3363 this.style.height = 'auto';
3364 this.style.height = Math.min(this.scrollHeight, 120) + 'px';
3365});
3366
3367document.getElementById('quick-actions').addEventListener('click', e => {
3368 const btn = e.target.closest('.quick-btn');
3369 if (btn && !isBusy) sendMessage(btn.dataset.action, true);
3370});
3371
3372document.getElementById('new-game-btn').addEventListener('click', async () => {
3373 if (!confirm('Start a new game? Your current adventure will be lost.')) return;
3374 document.getElementById('game-screen').classList.remove('visible');
3375 document.getElementById('creation-screen').style.display = '';
3376 document.getElementById('messages').innerHTML = '';
3377 selectedDescriptor = null;
3378 selectedType = null;
3379 selectedFocus = null;
3380 selectedSetting = 'fantasy';
3381 document.querySelectorAll('.setting-btn').forEach(b => b.classList.remove('selected'));
3382 document.querySelector('.setting-btn[data-setting="fantasy"]')?.classList.add('selected');
3383 gameState = null;
3384 conversationHistory = [];
3385 isBusy = false;
3386 document.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected'));
3387 localStorage.removeItem('dm_session');
3388 currentApiKey = (await decryptFromStorage(localStorage.getItem('dm_api_key'))) || null;
3389 if (!currentApiKey) { openSetupModal('own'); return; }
3390 checkBeginReady();
3391});
3392
3393// ── SETUP MODAL ───────────────────────────────────────
3394
3395function openSetupModal(mode) {
3396 const isShared = mode === 'shared';
3397 document.getElementById('setup-own-panel').style.display = isShared ? 'none' : '';
3398 document.getElementById('setup-shared-panel').style.display = isShared ? '' : 'none';
3399 if (!isShared) {
3400 // Pre-fill with currentApiKey if available (editing)
3401 if (currentApiKey) {
3402 document.getElementById('setup-key-input').value = currentApiKey;
3403 document.getElementById('setup-key-saved-badge').style.display = localStorage.getItem('dm_api_key') ? '' : 'none';
3404 document.getElementById('setup-forget-btn').style.display = localStorage.getItem('dm_api_key') ? '' : 'none';
3405 }
3406 syncSetupContinueBtn();
3407 }
3408 document.getElementById('setup-modal').classList.add('open');
3409}
3410
3411function closeSetupModal() {
3412 document.getElementById('setup-modal').classList.remove('open');
3413}
3414
3415function syncSetupContinueBtn() {
3416 const hasKey = document.getElementById('setup-key-input').value.trim().length > 0;
3417 document.getElementById('setup-continue-btn').disabled = !hasKey;
3418}
3419
3420// Own-key panel
3421document.getElementById('setup-key-input').addEventListener('input', () => {
3422 document.getElementById('setup-key-saved-badge').style.display = 'none';
3423 syncSetupContinueBtn();
3424});
3425
3426document.getElementById('setup-forget-btn').addEventListener('click', () => {
3427 localStorage.removeItem('dm_api_key');
3428 localStorage.removeItem('dm_session');
3429 currentApiKey = null;
3430 document.getElementById('setup-key-input').value = '';
3431 document.getElementById('setup-key-saved-badge').style.display = 'none';
3432 document.getElementById('setup-forget-btn').style.display = 'none';
3433 syncSetupContinueBtn();
3434});
3435
3436document.getElementById('setup-continue-btn').addEventListener('click', async () => {
3437 const key = document.getElementById('setup-key-input').value.trim();
3438 if (!key) return;
3439 currentApiKey = key;
3440 localStorage.setItem('dm_api_key', await encryptForStorage(key));
3441 closeSetupModal();
3442 if (!(await tryResumeSession())) {
3443 document.getElementById('creation-screen').style.display = '';
3444 }
3445});
3446
3447// Share link inside setup modal
3448document.getElementById('setup-share-toggle').addEventListener('click', () => {
3449 const panel = document.getElementById('setup-share-panel');
3450 const isOpen = panel.style.display !== 'none';
3451 panel.style.display = isOpen ? 'none' : '';
3452 if (!isOpen) {
3453 document.getElementById('setup-share-http-warning').style.display =
3454 window.isSecureContext ? 'none' : '';
3455 document.getElementById('setup-share-result').style.display = 'none';
3456 }
3457});
3458
3459document.getElementById('setup-share-generate-btn').addEventListener('click', async () => {
3460 const apiKey = document.getElementById('setup-key-input').value.trim();
3461 const passphrase = document.getElementById('setup-pass-input').value.trim();
3462 if (!apiKey) { alert('Enter your API key first.'); return; }
3463 if (!passphrase) { alert('Enter an access code / passphrase.'); return; }
3464 const btn = document.getElementById('setup-share-generate-btn');
3465 btn.disabled = true; btn.textContent = 'Encrypting…';
3466 try {
3467 const blob = await encryptApiKey(apiKey, passphrase);
3468 const url = `${location.origin}${location.pathname}#k=${blob}`;
3469 document.getElementById('setup-share-url-display').textContent = url;
3470 document.getElementById('setup-share-result').style.display = '';
3471 } catch (err) {
3472 alert(`Encryption failed: ${err.message}`);
3473 } finally {
3474 btn.disabled = false; btn.textContent = 'Generate';
3475 }
3476});
3477
3478document.getElementById('setup-share-copy-btn').addEventListener('click', () => {
3479 const url = document.getElementById('setup-share-url-display').textContent;
3480 navigator.clipboard.writeText(url).then(() => {
3481 const btn = document.getElementById('setup-share-copy-btn');
3482 btn.textContent = '✓ Copied!';
3483 setTimeout(() => btn.textContent = 'Copy URL', 2000);
3484 });
3485});
3486
3487// Shared-key (passphrase) panel
3488document.getElementById('setup-passphrase-input').addEventListener('input', () => {
3489 const hasPass = document.getElementById('setup-passphrase-input').value.trim().length > 0;
3490 document.getElementById('setup-unlock-btn').disabled = !hasPass;
3491});
3492
3493document.getElementById('setup-unlock-btn').addEventListener('click', async () => {
3494 const passphrase = document.getElementById('setup-passphrase-input').value.trim();
3495 if (!passphrase) return;
3496 const btn = document.getElementById('setup-unlock-btn');
3497 btn.disabled = true; btn.textContent = 'Unlocking…';
3498 try {
3499 currentApiKey = await decryptApiKey(getSharedBlob(), passphrase);
3500 } catch (err) {
3501 alert(err.message.includes('HTTPS') ? err.message : 'Invalid access code. Please check and try again.');
3502 btn.disabled = false; btn.textContent = 'Unlock →';
3503 return;
3504 }
3505 closeSetupModal();
3506 if (!(await tryResumeSession())) {
3507 document.getElementById('creation-screen').style.display = '';
3508 }
3509});
3510
3511// ── REPORT MODAL HANDLERS ─────────────────────────────
3512
3513const SERVICE_DESK_EMAIL = 'bugs-k7q4+dm@fuzzyworld.net';
3514
3515function openReportModal() {
3516 document.getElementById('report-desc').value = '';
3517 document.getElementById('report-debug-box').textContent = buildDebugInfo('');
3518 document.getElementById('report-modal').classList.add('open');
3519}
3520
3521document.getElementById('report-btn').addEventListener('click', openReportModal);
3522
3523document.getElementById('report-desc').addEventListener('input', () => {
3524 document.getElementById('report-debug-box').textContent =
3525 buildDebugInfo(document.getElementById('report-desc').value.trim());
3526});
3527
3528document.getElementById('report-close-btn').addEventListener('click', () => {
3529 document.getElementById('report-modal').classList.remove('open');
3530});
3531document.getElementById('report-modal').addEventListener('click', e => {
3532 if (e.target === document.getElementById('report-modal'))
3533 document.getElementById('report-modal').classList.remove('open');
3534});
3535
3536document.getElementById('report-email-btn').addEventListener('click', () => {
3537 const desc = document.getElementById('report-desc').value.trim();
3538 const body = buildDebugInfo(desc);
3539 const title = desc ? desc.split('\n')[0].slice(0, 80) : 'Bug report: Solo Dungeon Master';
3540 window.open(`mailto:${SERVICE_DESK_EMAIL}?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`);
3541});
3542
3543document.getElementById('report-copy-btn').addEventListener('click', () => {
3544 const desc = document.getElementById('report-desc').value.trim();
3545 navigator.clipboard.writeText(buildDebugInfo(desc)).then(() => {
3546 const btn = document.getElementById('report-copy-btn');
3547 btn.textContent = '✓ Copied!';
3548 setTimeout(() => { btn.textContent = '📋 Copy info'; }, 2000);
3549 });
3550});
3551
3552// ── INIT ──────────────────────────────────────────────
3553
3554(async () => {
3555 await buildCreationScreen();
3556
3557 // Load own key from storage if available
3558 const keyRaw = localStorage.getItem('dm_api_key');
3559 if (keyRaw) currentApiKey = await decryptFromStorage(keyRaw) || null;
3560
3561 if (currentApiKey) {
3562 // Key known — resume session or show creation screen
3563 if (!(await tryResumeSession())) {
3564 document.getElementById('creation-screen').style.display = '';
3565 }
3566 } else if (getSharedBlob()) {
3567 openSetupModal('shared');
3568 } else {
3569 openSetupModal('own');
3570 }
3571})().catch(console.error);
3572</script>
3573</body>
3574</html>