dm / commit
feat: migrate to Cypher System (Phases 1–2, SAVE_VERSION 2)
README.md | 12 +- plan.md | 182 ++++++++++++++++ public/index.html | 641 +++++++++++++++++++++++++++++++++++++++++------------- 3 files changed, 677 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index a964c6c..5333d01 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🎲 Solo Dungeon Master -An AI-powered solo D&D app that runs entirely in your browser. Your API key never leaves your device — all requests go directly from your browser to the Anthropic API. +An AI-powered solo tabletop RPG app that runs entirely in your browser, using the **Cypher System** rules. Your API key never leaves your device — all requests go directly from your browser to the Anthropic API. **Live:** [pavelsafronov.gitlab.io/dm/](https://pavelsafronov.gitlab.io/dm/) @@ -10,14 +10,18 @@ An AI-powered solo D&D app that runs entirely in your browser. Your API key neve On your first visit you'll be asked for an [Anthropic API key](https://console.anthropic.com/). The key is saved to your browser's `localStorage` (AES-GCM encrypted) so you only enter it once. After that, the game UI appears immediately on every return visit, resuming your previous adventure automatically. -The game is powered by `claude-haiku-4-5-20251001` and uses Claude's tool-use API to maintain a structured character sheet (HP, XP, inventory, gold) while generating freeform narrative. Costs roughly a few cents per session. +The game is powered by `claude-haiku-4-5-20251001` and uses Claude's tool-use API to maintain a structured character sheet (Might/Speed/Intellect pools, XP, cyphers, equipment) while generating freeform narrative. Costs roughly a few cents per session. ## Features -- **10 races × 10 classes** — Human, Elf, Dwarf, Tiefling, Half-Orc, Gnome, Halfling, Dragonborn, Half-Elf, Aasimar × Fighter, Wizard, Rogue, Paladin, Bard, Ranger, Cleric, Druid, Barbarian, Warlock -- **Session persistence** — adventure state and conversation history saved to `localStorage`; reloading the page resumes mid-scene +- **Cypher System character creation** — pick a Descriptor (20 options), Type (Warrior, Adept, Explorer, Speaker), and Focus (20 options) to form the sentence *"I am a Clever Warrior who Hunts."* +- **Three stat pools** — Might, Speed, and Intellect each with current/max and Edge displayed; colour-coded bars shift green → amber → red as pools deplete +- **Damage track** — one pool at 0 → Impaired; two at 0 → Debilitated; all at 0 → Dead +- **Cyphers** — one-use items found during play; displayed in the sidebar with Cypher Limit enforcement +- **Session persistence** — adventure state and conversation history saved to `localStorage`; reloading the page resumes mid-scene; save version guard offers Markdown export before discarding incompatible sessions - **Cost display** — per-turn and session cost estimates shown in the sidebar (based on token counts from the SSE stream) - **Dice roller** — d4 through d100, configurable count +- **World Notes** — orange-bordered GM meta-context panel for providing background lore, world physics, and house rules that Claude treats as absolute truth - **Share links** — generate an encrypted `#k=…` URL so someone else can use the app with your API key; they enter a passphrase you share separately; the key is never in the URL - **Report Issue** — in-game button that collects debug info (character state, recent errors, session cost) and opens a pre-filled GitLab issue or Service Desk email diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..efe1985 --- /dev/null +++ b/plan.md @@ -0,0 +1,182 @@ +# Cypher System (CSRD) Migration Plan + +## Background + +The app currently runs D&D 5e via Claude. The goal is to switch to the Cypher System Reference Document (CSRD) — Monte Cook Games' open ruleset used in Numenera, The Strange, and others. + +Every phase that changes `gameState` shape must increment `SAVE_VERSION` in `public/index.html`. Old saves are automatically offered for export and discarded. + +--- + +## Source material + +All content is available under the [Cypher System Open License (CSOL)](https://www.montecookgames.com/cypher-system-open-license/) — free for digital use. + +| Source | URL | Used for | +| --- | --- | --- | +| Cypher-System-JSON-DB | github.com/Jon-Davis/Cypher-System-JSON-DB | **Data arrays** — types (with pool values), descriptors, foci, abilities, cyphers. 1.74 MB JSON; too large to bundle directly, so we extract just the fields we need into compact inline JS arrays during development. | +| cyphersrd.quest | cyphersrd.quest | **System prompt** — authoritative rules text for task resolution, damage track, effort, GM intrusions, advancement. We write a tight summary (~800 tokens) from this. | +| Obsidian CSRD Markdown | github.com/Obsidian-TTRPG-Community/Cypher-SRD-Markdown | Cross-reference during development. | + +**Workflow for data extraction:** download CSRD.json from JSON-DB, run a small local script to pull the specific fields needed (type name + starting pools + cypher limit + starting abilities summary; descriptor name + stat bonus; focus name + one-line summary + tier 1 ability), and paste the result as inline JS arrays in `index.html`. No build step needed. + +--- + +## Phase 1 — Character foundation + +The biggest structural change. D&D's Race + Class becomes Cypher's **Descriptor + Type + Focus** sentence: *"I am a Clever Adept who Talks to Machines."* + +### 1a — Data definitions + +- [x] Replace `RACES` array with `DESCRIPTORS` (20 entries implemented) +- [x] Replace `CLASSES` array with `TYPES` (4 entries: Warrior, Adept, Explorer, Speaker — each with starting pools, Cypher Limit, and starting equipment) +- [x] Add `FOCI` array (20 entries implemented) + +### 1b — New `gameState.character` shape + +Replace current `{ name, race, class, level, hp, maxHp, xp, gold, inventory }` with: + +```js +{ + name, descriptor, type, focus, + tier, // 1–6 + effort, // = tier + might: { pool, max, edge }, + speed: { pool, max, edge }, + intellect: { pool, max, edge }, + xp, + damageTrack, // 'hale' | 'impaired' | 'debilitated' + recoveryUsed, // 0–4, resets each full rest + cypherLimit, // from type (usually 2) + cyphers, // [{ name, level, effect }] + skills, // [{ name, rank: 'trained'|'specialized'|'inability' }] + abilities, // [{ name, cost, description }] — from type + focus + equipment, // string[] (replaces inventory) + shins, // currency (or 'gold' for generic settings) +} +``` + +- [x] Update `beginAdventure()` to build this shape from Type + Descriptor + Focus selections +- [x] Bump `SAVE_VERSION` to 2 + +### 1c — Creation screen + +- [x] Replace two-grid layout (Race + Class) with three-grid layout (Descriptor + Type + Focus) +- [x] The sentence "I am a ___ ___ who ___" assembles live as user picks each +- [x] Type cards show starting pool values and Cypher Limit, not HP +- [x] Focus cards show a one-line flavour summary +- [x] Descriptor cards show the key stat bonus it provides + +### 1d — Header title format + +- [x] Update `header-title` from `"Name — Race Class"` to `"Name — Descriptor Type who Focus"` + +--- + +## Phase 2 — Core mechanics + +### 2a — New `UPDATE_TOOL` schema + +Replace current fields entirely: + +``` +might_damage // number — damage to Might pool +speed_damage // number — damage to Speed pool +intellect_damage // number — damage to Intellect pool +might_restore // number — points restored to Might pool +speed_restore // number — points restored to Speed pool +intellect_restore // number — points restored to Intellect pool +xp_gain // number +shins_change // number +cyphers_add // [{ name, level, effect }] +cyphers_remove // string[] — names of used/lost cyphers +abilities_add // [{ name, cost, description }] +equipment_add // string[] +equipment_remove // string[] +skills_add // [{ name, rank }] +tier_advance // boolean +recovery_used // boolean — mark one recovery slot as spent +reason // string +``` + +### 2b — New `applyStatUpdate()` logic + +- [x] Apply pool damage/restore, clamping to 0 and max +- [x] Recalculate `damageTrack` after every pool change: + - Hale → one pool at 0 → Impaired (−1 step to all rolls) + - Impaired → two pools at 0 → Debilitated (can barely act) + - Debilitated → all pools at 0 → Dead (end screen) +- [x] Enforce Cypher Limit: if `cyphers.length > cypherLimit`, warn player to use one +- [x] Handle `tier_advance`: increment `tier`, recalculate `effort = tier`, show tier-up fanfare +- [ ] Handle `recovery_used`: increment `recoveryUsed` (max 4; fourth resets all and marks 10-hour rest taken) — deferred to Phase 3d (recovery button) +- [x] Replace level-up toast with Tier-up toast (text: "TIER [N]!") + +### 2c — System prompt rewrite ✅ + +Complete replacement of `buildSystemPrompt`. Claude needs to understand: + +- **Character sentence** — what Descriptor, Type, and Focus each contribute +- **Task difficulty** — 0–10 scale; target = difficulty × 3; player rolls d20 +- **Special roll results** — 1 = GM intrusion (free); 17 = minor effect; 18 = minor effect; 19 = major effect or +1 damage; 20 = major effect +- **Effort** — costs 3 from a pool per level (minus Edge); reduces difficulty by 1 per level applied +- **Assets** — each relevant asset reduces difficulty by 1 (max 2 from assets) +- **Skills** — trained = −1 difficulty; specialized = −2 difficulty; inability = +1 difficulty +- **Damage track** — Impaired penalty (−1 step = difficulty +1 to all rolls); Debilitated behavior +- **GM Intrusions** — Claude should offer them occasionally; player accepts (gains 2 XP, gives 1 away) or rejects (spends 1 XP); if player has no XP they must accept +- **Recovery rolls** — 1d6 + Tier; progression: 1 action → 10 min → 1 hour → 10 hours; after four the character must sleep to reset +- **Cyphers** — found during play; Claude should introduce them narratively; remind player when at Cypher Limit; one-use +- **XP flow** — earned via intrusions and discoveries; spent on advancement (4 per step) or short-term benefits (1 each: reroll, refuse intrusion, add +2 to any roll) +- **Advancement steps** (any 4 to advance a tier): increase pool max by 4; add +1 Edge to a stat; train a skill; other improvement +- **Stat tracking** — call `update_character` before narrative text, same as current + +--- + +## Phase 3 — Sidebar UI redesign + +### 3a — Three pool bars + +- [x] Replace HP bar with three bars: Might, Speed, Intellect +- [x] Each bar shows `current / max` and `Edge: N` +- [x] Bar colour shifts: green (full) → amber (half) → red (low) — same as current HP logic +- [x] Damage Track indicator below the bars: coloured badge — green "Hale" / amber "Impaired" / red "Debilitated" + +### 3b — XP & advancement panel + +- [x] Replace XP progress bar (which tracked D&D level thresholds) with a plain XP counter +- [x] Tier shown as Roman numeral next to XP +- [ ] Show advancement progress: how many of the 4 steps toward next Tier are done +- [ ] "Spend 4 XP" button to initiate tier advancement (disabled if XP < 4 or steps < 4) + +### 3c — Cyphers panel + +- [x] New sidebar section "Cyphers" (replaces or extends Inventory) +- [x] Each cypher shows name, level, effect summary +- [ ] "Use" button per cypher (removes it from list and tells Claude) +- [x] Cypher Limit displayed; section border turns orange when at limit +- [x] Equipment (non-cypher items) stays as a separate Inventory list + +### 3d — Recovery roll button + +- [ ] Small section showing 4 recovery slots (filled/empty pips) +- [ ] "Recovery roll" button — disabled when busy or all slots used +- [ ] Clicking it rolls 1d6 + Tier, adds result to the lowest depleted pool, marks one slot used +- [ ] On 10-hour rest: all slots reset + +--- + +## Phase 4 — Setting / genre selector + +Cypher System is genre-neutral. Offer a setting choice at creation that adjusts tone, vocabulary, and available Foci. + +- [ ] Add setting selector to creation screen: Generic Fantasy / Post-Apocalyptic Sci-Fantasy (Numenera-flavoured) / Modern / Science Fiction +- [ ] Each setting customises: currency name (shins vs gold), "magic" vocabulary (esoteries vs spells vs powers), DM tone, and a curated subset of Foci +- [ ] System prompt includes setting context + +--- + +## Notes + +- Phases 1–2 land together (one `SAVE_VERSION` bump to 2) — they're too intertwined to separate safely +- Phase 3 can ship independently after Phase 2 (UI only, no `gameState` shape change) +- Phase 4 is cosmetic and can go in any order +- The CSRD is available at [cypher-srd.com](https://cypher-srd.com) for rules reference diff --git a/public/index.html b/public/index.html index 963dc01..edfef06 100644 --- a/public/index.html +++ b/public/index.html @@ -492,7 +492,131 @@ body { font-style: italic; } -/* HP bar */ +/* Character sentence (creation screen) */ +.char-sentence { + font-family: 'Cinzel', serif; + font-size: 0.85rem; + color: var(--text-dim); + text-align: center; + margin: 0 0 16px; + min-height: 1.4em; + letter-spacing: 0.04em; +} +.char-sentence .picked { color: var(--gold-light); } + +/* Compact grid for descriptors/foci (20 items) */ +.selection-grid.compact-grid { + grid-template-columns: repeat(5, 1fr); +} +.selection-grid.compact-grid .sel-card { + padding: 8px 4px; +} +.selection-grid.compact-grid .sel-card .icon { font-size: 1.2rem; } +.selection-grid.compact-grid .sel-card .name { font-size: 0.6rem; } + +/* Pool bars (Might/Speed/Intellect) */ +.pool-label-row { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 3px; +} +.pool-label { + font-family: 'Cinzel', serif; + font-size: 0.6rem; + letter-spacing: 0.1em; + color: var(--text-dim); + text-transform: uppercase; +} +.pool-value { + font-family: 'Cinzel', serif; + font-size: 0.78rem; + color: var(--text-bright); + font-weight: 600; +} +.pool-edge { + font-size: 0.62rem; + color: var(--text-dim); + margin-left: 4px; +} +.pool-bar-wrap { margin-bottom: 8px; } +#might-fill { background: #c0392b; } +#speed-fill { background: #27ae60; } +#intellect-fill { background: #6b5fc7; } +#might-fill.warning, #speed-fill.warning, #intellect-fill.warning { filter: brightness(0.75); } +#might-fill.danger, #speed-fill.danger, #intellect-fill.danger { + filter: brightness(0.6); + animation: hp-pulse 1.5s infinite; +} + +/* Damage track badge */ +.damage-track-row { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0 8px; + flex-wrap: wrap; +} +.dt-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: 'Cinzel', serif; + font-size: 0.62rem; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid; +} +.dt-badge.hale { color: #27ae60; border-color: #27ae60; background: rgba(39,174,96,0.08); } +.dt-badge.impaired { color: #f39c12; border-color: #f39c12; background: rgba(243,156,18,0.1); } +.dt-badge.debilitated { color: #e74c3c; border-color: #e74c3c; background: rgba(231,76,60,0.1); animation: hp-pulse 1.5s infinite; } +.dt-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } +.xp-tier-row { + display: flex; + align-items: center; + justify-content: space-between; + font-family: 'Cinzel', serif; + font-size: 0.72rem; + color: var(--text-dim); +} +.xp-tier-row .val { color: var(--gold); font-weight: 600; } + +/* Cyphers section */ +.cypher-entry { + border: 1px solid var(--border); + border-radius: 5px; + padding: 7px 9px; + margin-bottom: 6px; + background: var(--bg-card); +} +.cypher-name { + font-family: 'Cinzel', serif; + font-size: 0.68rem; + color: var(--gold-light); + margin-bottom: 2px; +} +.cypher-effect { + font-size: 0.75rem; + color: var(--text-dim); + line-height: 1.4; +} +.cypher-use-btn { + margin-top: 5px; + font-size: 0.65rem; + font-family: 'Cinzel', serif; + background: none; + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-dim); + cursor: pointer; + padding: 2px 8px; +} +.cypher-use-btn:hover { border-color: var(--gold-dim); color: var(--gold); } +.cypher-limit-warn { color: #d97706; font-size: 0.72rem; } + +/* Stat row */ .stat-row { display: flex; align-items: center; @@ -1079,14 +1203,21 @@ body { <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> <div class="creation-sub">Powered by Claude — your story begins here</div> + <p class="char-sentence" id="char-sentence">I am a <em>___</em> <em>___</em> who <em>___</em></p> + + <div class="creation-section"> + <div class="section-label">Descriptor — who are you?</div> + <div class="selection-grid compact-grid" id="descriptor-grid"></div> + </div> + <div class="creation-section"> - <div class="section-label">Choose Your Race</div> - <div class="selection-grid" id="race-grid"></div> + <div class="section-label">Type — what do you do?</div> + <div class="selection-grid" id="type-grid"></div> </div> <div class="creation-section"> - <div class="section-label">Choose Your Class</div> - <div class="selection-grid class-grid" id="class-grid"></div> + <div class="section-label">Focus — what makes you special?</div> + <div class="selection-grid compact-grid" id="focus-grid"></div> </div> <div class="creation-section"> @@ -1158,35 +1289,57 @@ body { <div class="char-icon" id="char-icon">⚔️</div> <div class="char-identity"> <div class="char-name" id="char-name-display">—</div> - <div class="char-race-class" id="char-race-class">— / —</div> + <div class="char-race-class" id="char-identity-sub">—</div> </div> </div> - <div class="stat-row"> - <span class="stat-label">HP</span> - <span class="stat-value" id="hp-display">—</span> + <div class="pool-bar-wrap"> + <div class="pool-label-row"> + <span class="pool-label">Might <span class="pool-edge" id="might-edge"></span></span> + <span class="pool-value" id="might-display">—</span> + </div> + <div class="bar-track"><div class="bar-fill" id="might-fill" style="width:100%"></div></div> </div> - <div class="bar-track"> - <div class="bar-fill" id="hp-fill" style="width:100%"></div> + + <div class="pool-bar-wrap"> + <div class="pool-label-row"> + <span class="pool-label">Speed <span class="pool-edge" id="speed-edge"></span></span> + <span class="pool-value" id="speed-display">—</span> + </div> + <div class="bar-track"><div class="bar-fill" id="speed-fill" style="width:100%"></div></div> </div> - <div class="stat-row"> - <span class="stat-label">XP <span id="level-display" style="color:var(--gold)">Lv 1</span></span> - <span class="stat-value" id="xp-display">0 / 300</span> + <div class="pool-bar-wrap"> + <div class="pool-label-row"> + <span class="pool-label">Intellect <span class="pool-edge" id="intellect-edge"></span></span> + <span class="pool-value" id="intellect-display">—</span> + </div> + <div class="bar-track"><div class="bar-fill" id="intellect-fill" style="width:100%"></div></div> </div> - <div class="bar-track"> - <div class="bar-fill" id="xp-fill" style="width:0%"></div> + + <div class="damage-track-row"> + <span class="dt-badge hale" id="dt-badge"><span class="dt-dot"></span><span id="dt-label">Hale</span></span> + <span class="xp-tier-row" style="flex:1;margin-left:8px"> + <span>XP <span class="val" id="xp-display">0</span></span> + <span>Tier <span class="val" id="tier-display">I</span></span> + </span> </div> <div class="gold-row"> <span>🪙</span> - <span id="gold-display">10 GP</span> + <span id="shins-display">10 Shins</span> </div> </div> - <!-- Inventory --> + <!-- Cyphers --> <div class="sidebar-section"> - <div class="sidebar-title">Inventory</div> + <div class="sidebar-title" id="cyphers-title">Cyphers (0/2)</div> + <div id="cyphers-list"><span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">No cyphers yet.</span></div> + </div> + + <!-- Equipment --> + <div class="sidebar-section"> + <div class="sidebar-title">Equipment</div> <ul id="inventory-list"></ul> </div> @@ -1510,34 +1663,82 @@ function getSharedBlob() { // ── DATA ────────────────────────────────────────────── -const RACES = [ - { name: 'Human', icon: '👤' }, - { name: 'Elf', icon: '🧝' }, - { name: 'Dwarf', icon: '⛏️' }, - { name: 'Tiefling', icon: '😈' }, - { name: 'Half-Orc', icon: '💪' }, - { name: 'Gnome', icon: '🎭' }, - { name: 'Halfling', icon: '🍃' }, - { name: 'Dragonborn', icon: '🐉' }, - { name: 'Half-Elf', icon: '🌟' }, - { name: 'Aasimar', icon: '👼' }, +// ── CYPHER SYSTEM DATA (CSRD) ───────────────────────── +// Source: Cypher System Reference Document, Cypher System Open License +// © Monte Cook Games — montecookgames.com/cypher-system-open-license + +const TYPES = [ + { name: 'Warrior', icon: '⚔️', + pools: { might: 10, speed: 10, intellect: 8 }, + edge: { might: 1, speed: 0, intellect: 0 }, + cypherLimit: 2, + note: 'Practiced with all weapons. Might Edge 1.', + equipment: ['Weapon of choice', 'Light armor', "Explorer's pack", 'Moderate-priced item'] }, + { name: 'Adept', icon: '🔮', + pools: { might: 7, speed: 9, intellect: 12 }, + edge: { might: 0, speed: 0, intellect: 1 }, + cypherLimit: 3, + note: 'Masters esoteries (special abilities). Intellect Edge 1. Cypher Limit 3.', + equipment: ['Clothing', 'Book of lore', 'Two expensive items', 'Moderate-priced item'] }, + { name: 'Explorer', icon: '🧭', + pools: { might: 10, speed: 9, intellect: 9 }, + edge: { might: 1, speed: 0, intellect: 0 }, + cypherLimit: 2, + note: 'Balanced adventurer; light/medium weapons. Might Edge 1.', + equipment: ['Weapon of choice', 'Light armor', 'Exploration gear', 'Moderate-priced item'] }, + { name: 'Speaker', icon: '🗣️', + pools: { might: 8, speed: 9, intellect: 11 }, + edge: { might: 0, speed: 0, intellect: 1 }, + cypherLimit: 2, + note: 'Social and deceptive abilities. Intellect Edge 1.', + equipment: ['Light weapon', 'Stylish clothing', 'Two expensive items', 'Moderate-priced item'] }, ]; -const CLASSES = [ - { name: 'Fighter', icon: '⚔️', hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', "Explorer's Pack"] }, - { name: 'Wizard', icon: '🔮', hp: 6, items: ['Quarterstaff', 'Spellbook', 'Arcane Focus', "Scholar's Pack"] }, - { name: 'Rogue', icon: '🗡️', hp: 8, items: ['Shortsword', 'Leather Armor', "Thieves' Tools", '2x Daggers'] }, - { name: 'Paladin', icon: '⚜️', hp: 10, items: ['Longsword', 'Shield', 'Chain Mail', 'Holy Symbol'] }, - { name: 'Bard', icon: '🎵', hp: 8, items: ['Rapier', 'Leather Armor', 'Lute', "Entertainer's Pack"] }, - { name: 'Ranger', icon: '🏹', hp: 10, items: ['Longbow', '20x Arrows', 'Leather Armor', 'Shortsword'] }, - { name: 'Cleric', icon: '✨', hp: 8, items: ['Mace', 'Shield', 'Scale Mail', 'Holy Symbol'] }, - { name: 'Druid', icon: '🌿', hp: 8, items: ['Quarterstaff', 'Hide Armor', 'Herbalism Kit', 'Druidic Focus'] }, - { name: 'Barbarian', icon: '🪓', hp: 12, items: ['Greataxe', '2x Handaxes', "Explorer's Pack"] }, - { name: 'Warlock', icon: '🌑', hp: 8, items: ['Dagger', 'Leather Armor', 'Arcane Focus', "Scholar's Pack"] }, +const DESCRIPTORS = [ + { name: 'Clever', icon: '🧠', poolBonus: { intellect: 2 }, note: 'Skilled at lies, trickery, and assessing danger.' }, + { name: 'Fast', icon: '💨', poolBonus: { speed: 2 }, note: 'Can move and act in the same round.' }, + { name: 'Graceful', icon: '🌸', poolBonus: { speed: 2 }, note: 'Trained in balance, performance, and Speed defense.' }, + { name: 'Hardy', icon: '🪨', poolBonus: { might: 4 }, note: 'Functions one step better on damage track.' }, + { name: 'Honorable', icon: '🛡️', poolBonus: { might: 2 }, note: 'Trained in pleasant social interactions and detecting lies.' }, + { name: 'Inquisitive', icon: '🔍', poolBonus: { intellect: 4 }, note: 'Trained in learning, geography, and history.' }, + { name: 'Intelligent', icon: '📚', poolBonus: { intellect: 2 }, note: 'Trained in one knowledge area and memorization.' }, + { name: 'Intuitive', icon: '✨', poolBonus: { intellect: 2 }, note: 'Trained in perception; can act out of turn once per cycle.' }, + { name: 'Lucky', icon: '🍀', poolBonus: {}, note: 'Gains a 3-point Luck Pool; bonuses to rerolls.' }, + { name: 'Mighty', icon: '💪', poolBonus: { might: 4 }, note: 'Trained in lifting/throwing; +1 to recovery rolls.' }, + { name: 'Mysterious', icon: '🌑', poolBonus: {}, note: 'Trained in stealth and resisting interrogation.' }, + { name: 'Mystical', icon: '🌙', poolBonus: { intellect: 2 }, note: 'Can detect and cast minor magic; trained in the supernatural.' }, + { name: 'Perceptive', icon: '👁️', poolBonus: { intellect: 2 }, note: 'Trained in noticing details and recalling trivia.' }, + { name: 'Resilient', icon: '🌳', poolBonus: { might: 2, intellect: 2 }, note: 'Trained in both Might and Intellect defense; extra recovery roll.' }, + { name: 'Sharp-Eyed', icon: '🦅', poolBonus: {}, note: 'Trained in initiative and perception; can find foe weaknesses.' }, + { name: 'Stealthy', icon: '🌫️', poolBonus: { speed: 2 }, note: 'Trained in stealth, trickery, and illusions.' }, + { name: 'Strong', icon: '🪓', poolBonus: { might: 4 }, note: 'Trained in breaking objects and jumping.' }, + { name: 'Strong-Willed',icon: '🔮', poolBonus: { intellect: 4 }, note: 'Trained in resisting mental effects and concentration.' }, + { name: 'Tough', icon: '🦾', poolBonus: { might: 2 }, note: '+1 Armor; +1 to recovery rolls.' }, + { name: 'Weird', icon: '🌀', poolBonus: { intellect: 2 }, note: 'Trained in supernatural knowledge; senses nearby strange phenomena.' }, ]; -const XP_THRESHOLDS = [300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, - 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000]; +const FOCI = [ + { name: 'Controls Beasts', icon: '🦁', playstyle: 'Communicate with and command animals.' }, + { name: 'Crafts Illusions', icon: '🎭', playstyle: 'Fashion images from light so real they deceive all senses.' }, + { name: 'Crafts Unique Objects', icon: '⚙️', playstyle: 'Inventor of strange gadgets, gear, and contraptions.' }, + { name: 'Explores Dark Places', icon: '🕯️', playstyle: 'Treasure hunter and scavenger of lost and forgotten things.' }, + { name: 'Fights With Panache', icon: '🎪', playstyle: 'Swashbuckling daredevil who wins through flamboyant style.' }, + { name: 'Howls at the Moon', icon: '🐺', playstyle: 'Transforms into a fearsome beast with dangerous control issues.' }, + { name: 'Hunts', icon: '🏹', playstyle: 'Stalking hunter who tracks and takes down a chosen quarry.' }, + { name: 'Infiltrates', icon: '🕵️', playstyle: 'Uses subtlety, guile, and stealth to enter impossible places.' }, + { name: 'Leads', icon: '👑', playstyle: 'Natural commander who maintains a loyal band of followers.' }, + { name: 'Lives in the Wilderness',icon: '🌲', playstyle: 'Survivalist who thrives where others perish.' }, + { name: 'Masters Spells', icon: '📜', playstyle: 'Memorizes and rapidly deploys a variety of arcane spells.' }, + { name: 'Masters Weaponry', icon: '🗡️', playstyle: 'Grandmaster of one weapon type, surpassing all others.' }, + { name: 'Murders', icon: '🩸', playstyle: 'Assassin specialising in lethal surprise and clean eliminations.' }, + { name: 'Never Says Die', icon: '💀', playstyle: 'Unstoppable survivor who keeps fighting past all reason.' }, + { name: 'Rides the Lightning', icon: '⚡', playstyle: 'Creates and discharges electrical power to shock and energize.' }, + { name: 'Slays Monsters', icon: '🐉', playstyle: 'Dedicated monster hunter with lethal knowledge of creatures.' }, + { name: 'Solves Mysteries', icon: '🔎', playstyle: 'Master of deduction who reads evidence to find hidden answers.' }, + { name: 'Wears a Sheen of Ice', icon: '❄️', playstyle: 'Commands the wintry power of cold and ice.' }, + { name: 'Wields Two Weapons', icon: '🤺', playstyle: 'Dual-wielder who overwhelms enemies with relentless twin attacks.' }, + { name: 'Works Miracles', icon: '💫', playstyle: 'Healer and time-bender who restores the injured.' }, +]; const DIE_TYPES = [4, 6, 8, 10, 12, 20, 100]; @@ -1547,39 +1748,83 @@ const UPDATE_TOOL = { input_schema: { type: 'object', properties: { - hp_change: { type: 'number', description: 'HP delta. Negative=damage, positive=healing. 0 if unchanged.' }, - gold_change: { type: 'number', description: 'Gold delta. 0 if unchanged.' }, - xp_gain: { type: 'number', description: 'XP gained this turn. 0 if none.' }, - inventory_add: { type: 'array', items: { type: 'string' }, description: 'Items gained.' }, - inventory_remove: { type: 'array', items: { type: 'string' }, description: 'Items lost.' }, + might_damage: { type: 'number', description: 'Points of damage to Might pool. 0 if none.' }, + speed_damage: { type: 'number', description: 'Points of damage to Speed pool. 0 if none.' }, + intellect_damage: { type: 'number', description: 'Points of damage to Intellect pool. 0 if none.' }, + might_restore: { type: 'number', description: 'Points restored to Might pool (recovery, healing). 0 if none.' }, + speed_restore: { type: 'number', description: 'Points restored to Speed pool. 0 if none.' }, + intellect_restore:{ type: 'number', description: 'Points restored to Intellect pool. 0 if none.' }, + xp_gain: { type: 'number', description: 'XP awarded (intrusions, discoveries). 0 if none.' }, + shins_change: { type: 'number', description: 'Shins (currency) delta. Negative=spent. 0 if unchanged.' }, + cyphers_add: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, level: { type: 'number' }, effect: { type: 'string' } }, required: ['name','level','effect'] }, description: 'Cyphers found.' }, + cyphers_remove: { type: 'array', items: { type: 'string' }, description: 'Names of cyphers used or lost.' }, + equipment_add: { type: 'array', items: { type: 'string' }, description: 'Equipment gained.' }, + equipment_remove: { type: 'array', items: { type: 'string' }, description: 'Equipment lost.' }, + tier_advance: { type: 'boolean', description: 'True if character advances a Tier.' }, reason: { type: 'string', description: 'Brief reason for the change.' }, }, - required: ['hp_change', 'gold_change', 'xp_gain', 'inventory_add', 'inventory_remove', 'reason'], + 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','tier_advance','reason'], }, }; function buildSystemPrompt(character, theme, notes) { - return `You are a masterful Dungeon Master running a solo D&D 5e adventure. + const c = character; + const dt = c.damageTrack || 'hale'; + return `You are a masterful Game Master running a solo Cypher System adventure (CSRD rules). -CHARACTER: ${character.name} — Level ${character.level} ${character.race} ${character.class} +CHARACTER: ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus} ADVENTURE THEME: ${theme || 'classic fantasy'} +STAT POOLS +• Might: ${c.might.pool}/${c.might.max} (Edge ${c.might.edge}) +• Speed: ${c.speed.pool}/${c.speed.max} (Edge ${c.speed.edge}) +• Intellect: ${c.intellect.pool}/${c.intellect.max} (Edge ${c.intellect.edge}) +• Damage track: ${dt.toUpperCase()} | Effort: ${c.tier} | XP: ${c.xp} | Shins: ${c.shins} +• Cyphers: ${(c.cyphers||[]).length}/${c.cypherLimit}${(c.cyphers||[]).length ? ' — ' + c.cyphers.map(cy=>`${cy.name} (Lv${cy.level})`).join(', ') : ''} + +CYPHER SYSTEM RULES — TASK RESOLUTION +You set difficulty 0–10; target number = difficulty × 3; player rolls d20. +Roll ≥ target = success. Special results: 1 = fumble/free GM Intrusion; 17–18 = minor effect; 19–20 = major effect. +Reducing difficulty: each asset (tool, positioning, help) −1 (max 2 from assets); trained skill −1; specialized skill −2; inability +1. +Effort: player may apply Effort to reduce difficulty by 1 per level; each Effort level costs 3 from a pool minus that pool's Edge. + +DAMAGE +Typical weapon damage: light 2 pts, medium 4 pts, heavy 6 pts. Damage depletes Might pool by default (Speed or Intellect if specified). +Damage track: +• Hale — all pools above 0, no penalties. +• Impaired — one pool at 0; all tasks hindered (difficulty +1). +• Debilitated — two pools at 0; can only move or take defensive actions. +• Dead — all pools at 0. + +RECOVERY ROLLS +Player 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. + +GM INTRUSIONS +Occasionally 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). + +XP & ADVANCEMENT +XP is earned through intrusions and discoveries; 1 XP can be spent to reroll or refuse an intrusion. +To 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). + +CYPHERS +One-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. + +STAT TRACKING — call update_character BEFORE writing narrative whenever: pools change, shins change, XP is awarded, items/cyphers are gained or lost, or Tier advances. + NARRATIVE RULES • Write 2–4 vivid, immersive paragraphs per turn. • Give NPCs distinct voices and motivations. • Make choices matter — show consequences immediately. -• Run combat with suspense and tactical detail. -• End every turn with either 2–3 numbered options OR "What do you do?" - -STAT TRACKING — call update_character whenever HP changes, gold is gained/lost, XP is earned, or items change hands. Call the tool BEFORE writing your narrative text. +• End every turn with 2–3 numbered options OR "What do you do?" -TONE: Cinematic. Dangerous. Immersive. Legendary.${notes ? `\n\nWORLD NOTES — ABSOLUTE TRUTHS\nThe GM has defined the following facts about this world. Treat them as immutable and weave them naturally into every scene:\n\n${notes}` : ''}`; +TONE: 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}` : ''}`; } // ── STATE ───────────────────────────────────────────── -let selectedRace = null; -let selectedClass = null; +let selectedDescriptor = null; +let selectedType = null; +let selectedFocus = null; let gameState = null; let conversationHistory = []; let isBusy = false; @@ -1632,8 +1877,8 @@ function buildDebugInfo(userDesc) { lines.push('', '--- Session ---'); lines.push(`Player ID: p_${getOrCreatePlayerId()}`); if (gameState.gameId) lines.push(`Game ID: g_${gameState.gameId}`); - lines.push(`Character: ${c.name} — Lv ${c.level} ${c.race} ${c.class}`); - lines.push(`HP: ${c.hp}/${c.maxHp} XP: ${c.xp} Gold: ${c.gold}g`); + lines.push(`Character: ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus}`); + 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}`); lines.push(`Turns: ${conversationHistory.length} Cost: $${sessionCost.toFixed(5)}`); } @@ -1651,7 +1896,7 @@ function buildDebugInfo(userDesc) { // ── SESSION PERSISTENCE ─────────────────────────────── // Increment SAVE_VERSION on any breaking change to gameState shape or // session format. tryResumeSession discards saves from older versions. -const SAVE_VERSION = 1; +const SAVE_VERSION = 2; function saveSession() { if (!gameState) return; @@ -1691,7 +1936,7 @@ async function tryResumeSession() { document.getElementById('game-screen').classList.add('visible'); document.getElementById('messages').innerHTML = session.messagesHTML || ''; document.getElementById('header-title').textContent = - `${gameState.character.name} — ${gameState.character.race} ${gameState.character.class}`; + `${gameState.character.name} — ${gameState.character.descriptor} ${gameState.character.type}`; sessionCost = session.sessionCost || 0; lastTurnCost = 0; @@ -1705,7 +1950,7 @@ async function tryResumeSession() { const msgEl = document.getElementById('messages'); msgEl.scrollTop = msgEl.scrollHeight; - console.log(`▶ Resumed session — ${conversationHistory.length} messages, Lv ${gameState.character.level} ${gameState.character.name}, $${sessionCost.toFixed(5)} spent`); + console.log(`▶ Resumed session — ${conversationHistory.length} messages, Tier ${gameState.character.tier} ${gameState.character.name}, $${sessionCost.toFixed(5)} spent`); return true; } @@ -1716,9 +1961,19 @@ function generateAdventureMarkdown(session) { const lines = ['# Solo Dungeon Master — Adventure Log', '']; if (c) { - lines.push(`**Character:** ${c.name} — Level ${c.level} ${c.race} ${c.class}`); - lines.push(`**HP:** ${c.hp}/${c.maxHp} · **XP:** ${c.xp} · **Gold:** ${c.gold} GP`); - if (c.inventory?.length) lines.push(`**Inventory:** ${c.inventory.join(', ')}`); + // Cypher format (v2+) + if (c.type) { + lines.push(`**Character:** ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus}`); + lines.push(`**Might:** ${c.might?.pool}/${c.might?.max} · **Speed:** ${c.speed?.pool}/${c.speed?.max} · **Intellect:** ${c.intellect?.pool}/${c.intellect?.max}`); + lines.push(`**XP:** ${c.xp} · **Shins:** ${c.shins}`); + if (c.cyphers?.length) lines.push(`**Cyphers:** ${c.cyphers.map(cy=>`${cy.name} (Lv${cy.level})`).join(', ')}`); + if (c.equipment?.length) lines.push(`**Equipment:** ${c.equipment.join(', ')}`); + } else { + // Legacy D&D format (v0/v1) + lines.push(`**Character:** ${c.name} — Level ${c.level} ${c.race} ${c.class}`); + lines.push(`**HP:** ${c.hp}/${c.maxHp} · **XP:** ${c.xp} · **Gold:** ${c.gold} GP`); + if (c.inventory?.length) lines.push(`**Inventory:** ${c.inventory.join(', ')}`); + } } if (session.sessionCost) { lines.push(`**Session cost:** $${session.sessionCost.toFixed(4)}`); @@ -1764,67 +2019,91 @@ function showVersionMismatchModal(session) { // ── CREATION SCREEN ─────────────────────────────────── -async function buildCreationScreen() { - const raceGrid = document.getElementById('race-grid'); - RACES.forEach(r => { - const card = document.createElement('div'); - card.className = 'sel-card'; - card.innerHTML = `<span class="icon">${r.icon}</span><span class="name">${r.name}</span>`; - card.addEventListener('click', () => { - document.querySelectorAll('#race-grid .sel-card').forEach(c => c.classList.remove('selected')); - card.classList.add('selected'); - selectedRace = r; - checkBeginReady(); - }); - raceGrid.appendChild(card); - }); +function updateCharSentence() { + const d = selectedDescriptor?.name || '<em>___</em>'; + const t = selectedType?.name || '<em>___</em>'; + const f = selectedFocus?.name || '<em>___</em>'; + const wrap = n => `<span class="picked">${n}</span>`; + document.getElementById('char-sentence').innerHTML = + `I am a ${selectedDescriptor ? wrap(d) : d} ${selectedType ? wrap(t) : t} who ${selectedFocus ? wrap(f) : f}`; +} - const classGrid = document.getElementById('class-grid'); - CLASSES.forEach(c => { +function makeSelGrid(gridId, items, getCurrent, setCurrent) { + const grid = document.getElementById(gridId); + items.forEach(item => { const card = document.createElement('div'); card.className = 'sel-card'; - card.innerHTML = `<span class="icon">${c.icon}</span><span class="name">${c.name}</span>`; + card.innerHTML = `<span class="icon">${item.icon}</span><span class="name">${item.name}</span>`; + card.title = item.note || item.playstyle || ''; card.addEventListener('click', () => { - document.querySelectorAll('#class-grid .sel-card').forEach(el => el.classList.remove('selected')); + grid.querySelectorAll('.sel-card').forEach(c => c.classList.remove('selected')); card.classList.add('selected'); - selectedClass = c; + setCurrent(item); + updateCharSentence(); checkBeginReady(); }); - classGrid.appendChild(card); + grid.appendChild(card); }); +} +async function buildCreationScreen() { + makeSelGrid('descriptor-grid', DESCRIPTORS, () => selectedDescriptor, v => { selectedDescriptor = v; }); + makeSelGrid('type-grid', TYPES, () => selectedType, v => { selectedType = v; }); + makeSelGrid('focus-grid', FOCI, () => selectedFocus, v => { selectedFocus = v; }); document.getElementById('begin-btn').addEventListener('click', () => beginAdventure()); document.getElementById('change-key-btn').addEventListener('click', () => openSetupModal(getSharedBlob() ? 'shared' : 'own')); } function checkBeginReady() { - document.getElementById('begin-btn').disabled = !(selectedRace && selectedClass); + document.getElementById('begin-btn').disabled = !(selectedDescriptor && selectedType && selectedFocus); +} + +function buildStartingPools() { + const t = selectedType; + const d = selectedDescriptor; + const bonus = d.poolBonus || {}; + return { + might: { pool: t.pools.might + (bonus.might || 0), max: t.pools.might + (bonus.might || 0), edge: t.edge.might }, + speed: { pool: t.pools.speed + (bonus.speed || 0), max: t.pools.speed + (bonus.speed || 0), edge: t.edge.speed }, + intellect: { pool: t.pools.intellect + (bonus.intellect || 0), max: t.pools.intellect + (bonus.intellect || 0), edge: t.edge.intellect }, + }; } async function beginAdventure() { if (!currentApiKey) { openSetupModal(getSharedBlob() ? 'shared' : 'own'); return; } const apiKey = currentApiKey; - const name = document.getElementById('char-name').value.trim() || `The ${selectedClass.name}`; + const name = document.getElementById('char-name').value.trim() || `The ${selectedType.name}`; const theme = document.getElementById('adventure-theme').value.trim(); worldNotes = document.getElementById('creation-world-notes').value.trim(); + const pools = buildStartingPools(); + gameState = { apiKey, gameId: randomId(), character: { name, - race: selectedRace.name, - class: selectedClass.name, - level: 1, - hp: selectedClass.hp, - maxHp: selectedClass.hp, + descriptor: selectedDescriptor.name, + type: selectedType.name, + focus: selectedFocus.name, + tier: 1, + effort: 1, + might: pools.might, + speed: pools.speed, + intellect: pools.intellect, + damageTrack: 'hale', + recoveryUsed: 0, + cypherLimit: selectedType.cypherLimit, + cyphers: [], + skills: [], + abilities: [], + equipment: [...selectedType.equipment], + shins: 10, xp: 0, - gold: 10, - inventory: [...selectedClass.items], }, theme, - icon: selectedClass.icon, + icon: selectedType.icon, }; conversationHistory = []; @@ -1837,10 +2116,9 @@ async function beginAdventure() { syncWorldNotesSidebar(); document.getElementById('header-title').textContent = - `${name} — ${selectedRace.name} ${selectedClass.name}`; + `${name} — ${selectedDescriptor.name} ${selectedType.name}`; - // Auto-send the opening message - const opening = `The adventure begins. I stand ready, ${name} — a ${selectedRace.name} ${selectedClass.name}${theme ? ` venturing into ${theme}` : ''}. Set the scene. Bring this world to life.`; + 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.`; sendMessage(opening, false); } @@ -1851,32 +2129,62 @@ function updateSidebar() { document.getElementById('char-icon').textContent = gameState.icon; document.getElementById('char-name-display').textContent = c.name; - document.getElementById('char-race-class').textContent = `${c.race} · ${c.class}`; - document.getElementById('level-display').textContent = `Lv ${c.level}`; - - // HP - document.getElementById('hp-display').textContent = `${c.hp} / ${c.maxHp}`; - const hpPct = Math.max(0, (c.hp / c.maxHp) * 100); - const hpFill = document.getElementById('hp-fill'); - hpFill.style.width = hpPct + '%'; - hpFill.className = 'bar-fill'; - if (hpPct <= 25) hpFill.classList.add('danger'); - else if (hpPct <= 50) hpFill.classList.add('warning'); - - // XP - const xpNeeded = XP_THRESHOLDS[c.level - 1] || 999999; - const prevXp = c.level > 1 ? XP_THRESHOLDS[c.level - 2] : 0; - const xpPct = Math.min(100, ((c.xp - prevXp) / (xpNeeded - prevXp)) * 100); - document.getElementById('xp-display').textContent = `${c.xp} / ${xpNeeded}`; - document.getElementById('xp-fill').style.width = xpPct + '%'; - - // Gold - document.getElementById('gold-display').textContent = `${c.gold} GP`; - - // Inventory + document.getElementById('char-identity-sub').textContent = `${c.descriptor} ${c.type} · ${c.focus}`; + + // Pool bars + const pools = ['might', 'speed', 'intellect']; + pools.forEach(stat => { + const pool = c[stat]; + const pct = pool.max > 0 ? Math.max(0, (pool.pool / pool.max) * 100) : 0; + document.getElementById(`${stat}-display`).textContent = `${pool.pool} / ${pool.max}`; + document.getElementById(`${stat}-edge`).textContent = pool.edge > 0 ? `Edge ${pool.edge}` : ''; + const fill = document.getElementById(`${stat}-fill`); + fill.style.width = pct + '%'; + fill.className = 'bar-fill'; + if (pct <= 25) fill.classList.add('danger'); + else if (pct <= 50) fill.classList.add('warning'); + }); + + // Damage track + const badge = document.getElementById('dt-badge'); + badge.className = `dt-badge ${c.damageTrack}`; + document.getElementById('dt-label').textContent = + c.damageTrack.charAt(0).toUpperCase() + c.damageTrack.slice(1); + + // XP and Tier + document.getElementById('xp-display').textContent = c.xp; + const tierNumerals = ['I','II','III','IV','V','VI']; + document.getElementById('tier-display').textContent = tierNumerals[(c.tier || 1) - 1] || c.tier; + + // Shins + document.getElementById('shins-display').textContent = `${c.shins} Shins`; + + // Cyphers + const cypherList = document.getElementById('cyphers-list'); + document.getElementById('cyphers-title').textContent = + `Cyphers (${c.cyphers.length}/${c.cypherLimit})`; + if (c.cyphers.length === 0) { + cypherList.innerHTML = '<span style="font-size:0.78rem;color:var(--text-dim);font-style:italic">No cyphers yet.</span>'; + } else { + cypherList.innerHTML = ''; + c.cyphers.forEach(cypher => { + const div = document.createElement('div'); + div.className = 'cypher-entry'; + div.innerHTML = `<span class="cypher-name">${cypher.name} <small>(lvl ${cypher.level})</small></span>` + + `<span class="cypher-effect">${cypher.effect}</span>`; + cypherList.appendChild(div); + }); + } + if (c.cyphers.length >= c.cypherLimit) { + cypherList.classList.add('cypher-limit-warn'); + } else { + cypherList.classList.remove('cypher-limit-warn'); + } + + // Equipment const list = document.getElementById('inventory-list'); list.innerHTML = ''; - c.inventory.forEach(item => { + (c.equipment || []).forEach(item => { const li = document.createElement('li'); li.textContent = item; list.appendChild(li); @@ -1885,55 +2193,79 @@ function updateSidebar() { function applyStatUpdate(update) { const c = gameState.character; - let leveledUp = false; - let newLevel = c.level; - if (update.hp_change) { - c.hp = Math.max(0, Math.min(c.maxHp, c.hp + update.hp_change)); - } - if (update.gold_change) { - c.gold = Math.max(0, c.gold + update.gold_change); - } - if (update.xp_gain) { - c.xp += update.xp_gain; - while (c.level < 20 && c.xp >= XP_THRESHOLDS[c.level - 1]) { - c.level++; - c.maxHp += Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1); - c.hp = Math.min(c.hp + Math.max(1, Math.floor(CLASSES.find(cl => cl.name === c.class)?.hp / 2) + 1), c.maxHp); - newLevel = c.level; - leveledUp = true; + // Pool damage / restore + const pools = ['might', 'speed', 'intellect']; + pools.forEach(stat => { + const dmg = update[`${stat}_damage`] || 0; + const rst = update[`${stat}_restore`] || 0; + if (dmg) c[stat].pool = Math.max(0, c[stat].pool - dmg); + if (rst) c[stat].pool = Math.min(c[stat].max, c[stat].pool + rst); + }); + + // Recalculate damage track + const zeroPools = pools.filter(s => c[s].pool === 0).length; + if (zeroPools === 0) c.damageTrack = 'hale'; + else if (zeroPools === 1) c.damageTrack = 'impaired'; + else if (zeroPools === 2) c.damageTrack = 'debilitated'; + + // XP and shins + if (update.xp_gain) c.xp = (c.xp || 0) + update.xp_gain; + if (update.shins_change) c.shins = Math.max(0, (c.shins || 0) + update.shins_change); + + // Cyphers + if (update.cyphers_add?.length) { + c.cyphers.push(...update.cyphers_add); + if (c.cyphers.length > c.cypherLimit) { + addSystemMessage(`⚠️ You are carrying ${c.cyphers.length} cyphers — over your limit of ${c.cypherLimit}! Use one before it causes instability.`); } } - if (update.inventory_add?.length) { - c.inventory.push(...update.inventory_add); + if (update.cyphers_remove?.length) { + update.cyphers_remove.forEach(name => { + const idx = c.cyphers.findIndex(cy => cy.name.toLowerCase().includes(name.toLowerCase())); + if (idx !== -1) c.cyphers.splice(idx, 1); + }); } - if (update.inventory_remove?.length) { - update.inventory_remove.forEach(item => { - const idx = c.inventory.findIndex(i => i.toLowerCase().includes(item.toLowerCase())); - if (idx !== -1) c.inventory.splice(idx, 1); + + // Equipment + if (update.equipment_add?.length) c.equipment.push(...update.equipment_add); + if (update.equipment_remove?.length) { + update.equipment_remove.forEach(item => { + const idx = c.equipment.findIndex(e => e.toLowerCase().includes(item.toLowerCase())); + if (idx !== -1) c.equipment.splice(idx, 1); }); } + // Tier advance + let tieredUp = false; + if (update.tier_advance && c.tier < 6) { + c.tier++; + c.effort = c.tier; + tieredUp = true; + } + updateSidebar(); if (update.reason) { addSystemMessage(`⚡ ${update.reason}`); } - if (leveledUp) { - showLevelUp(newLevel); + if (tieredUp) { + showTierUp(c.tier); } - if (c.hp <= 0) { - addSystemMessage('💀 You have fallen. Your adventure ends here... for now.'); + // Death: all three pools at 0 + if (pools.every(s => c[s].pool === 0)) { + addSystemMessage('💀 All your pools have been depleted. Your adventure ends here... for now.'); setBusy(true); } } -function showLevelUp(level) { +function showTierUp(tier) { + const tierNumerals = ['I','II','III','IV','V','VI']; const toast = document.getElementById('levelup-toast'); - document.getElementById('toast-title').textContent = `LEVEL ${level}!`; - document.getElementById('toast-sub').textContent = `${gameState.character.name} grows stronger. The world trembles.`; + document.getElementById('toast-title').textContent = `TIER ${tierNumerals[tier - 1] || tier}!`; + document.getElementById('toast-sub').textContent = `${gameState.character.name} has grown in power. New abilities await.`; toast.style.display = 'block'; setTimeout(() => { if (toast.style.display === 'block') toast.style.display = 'none'; @@ -2359,8 +2691,9 @@ document.getElementById('new-game-btn').addEventListener('click', async () => { document.getElementById('game-screen').classList.remove('visible'); document.getElementById('creation-screen').style.display = ''; document.getElementById('messages').innerHTML = ''; - selectedRace = null; - selectedClass = null; + selectedDescriptor = null; + selectedType = null; + selectedFocus = null; gameState = null; conversationHistory = []; isBusy = false;