fuzzyworld

dm / commit

feat: complete Phases 3–4 (recovery, advancement, cypher Use, settings)

eabebeeab822cfd3a3c3ff4203d5c965e1fe0736 · Pavel Safronov · 2026-05-27 22:30:52 -0700 · parents efa9a3e4cecf

feat: complete Phases 3–4 (recovery, advancement, cypher Use, settings)

Phase 3 (sidebar):
- Advancement row: 4 dot pips + "Spend 4 XP" button; fills dots on each
  spend, auto-advances Tier and shows toast when 4 are complete
- Recovery section: 4 slot pips + roll button; rolls 1d6+Tier and adds
  to most depleted pool; 4th click resets slots (full rest)
- Cypher Use button: removes cypher and sends activation message to Claude
- Currency label in sidebar reflects setting (shins/gold/dollars/credits)

Phase 4 (setting selector):
- 4 buttons on creation screen: Fantasy / Numenera / Modern / Sci-Fi
- Each sets currency, abilities vocabulary, and DM tone paragraph
  injected into the system prompt
- Setting stored in gameState.character.setting

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