diff --git a/docs/plans/2026-02-24-run-loop-plan.md b/docs/plans/2026-02-24-run-loop-plan.md new file mode 100644 index 0000000..ee01628 --- /dev/null +++ b/docs/plans/2026-02-24-run-loop-plan.md @@ -0,0 +1,586 @@ +# Run Loop Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Create a fight → reward → fight → die → restart loop so the game never dead-ends. + +**Architecture:** Add a run state layer above combat state that tracks the player's evolving deck, HP, and card rewards deck across multiple combats. After victory, show 3 cards from the rewards deck for the player to pick from (or skip). After defeat, restart everything fresh. Combat state is created from run state each fight. + +**Tech Stack:** Vanilla JS, ES modules, bun test, biome + +--- + +### Task 1: Run state module + +**Files:** +- Create: `src/run.js` +- Test: `src/run.test.js` + +**Step 1: Write failing tests for createRunState** + +```javascript +import { describe, test, expect } from "bun:test"; +import { createRunState } from "./run.js"; +import { initCards } from "./cards.js"; + +describe("createRunState", () => { + test("initializes ironclad run with starter deck", async () => { + await initCards(); + const run = createRunState("ironclad"); + expect(run.character).toBe("ironclad"); + expect(run.hp).toBe(11); + expect(run.maxHp).toBe(11); + expect(run.deck).toEqual([ + "strike_r", "strike_r", "strike_r", "strike_r", "strike_r", + "defend_r", "defend_r", "defend_r", "defend_r", + "bash", + ]); + expect(run.combatCount).toBe(0); + }); + + test("builds rewards deck from common + uncommon cards", async () => { + await initCards(); + const run = createRunState("ironclad"); + expect(run.cardRewardsDeck.length).toBe(56); + // should not contain starters, rares, or upgraded cards + expect(run.cardRewardsDeck).not.toContain("strike_r"); + expect(run.cardRewardsDeck).not.toContain("bash"); + expect(run.cardRewardsDeck).not.toContain("barricade"); + expect(run.cardRewardsDeck).not.toContain("anger+"); + // should contain common and uncommon + expect(run.cardRewardsDeck).toContain("anger"); + expect(run.cardRewardsDeck).toContain("battle_trance"); + }); + + test("rewards deck is shuffled (not alphabetical)", async () => { + await initCards(); + const run1 = createRunState("ironclad"); + const run2 = createRunState("ironclad"); + // extremely unlikely both are identical if shuffled + const same = run1.cardRewardsDeck.every((c, i) => c === run2.cardRewardsDeck[i]); + expect(same).toBe(false); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/run.test.js` +Expected: FAIL - module not found + +**Step 3: Write createRunState implementation** + +In `src/run.js`: + +```javascript +import { getStarterDeck, getCard } from "./cards.js"; +import { shuffle } from "./state.js"; + +export function createRunState(character) { + return { + character, + hp: 11, + maxHp: 11, + deck: [...getStarterDeck(character)], + cardRewardsDeck: buildRewardsDeck(character), + combatCount: 0, + }; +} + +function buildRewardsDeck(character) { + const cards = []; + // getCard returns undefined for unknown ids, so we need to iterate all cards + // we need access to the card database - add a getAllCards export to cards.js + for (const card of getAllCards()) { + if ( + card.character === character && + (card.rarity === "common" || card.rarity === "uncommon") && + !card.id.endsWith("+") + ) { + cards.push(card.id); + } + } + return shuffle([...cards]); +} +``` + +Note: this requires exporting `shuffle` from state.js and adding `getAllCards` to cards.js. See step 4. + +**Step 4: Export shuffle from state.js, add getAllCards to cards.js** + +In `src/state.js`, the `shuffle` function needs to be exported. Find the existing shuffle function and add `export` keyword. + +In `src/cards.js`, add: + +```javascript +export function getAllCards() { + return Object.values(cardDb); +} +``` + +**Step 5: Run tests to verify they pass** + +Run: `bun test src/run.test.js` +Expected: PASS (3 tests) + +**Step 6: Commit** + +``` +Add run state module with createRunState and rewards deck builder +``` + +--- + +### Task 2: Reveal and pick reward cards + +**Files:** +- Modify: `src/run.js` +- Test: `src/run.test.js` + +**Step 1: Write failing tests for revealRewards and pickReward** + +Add to `src/run.test.js`: + +```javascript +import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js"; + +describe("revealRewards", () => { + test("reveals top 3 cards from rewards deck", async () => { + await initCards(); + const run = createRunState("ironclad"); + const top3 = run.cardRewardsDeck.slice(0, 3); + const result = revealRewards(run); + expect(result.revealed).toEqual(top3); + expect(result.cardRewardsDeck.length).toBe(53); + }); + + test("reveals fewer if deck has < 3 cards", async () => { + await initCards(); + const run = createRunState("ironclad"); + run.cardRewardsDeck = ["anger", "flex"]; + const result = revealRewards(run); + expect(result.revealed).toEqual(["anger", "flex"]); + expect(result.cardRewardsDeck.length).toBe(0); + }); +}); + +describe("pickReward", () => { + test("adds picked card to deck", async () => { + await initCards(); + const run = createRunState("ironclad"); + const revealed = ["anger", "flex", "cleave"]; + const result = pickReward(run, revealed, 0); + expect(result.deck).toContain("anger"); + expect(result.deck.length).toBe(11); // 10 starter + 1 + }); + + test("does not return unpicked cards to rewards deck", async () => { + await initCards(); + const run = createRunState("ironclad"); + // remove these from rewards deck so we can track them + run.cardRewardsDeck = run.cardRewardsDeck.filter( + (c) => !["anger", "flex", "cleave"].includes(c) + ); + const revealed = ["anger", "flex", "cleave"]; + const result = pickReward(run, revealed, 0); + // flex and cleave are gone (not in deck, not in rewards) + expect(result.deck).not.toContain("flex"); + expect(result.cardRewardsDeck).not.toContain("flex"); + }); +}); + +describe("skipRewards", () => { + test("shuffles all revealed cards back into rewards deck", async () => { + await initCards(); + const run = createRunState("ironclad"); + run.cardRewardsDeck = ["a", "b", "c"]; + const revealed = ["x", "y", "z"]; + const result = skipRewards(run, revealed); + expect(result.cardRewardsDeck.length).toBe(6); + expect(result.cardRewardsDeck).toContain("x"); + expect(result.cardRewardsDeck).toContain("y"); + expect(result.cardRewardsDeck).toContain("z"); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/run.test.js` +Expected: FAIL - functions not exported + +**Step 3: Implement revealRewards, pickReward, skipRewards** + +Add to `src/run.js`: + +```javascript +export function revealRewards(run) { + const count = Math.min(3, run.cardRewardsDeck.length); + const revealed = run.cardRewardsDeck.slice(0, count); + const remaining = run.cardRewardsDeck.slice(count); + return { ...run, revealed, cardRewardsDeck: remaining }; +} + +export function pickReward(run, revealed, index) { + const picked = revealed[index]; + return { + ...run, + deck: [...run.deck, picked], + }; +} + +export function skipRewards(run, revealed) { + return { + ...run, + cardRewardsDeck: shuffle([...run.cardRewardsDeck, ...revealed]), + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/run.test.js` +Expected: PASS (all tests) + +**Step 5: Commit** + +``` +Add reward reveal, pick, and skip functions +``` + +--- + +### Task 3: Create combat from run state + +**Files:** +- Modify: `src/state.js` +- Test: `src/state.test.js` (or add to existing tests) + +**Step 1: Write failing tests for createCombatFromRun** + +Find existing state tests and add (or create `src/run.test.js` additions): + +```javascript +import { createCombatFromRun } from "./state.js"; +import { initCards } from "./cards.js"; +import { initEnemies } from "./enemies.js"; +import { createRunState } from "./run.js"; + +describe("createCombatFromRun", () => { + test("uses run deck instead of starter deck", async () => { + await initCards(); + await initEnemies(); + const run = createRunState("ironclad"); + run.deck = [...run.deck, "anger"]; // 11 cards + const state = createCombatFromRun(run, "jaw_worm"); + const allCards = [ + ...state.players[0].drawPile, + ...state.players[0].hand, + ...state.players[0].discardPile, + ]; + expect(allCards.length).toBe(11); + expect(allCards).toContain("anger"); + }); + + test("uses run HP", async () => { + await initCards(); + await initEnemies(); + const run = createRunState("ironclad"); + run.hp = 7; + const state = createCombatFromRun(run, "jaw_worm"); + expect(state.players[0].hp).toBe(7); + expect(state.players[0].maxHp).toBe(11); + }); + + test("increments combatCount on run", async () => { + await initCards(); + await initEnemies(); + const run = createRunState("ironclad"); + expect(run.combatCount).toBe(0); + const state = createCombatFromRun(run, "jaw_worm"); + // combat state should carry the combat number + expect(state.combat.combatCount).toBe(1); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/state.test.js` (or whichever file) +Expected: FAIL + +**Step 3: Implement createCombatFromRun** + +Add to `src/state.js`: + +```javascript +export function createCombatFromRun(run, enemyIdOrIds) { + const enemyIds = Array.isArray(enemyIdOrIds) ? enemyIdOrIds : [enemyIdOrIds]; + const enemies = enemyIds.map((id, i) => makeEnemy(id, i)); + + const player = { + id: 0, + character: run.character, + hp: run.hp, + maxHp: run.maxHp, + energy: 3, + maxEnergy: 3, + block: 0, + strength: 0, + vulnerable: 0, + weak: 0, + drawPile: shuffle([...run.deck]), + hand: [], + discardPile: [], + exhaustPile: [], + powers: [], + }; + + return { + players: [player], + enemies, + combat: { + turn: 1, + phase: "player_turn", + dieResult: null, + selectedCard: null, + log: [], + playerCount: 1, + activePlayerIndex: null, + playersReady: [], + combatCount: run.combatCount + 1, + }, + player, + enemy: enemies[0], + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `bun test` +Expected: PASS + +**Step 5: Commit** + +``` +Add createCombatFromRun to build combat state from run +``` + +--- + +### Task 4: Wire the run loop in main.js + +**Files:** +- Modify: `src/main.js` + +This task connects the run state, combat, rewards, and restart flow. No unit tests for this task (it's DOM wiring) - we'll verify by playing. + +**Step 1: Update init() to create run state** + +Replace the current init: + +```javascript +import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js"; +import { createCombatFromRun } from "./state.js"; + +let state = null; +let run = null; +let revealed = null; + +async function init() { + await Promise.all([initCards(), initEnemies()]); + startNewRun(); +} + +function startNewRun() { + run = createRunState("ironclad"); + startNextCombat(); +} + +function startNextCombat() { + run = { ...run, combatCount: run.combatCount + 1 }; + state = createCombatFromRun(run, "jaw_worm"); + state = startTurn(state); + revealed = null; + render(state); +} +``` + +**Step 2: Update victory handling to show rewards phase** + +When combat ends in victory, transition to rewards instead of dead-ending. In the places where `checkCombatEnd` returns "victory": + +```javascript +const result = checkCombatEnd(state); +if (result === "victory") { + state = { ...state, combat: { ...state.combat, phase: "rewards" } }; + const rewardResult = revealRewards(run); + run = rewardResult; + revealed = rewardResult.revealed; + render(state, revealed); + return; +} +if (result === "defeat") { + state = { ...state, combat: { ...state.combat, phase: "ended", result: "defeat" } }; + render(state); + return; +} +``` + +**Step 3: Update defeat handling to allow restart** + +Add click handler on overlay for restart: + +```javascript +document.getElementById("overlay").addEventListener("click", () => { + if (state.combat.phase === "ended" && state.combat.result === "defeat") { + startNewRun(); + } +}); +``` + +**Step 4: Commit** + +``` +Wire run loop in main.js with victory rewards and defeat restart +``` + +--- + +### Task 5: Render rewards screen and handle pick/skip + +**Files:** +- Modify: `src/render.js` +- Modify: `src/main.js` +- Modify: `index.html` + +**Step 1: Add skip button to HTML** + +In `index.html`, inside or near the overlay, add a skip button (can be dynamically shown): + +```html + +``` + +**Step 2: Update renderOverlay to handle rewards phase** + +In `src/render.js`, update `renderOverlay`: + +```javascript +function renderOverlay(state, revealed) { + const overlay = document.getElementById("overlay"); + const overlayText = document.getElementById("overlay-text"); + const rewardCards = document.getElementById("reward-cards"); + const skipBtn = document.getElementById("skip-btn"); + + if (state.combat.phase === "rewards" && revealed) { + overlay.hidden = false; + overlayText.textContent = "card reward"; + rewardCards.innerHTML = ""; + for (const cardId of revealed) { + const card = getCard(cardId); + const img = document.createElement("img"); + img.src = card.image || ""; + img.alt = card.name; + img.title = `${card.name} (${card.cost}) - ${card.description}`; + img.dataset.cardId = cardId; + img.className = "reward-card"; + rewardCards.appendChild(img); + } + skipBtn.hidden = false; + } else if (state.combat.phase === "ended") { + overlay.hidden = false; + overlayText.textContent = state.combat.result === "defeat" + ? "defeat - click to restart" + : "victory"; + rewardCards.innerHTML = ""; + skipBtn.hidden = true; + } else { + overlay.hidden = true; + rewardCards.innerHTML = ""; + skipBtn.hidden = true; + } +} +``` + +Update `render` function signature to pass `revealed`: + +```javascript +export function render(state, revealed) { + renderEnemy(state); + renderInfoBar(state); + renderHand(state); + renderOverlay(state, revealed); +} +``` + +**Step 3: Add reward pick and skip event handlers in main.js** + +```javascript +document.getElementById("reward-cards").addEventListener("click", (e) => { + const img = e.target.closest(".reward-card"); + if (!img || !revealed) return; + const cardId = img.dataset.cardId; + const index = revealed.indexOf(cardId); + if (index === -1) return; + run = pickReward(run, revealed, index); + // sync hp back from combat + run = { ...run, hp: state.players[0].hp }; + revealed = null; + startNextCombat(); +}); + +document.getElementById("skip-btn").addEventListener("click", () => { + if (!revealed) return; + run = skipRewards(run, revealed); + run = { ...run, hp: state.players[0].hp }; + revealed = null; + startNextCombat(); +}); +``` + +**Step 4: Commit** + +``` +Add reward card rendering with pick and skip handlers +``` + +--- + +### Task 6: Run checks and verify + +**Step 1: Run biome check** + +Run: `bun run check` +Expected: PASS (lint + format + tests) + +**Step 2: Manual play test** + +Run: `bun run dev` + +Verify: +- Fight jaw worm, win → see 3 reward cards +- Pick a card → new combat starts, HP carried over +- Win again → rewards again (deck is bigger now) +- Lose → "defeat - click to restart" → click → fresh run +- Skip rewards → next combat starts, rewards deck has those cards back + +**Step 3: Fix any issues found** + +**Step 4: Commit any fixes** + +--- + +## Task checklist + +- [ ] Task 1: Run state module (createRunState, rewards deck builder) +- [ ] Task 2: Reveal and pick reward cards (revealRewards, pickReward, skipRewards) +- [ ] Task 3: Create combat from run state (createCombatFromRun) +- [ ] Task 4: Wire the run loop in main.js +- [ ] Task 5: Render rewards screen and handle pick/skip +- [ ] Task 6: Run checks and verify