dm / commit
feat: complete Phases 3–4 (recovery, advancement, cypher Use, settings)
plan.md | 23 +++--- public/index.html | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 212 insertions(+), 16 deletions(-) diff --git a/plan.md b/plan.md index efe1985..ab7219f 100644 --- a/plan.md +++ b/plan.md @@ -144,33 +144,34 @@ Complete replacement of `buildSystemPrompt`. Claude needs to understand: - [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) +- [x] Show advancement progress: 4 dot pips below XP/Tier row +- [x] "Spend 4 XP" button — disabled when XP < 4 or Tier 6; spending 4 steps increments dots; at 4 dots tier advances and dots reset ### 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] "Use" button per cypher — removes it and sends activation message to 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 +- [x] Recovery section showing 4 slot pips (empty → filled as used) +- [x] "Recovery Roll (1d6 + Tier)" button — rolls and adds to most depleted pool +- [x] Marks one slot used per roll; when all 4 used button becomes "Full rest (reset slots)" +- [x] Clicking reset button clears all slots --- -## Phase 4 — Setting / genre selector +## 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 +- [x] Setting selector on creation screen: Fantasy / Numenera / Modern / Sci-Fi +- [x] Each setting customises: currency name, special abilities vocabulary, DM tone paragraph +- [x] System prompt includes setting context; currency label shown in sidebar +- [ ] Curated subset of Foci per setting (deferred — all foci remain available for now) --- diff --git a/public/index.html b/public/index.html index edfef06..5242711 100644 --- a/public/index.html +++ b/public/index.html @@ -616,6 +616,30 @@ body { .cypher-use-btn:hover { border-color: var(--gold-dim); color: var(--gold); } .cypher-limit-warn { color: #d97706; font-size: 0.72rem; } +/* Advancement row */ +.adv-row { display:flex; align-items:center; gap:8px; margin:4px 0 6px; flex-wrap:wrap; } +.adv-dots { display:flex; gap:4px; } +.adv-dot { width:9px; height:9px; border-radius:50%; border:1px solid var(--border); background:transparent; transition:background 0.2s; } +.adv-dot.done { background:var(--gold); border-color:var(--gold); } +.adv-label { font-family:'Cinzel',serif; font-size:0.6rem; color:var(--text-dim); flex:1; white-space:nowrap; } +.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; } +.spend-xp-btn:not(:disabled):hover { border-color:var(--gold-dim); color:var(--gold); } +.spend-xp-btn:disabled { opacity:0.4; cursor:default; } + +/* Recovery section */ +.rec-pips { display:flex; gap:6px; align-items:center; margin-bottom:8px; } +.rec-pip { width:11px; height:11px; border-radius:50%; border:1px solid var(--border); background:transparent; transition:background 0.2s; } +.rec-pip.used { background:var(--gold-dim); border-color:var(--gold); } +.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; } +.recovery-roll-btn:not(:disabled):hover { border-color:var(--gold-dim); color:var(--gold); } +.recovery-roll-btn:disabled { opacity:0.4; cursor:default; } + +/* Setting selector */ +.setting-row { display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; } +.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; } +.setting-btn:hover { border-color:var(--gold-dim); color:var(--text-bright); } +.setting-btn.selected { border-color:var(--gold); color:var(--gold); background:rgba(212,175,55,0.08); } + /* Stat row */ .stat-row { display: flex; @@ -1234,6 +1258,16 @@ body { </div> </div> + <div class="creation-section"> + <div class="section-label">Setting</div> + <div class="setting-row" id="setting-row"> + <button class="setting-btn selected" data-setting="fantasy">⚔️ Fantasy</button> + <button class="setting-btn" data-setting="numenera">🔮 Numenera</button> + <button class="setting-btn" data-setting="modern">🌆 Modern</button> + <button class="setting-btn" data-setting="scifi">🚀 Sci-Fi</button> + </div> + </div> + <div class="world-notes-section"> <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> <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> @@ -1325,6 +1359,12 @@ body { </span> </div> + <div class="adv-row"> + <span class="adv-dots" id="adv-dots"></span> + <span class="adv-label" id="adv-label">Steps to Tier II</span> + <button class="spend-xp-btn" id="spend-xp-btn" disabled>Spend 4 XP</button> + </div> + <div class="gold-row"> <span>🪙</span> <span id="shins-display">10 Shins</span> @@ -1343,6 +1383,13 @@ body { <ul id="inventory-list"></ul> </div> + <!-- Recovery --> + <div class="sidebar-section"> + <div class="sidebar-title">Recovery</div> + <div class="rec-pips" id="rec-pips"></div> + <button class="recovery-roll-btn" id="recovery-roll-btn" disabled>🎲 Recovery Roll (1d6 + Tier)</button> + </div> + <!-- Session Cost --> <div class="sidebar-section"> <div class="sidebar-title">Session Cost</div> @@ -1770,10 +1817,13 @@ const UPDATE_TOOL = { function buildSystemPrompt(character, theme, notes) { const c = character; const dt = c.damageTrack || 'hale'; + const setting = SETTINGS[c.setting] || SETTINGS.fantasy; return `You are a masterful Game Master running a solo Cypher System adventure (CSRD rules). CHARACTER: ${c.name} — Tier ${c.tier} ${c.descriptor} ${c.type} who ${c.focus} -ADVENTURE THEME: ${theme || 'classic fantasy'} +SETTING: ${setting.label}${theme ? ` | THEME: ${theme}` : ''} +Currency: ${setting.currency} | Special abilities: ${setting.magic} +${setting.tone} STAT POOLS • Might: ${c.might.pool}/${c.might.max} (Edge ${c.might.edge}) @@ -1822,9 +1872,17 @@ TONE: Cinematic. Dangerous. Immersive. Legendary.${notes ? `\n\nWORLD NOTES — // ── STATE ───────────────────────────────────────────── +const SETTINGS = { + fantasy: { label: 'Classic Fantasy', currency: 'gold coins', magic: 'sorcery and spells', tone: 'Classic sword-and-sorcery: dungeons, dragons, ruins, taverns.' }, + 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.' }, + modern: { label: 'Modern Thriller', currency: 'dollars', magic: 'powers and abilities', tone: 'Contemporary world: urban environments, secret organisations, street-level action.' }, + scifi: { label: 'Science Fiction', currency: 'credits', magic: 'cybernetics and tech', tone: 'Space opera or near-future: starships, aliens, megacorps, advanced technology.' }, +}; + let selectedDescriptor = null; let selectedType = null; let selectedFocus = null; +let selectedSetting = 'fantasy'; let gameState = null; let conversationHistory = []; let isBusy = false; @@ -2094,6 +2152,7 @@ async function beginAdventure() { intellect: pools.intellect, damageTrack: 'hale', recoveryUsed: 0, + advancementSteps: 0, cypherLimit: selectedType.cypherLimit, cyphers: [], skills: [], @@ -2101,6 +2160,7 @@ async function beginAdventure() { equipment: [...selectedType.equipment], shins: 10, xp: 0, + setting: selectedSetting, }, theme, icon: selectedType.icon, @@ -2154,10 +2214,27 @@ function updateSidebar() { // 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; + const tierIdx = (c.tier || 1) - 1; + document.getElementById('tier-display').textContent = tierNumerals[tierIdx] || c.tier; + + // Advancement dots + const steps = c.advancementSteps || 0; + const advDots = document.getElementById('adv-dots'); + advDots.innerHTML = ''; + for (let i = 0; i < 4; i++) { + const dot = document.createElement('span'); + dot.className = 'adv-dot' + (i < steps ? ' done' : ''); + advDots.appendChild(dot); + } + const nextTierLabel = c.tier < 6 ? `Tier ${tierNumerals[tierIdx + 1] || c.tier + 1}` : 'Max Tier'; + document.getElementById('adv-label').textContent = `→ ${nextTierLabel}`; + const spendBtn = document.getElementById('spend-xp-btn'); + spendBtn.disabled = isBusy || c.xp < 4 || c.tier >= 6; - // Shins - document.getElementById('shins-display').textContent = `${c.shins} Shins`; + // Shins (show setting currency label if available) + const settingDef = SETTINGS[c.setting] || SETTINGS.fantasy; + const currencyName = settingDef.currency.split(' ')[0]; + document.getElementById('shins-display').textContent = `${c.shins} ${currencyName.charAt(0).toUpperCase() + currencyName.slice(1)}`; // Cyphers const cypherList = document.getElementById('cyphers-list'); @@ -2172,6 +2249,11 @@ function updateSidebar() { 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>`; + const useBtn = document.createElement('button'); + useBtn.className = 'cypher-use-btn'; + useBtn.textContent = 'Use'; + useBtn.addEventListener('click', () => useCypher(cypher.name)); + div.appendChild(useBtn); cypherList.appendChild(div); }); } @@ -2189,6 +2271,26 @@ function updateSidebar() { li.textContent = item; list.appendChild(li); }); + + // Recovery pips + const recoveryUsed = c.recoveryUsed || 0; + const pipsEl = document.getElementById('rec-pips'); + const recoveryTips = ['1 action', '10 minutes', '1 hour', '10 hours']; + pipsEl.innerHTML = ''; + for (let i = 0; i < 4; i++) { + const pip = document.createElement('span'); + pip.className = 'rec-pip' + (i < recoveryUsed ? ' used' : ''); + pip.title = recoveryTips[i]; + pipsEl.appendChild(pip); + } + const recBtn = document.getElementById('recovery-roll-btn'); + if (recoveryUsed >= 4) { + recBtn.disabled = isBusy; + recBtn.textContent = '🛌 Full rest (reset slots)'; + } else { + recBtn.disabled = isBusy; + recBtn.textContent = '🎲 Recovery Roll (1d6 + Tier)'; + } } function applyStatUpdate(update) { @@ -2210,9 +2312,14 @@ function applyStatUpdate(update) { else if (zeroPools === 2) c.damageTrack = 'debilitated'; // XP and shins - if (update.xp_gain) c.xp = (c.xp || 0) + update.xp_gain; + 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); + // Recovery used (Claude marks a slot; actual roll happens via the sidebar button) + if (update.recovery_used) { + c.recoveryUsed = Math.min(4, (c.recoveryUsed || 0) + 1); + } + // Cyphers if (update.cyphers_add?.length) { c.cyphers.push(...update.cyphers_add); @@ -2272,6 +2379,80 @@ function showTierUp(tier) { }, 6000); } +function useCypher(name) { + if (isBusy || !gameState) return; + const c = gameState.character; + const cypher = c.cyphers.find(cy => cy.name === name); + if (!cypher) return; + c.cyphers = c.cyphers.filter(cy => cy.name !== name); + updateSidebar(); + saveSession(); + sendMessage(`I activate my ${cypher.name} cypher (level ${cypher.level}): ${cypher.effect}`, true); +} + +function spendAdvancement() { + if (isBusy || !gameState) return; + const c = gameState.character; + if (c.xp < 4 || c.tier >= 6) return; + c.xp -= 4; + c.advancementSteps = (c.advancementSteps || 0) + 1; + const tierNumerals = ['I','II','III','IV','V','VI']; + if (c.advancementSteps >= 4) { + c.advancementSteps = 0; + c.tier = Math.min(6, c.tier + 1); + c.effort = c.tier; + updateSidebar(); + saveSession(); + showTierUp(c.tier); + addSystemMessage(`⚡ All 4 advancements complete — you have reached Tier ${tierNumerals[c.tier - 1] || c.tier}!`); + } else { + updateSidebar(); + saveSession(); + addSystemMessage(`⚡ Advancement step ${c.advancementSteps}/4 — ${4 - c.advancementSteps} more to reach Tier ${tierNumerals[c.tier] || c.tier + 1}.`); + } +} + +function rollRecovery() { + if (isBusy || !gameState) return; + const c = gameState.character; + const used = c.recoveryUsed || 0; + + if (used >= 4) { + // Full rest: reset slots + c.recoveryUsed = 0; + updateSidebar(); + saveSession(); + addSystemMessage('🛌 Full rest taken — recovery slots reset.'); + return; + } + + const dieRoll = Math.floor(Math.random() * 6) + 1; + const total = dieRoll + (c.tier || 1); + + // Add to most depleted pool (lowest current value) + const statNames = ['might', 'speed', 'intellect']; + const target = statNames.reduce((lowest, s) => c[s].pool < c[lowest].pool ? s : lowest, statNames[0]); + const before = c[target].pool; + c[target].pool = Math.min(c[target].max, c[target].pool + total); + const gained = c[target].pool - before; + + c.recoveryUsed = used + 1; + + // Recalculate damage track + const zeroPools = statNames.filter(s => c[s].pool === 0).length; + if (zeroPools === 0) c.damageTrack = 'hale'; + else if (zeroPools === 1) c.damageTrack = 'impaired'; + else c.damageTrack = 'debilitated'; + + updateSidebar(); + saveSession(); + + const recoveryLabels = ['1-action', '10-minute', '1-hour', '10-hour']; + const label = recoveryLabels[c.recoveryUsed - 1]; + const statLabel = target.charAt(0).toUpperCase() + target.slice(1); + 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.' : ''}`); +} + // ── DICE ROLLER ─────────────────────────────────────── let selectedDie = 20; @@ -2333,6 +2514,17 @@ document.getElementById('world-notes-save-btn').addEventListener('click', () => applyWorldNotes(); }); +document.getElementById('spend-xp-btn').addEventListener('click', () => spendAdvancement()); +document.getElementById('recovery-roll-btn').addEventListener('click', () => rollRecovery()); + +document.getElementById('setting-row').addEventListener('click', e => { + const btn = e.target.closest('.setting-btn'); + if (!btn) return; + document.querySelectorAll('.setting-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedSetting = btn.dataset.setting; +}); + function rollDice() { if (isBusy) return; const count = Math.min(10, Math.max(1, parseInt(document.getElementById('dice-count').value, 10) || 1)); @@ -2694,6 +2886,9 @@ document.getElementById('new-game-btn').addEventListener('click', async () => { selectedDescriptor = null; selectedType = null; selectedFocus = null; + selectedSetting = 'fantasy'; + document.querySelectorAll('.setting-btn').forEach(b => b.classList.remove('selected')); + document.querySelector('.setting-btn[data-setting="fantasy"]')?.classList.add('selected'); gameState = null; conversationHistory = []; isBusy = false;