dm / commit
feat: Phase 5c — advancement modal with full skill training/specialization
plan.md | 7 ++- public/index.html | 182 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/plan.md b/plan.md index 284d072..7ac2928 100644 --- a/plan.md +++ b/plan.md @@ -199,9 +199,10 @@ Several Type Tier 1 abilities grant skill training ("Physical Skills", "Interact The XP advancement path ("train a skill") is currently only narrated. To make it mechanical: -- [ ] Add a "Train a Skill" option to the Spend 4 XP flow — prompts for skill name, adds it as trained -- [ ] Allow upgrading trained → specialized for another advancement step -- [ ] Specialization cap at 2 levels; inability cannot be trained away (only reduced to trained via advancement) +- [x] Add a "Train a Skill" option to the Spend 4 XP flow — prompts for skill name, adds it as trained +- [x] Allow upgrading trained → specialized for another advancement step +- [x] Specialization cap at 2 levels; inability cannot be trained away (only reduced to trained via advancement) +- [x] Full advancement modal: pool +4, edge +1, train skill, specialize skill, other improvement ### 5d — Skill lookup in task resolution diff --git a/public/index.html b/public/index.html index a16f0a2..65a516f 100644 --- a/public/index.html +++ b/public/index.html @@ -1152,6 +1152,37 @@ body { } .version-actions { display: flex; gap: 10px; flex-wrap: wrap; } +/* ── ADVANCEMENT MODAL ──────────────────────────── */ + +#advancement-modal { + display: none; position: fixed; inset: 0; z-index: 400; + background: rgba(0,0,0,0.75); align-items: center; justify-content: center; +} +#advancement-modal.open { display: flex; } +.adv-inner { + background: linear-gradient(135deg, #0a0a14, #0e0e1e); + border: 1px solid var(--gold-dim); border-radius: 10px; + padding: 28px 32px; width: 440px; max-width: 95vw; + box-shadow: 0 0 60px rgba(201,168,76,0.15), 0 20px 60px rgba(0,0,0,0.8); +} +.adv-title { font-family:'Cinzel',serif; font-size:1rem; font-weight:600; color:var(--gold-light); margin-bottom:18px; letter-spacing:0.06em; } +.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; } +.adv-option:hover { border-color:var(--gold-dim); color:var(--text); } +.adv-option.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.08); } +.adv-option.unavailable { opacity:0.35; cursor:default; pointer-events:none; } +.adv-stat-btns { display:flex; gap:8px; margin-top:10px; } +.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; } +.adv-stat-btn:hover { border-color:var(--gold-dim); color:var(--gold); } +.adv-stat-btn.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.1); } +.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; } +.adv-skill-input:focus { outline:none; border-color:var(--gold-dim); } +.adv-skill-list { margin-top:10px; display:flex; flex-direction:column; gap:6px; max-height:160px; overflow-y:auto; } +.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; } +.adv-skill-item:hover { border-color:var(--gold-dim); color:var(--text); } +.adv-skill-item.selected { border-color:var(--gold); color:var(--gold-light); background:rgba(201,168,76,0.08); } +.adv-footer { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; } +.adv-sub-label { font-size:0.8rem; color:var(--text-dim); margin-bottom:2px; } + /* ── REPORT MODAL ───────────────────────────────── */ #report-modal { @@ -1456,6 +1487,25 @@ body { </div> <!-- Version mismatch modal --> +<!-- Advancement modal --> +<div id="advancement-modal"> + <div class="adv-inner"> + <div class="adv-title">⚡ Spend 4 XP — Choose Advancement</div> + <div id="adv-options"> + <div class="adv-option" data-opt="pool">📈 Increase a pool max by 4</div> + <div class="adv-option" data-opt="edge">⚔️ Add +1 Edge to a stat</div> + <div class="adv-option" data-opt="train">🎓 Train a new skill</div> + <div class="adv-option" data-opt="specialize">⭐ Specialize in a trained skill</div> + <div class="adv-option" data-opt="other">✨ Other improvement</div> + </div> + <div id="adv-sub"></div> + <div class="adv-footer"> + <button class="share-btn share-btn-secondary" id="adv-cancel-btn">Cancel</button> + <button class="share-btn" id="adv-confirm-btn" disabled>Confirm</button> + </div> + </div> +</div> + <div id="version-modal"> <div class="version-inner"> <div class="version-title">📜 Game Format Updated</div> @@ -2690,25 +2740,130 @@ function useCypher(name) { sendMessage(`I activate my ${cypher.name} cypher (level ${cypher.level}): ${cypher.effect}`, true); } -function spendAdvancement() { +let _advChoice = null; + +function openAdvancementModal() { if (isBusy || !gameState) return; const c = gameState.character; if (c.xp < 4 || c.tier >= 6) return; + _advChoice = null; + document.querySelectorAll('.adv-option').forEach(el => el.classList.remove('selected')); + document.getElementById('adv-sub').innerHTML = ''; + document.getElementById('adv-confirm-btn').disabled = true; + const trainedSkills = (c.skills || []).filter(s => s.rank === 'trained'); + document.querySelector('.adv-option[data-opt="specialize"]') + .classList.toggle('unavailable', trainedSkills.length === 0); + document.getElementById('advancement-modal').classList.add('open'); +} + +function selectAdvOption(opt) { + _advChoice = { type: opt }; + document.querySelectorAll('.adv-option').forEach(el => el.classList.remove('selected')); + document.querySelector(`.adv-option[data-opt="${opt}"]`).classList.add('selected'); + const sub = document.getElementById('adv-sub'); + const confirmBtn = document.getElementById('adv-confirm-btn'); + confirmBtn.disabled = true; + const c = gameState.character; + + if (opt === 'pool' || opt === 'edge') { + const label = opt === 'pool' ? 'Which pool to increase by 4:' : 'Which stat to gain +1 Edge:'; + const hint = opt === 'edge' + ? `<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>` + : ''; + sub.innerHTML = `<div class="adv-sub-label">${label}</div> + <div class="adv-stat-btns"> + <button class="adv-stat-btn" data-stat="might">Might</button> + <button class="adv-stat-btn" data-stat="speed">Speed</button> + <button class="adv-stat-btn" data-stat="intellect">Intellect</button> + </div>${hint}`; + sub.querySelectorAll('.adv-stat-btn').forEach(btn => { + btn.addEventListener('click', () => { + sub.querySelectorAll('.adv-stat-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + _advChoice.stat = btn.dataset.stat; + confirmBtn.disabled = false; + }); + }); + } else if (opt === 'train') { + sub.innerHTML = `<div class="adv-sub-label">Skill name to train (or upgrade inability → trained):</div> + <input class="adv-skill-input" id="adv-skill-name" type="text" placeholder="e.g. Climbing, Stealth, History…" maxlength="60">`; + const input = document.getElementById('adv-skill-name'); + input.addEventListener('input', () => { confirmBtn.disabled = input.value.trim().length === 0; }); + setTimeout(() => input.focus(), 50); + } else if (opt === 'specialize') { + const trained = (c.skills || []).filter(s => s.rank === 'trained'); + sub.innerHTML = `<div class="adv-sub-label">Choose a trained skill to specialize in:</div> + <div class="adv-skill-list">${trained.map(s => + `<div class="adv-skill-item" data-skill="${s.name}">${s.name}<span class="skill-rank trained" style="font-size:0.6rem">TRAINED</span></div>` + ).join('')}</div>`; + sub.querySelectorAll('.adv-skill-item').forEach(item => { + item.addEventListener('click', () => { + sub.querySelectorAll('.adv-skill-item').forEach(i => i.classList.remove('selected')); + item.classList.add('selected'); + _advChoice.skillName = item.dataset.skill; + confirmBtn.disabled = false; + }); + }); + } else if (opt === 'other') { + sub.innerHTML = `<div class="adv-sub-label">Describe your improvement (Claude will narrate it):</div> + <input class="adv-skill-input" id="adv-other-desc" type="text" placeholder="e.g. Gain a contact in the city…" maxlength="120">`; + const input = document.getElementById('adv-other-desc'); + input.addEventListener('input', () => { confirmBtn.disabled = input.value.trim().length === 0; }); + setTimeout(() => input.focus(), 50); + } +} + +function confirmAdvancement() { + if (!_advChoice || !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']; + let msg = ''; + const { type, stat } = _advChoice; + + if (type === 'pool' && stat) { + c[stat].max += 4; + c[stat].pool = Math.min(c[stat].pool + 4, c[stat].max); + msg = `📈 ${stat[0].toUpperCase() + stat.slice(1)} pool max increased by 4 (now ${c[stat].max}).`; + } else if (type === 'edge' && stat) { + c[stat].edge += 1; + msg = `⚔️ ${stat[0].toUpperCase() + stat.slice(1)} Edge increased to ${c[stat].edge}.`; + } else if (type === 'train') { + const name = document.getElementById('adv-skill-name')?.value.trim(); + if (!name) { c.xp += 4; c.advancementSteps -= 1; return; } + const existing = (c.skills || []).find(s => s.name.toLowerCase() === name.toLowerCase()); + if (existing) { + if (existing.rank === 'inability') { existing.rank = 'trained'; msg = `🎓 ${existing.name} reduced from Inability to Trained.`; } + else if (existing.rank === 'trained') { existing.rank = 'specialized'; msg = `⭐ ${existing.name} upgraded from Trained to Specialized.`; } + else { msg = `🎓 Already Specialized in ${existing.name}.`; } + } else { + c.skills = [...(c.skills || []), { name, rank: 'trained' }]; + msg = `🎓 Now trained in ${name}.`; + } + } else if (type === 'specialize' && _advChoice.skillName) { + const skill = (c.skills || []).find(s => s.name === _advChoice.skillName); + if (skill) { skill.rank = 'specialized'; msg = `⭐ ${skill.name} upgraded from Trained to Specialized.`; } + } else if (type === 'other') { + const desc = document.getElementById('adv-other-desc')?.value.trim(); + if (!desc) { c.xp += 4; c.advancementSteps -= 1; return; } + msg = `✨ Advancement: ${desc}`; + } + + document.getElementById('advancement-modal').classList.remove('open'); + _advChoice = null; + if (c.advancementSteps >= 4) { c.advancementSteps = 0; c.tier = Math.min(6, c.tier + 1); c.effort = c.tier; - updateSidebar(); - saveSession(); + updateSidebar(); saveSession(); showTierUp(c.tier); - addSystemMessage(`⚡ All 4 advancements complete — you have reached Tier ${tierNumerals[c.tier - 1] || c.tier}!`); + addSystemMessage(`${msg}\n⚡ All 4 advancements complete — Tier ${tierNumerals[c.tier - 1]}!`); } else { - updateSidebar(); - saveSession(); - addSystemMessage(`⚡ Advancement step ${c.advancementSteps}/4 — ${4 - c.advancementSteps} more to reach Tier ${tierNumerals[c.tier] || c.tier + 1}.`); + updateSidebar(); saveSession(); + addSystemMessage(`${msg} (Step ${c.advancementSteps}/4 — ${4 - c.advancementSteps} more to Tier ${tierNumerals[c.tier] || c.tier + 1}.)`); } } @@ -2814,7 +2969,18 @@ document.getElementById('world-notes-save-btn').addEventListener('click', () => applyWorldNotes(); }); -document.getElementById('spend-xp-btn').addEventListener('click', () => spendAdvancement()); +document.getElementById('spend-xp-btn').addEventListener('click', openAdvancementModal); +document.querySelectorAll('.adv-option').forEach(opt => { + opt.addEventListener('click', () => { if (!opt.classList.contains('unavailable')) selectAdvOption(opt.dataset.opt); }); +}); +document.getElementById('adv-cancel-btn').addEventListener('click', () => { + document.getElementById('advancement-modal').classList.remove('open'); +}); +document.getElementById('adv-confirm-btn').addEventListener('click', confirmAdvancement); +document.getElementById('advancement-modal').addEventListener('click', e => { + if (e.target === document.getElementById('advancement-modal')) + document.getElementById('advancement-modal').classList.remove('open'); +}); document.getElementById('recovery-roll-btn').addEventListener('click', () => rollRecovery()); document.getElementById('setting-row').addEventListener('click', e => {