fuzzyworld

dm / commit

feat: Phase 5c — advancement modal with full skill training/specialization

7aa0370d19e20169e93ded2e4c8c228cfaa26fab · Pavel Safronov · 2026-05-27 23:14:11 -0700 · parents 86441da64c4b

feat: Phase 5c — advancement modal with full skill training/specialization

Replace the single-click "Spend 4 XP" with a modal offering all CSRD advancement options:
- Increase a pool max by 4 (choose Might/Speed/Intellect)
- Add +1 Edge to a stat
- Train a new skill (text input; upgrades inability→trained if skill exists)
- Specialize in a trained skill (shows only trained-rank skills; upgrades to specialized)
- Other improvement (free text, narrated by Claude)

Two trained ranks of the same skill promote to specialized; specialization is the cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 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 => {