# Act 1 Single-Player Run Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build a complete single-player Ironclad Act 1 run — linear map, combat encounters, card rewards, campfire, elite, and boss fight. **Architecture:** Layered on the existing combat engine. A run state module tracks HP, deck, potions, and rewards deck across combats. A map module defines a linear sequence of nodes. Main.js orchestrates the flow: map screen -> node handler (combat/campfire) -> rewards -> map screen. All new code follows the existing immutable state pattern. **Tech Stack:** Vanilla JS, ES modules, bun test, biome (2-space indent) **Design doc:** `docs/plans/2026-02-24-act1-single-player-design.md` **Existing work:** A run-loop plan exists at `docs/plans/2026-02-24-run-loop-plan.md` covering Tasks 1-3 below (run state, rewards, combat-from-run). This plan supersedes it and extends through the full Act 1 loop. --- ## Phase A: Run State + Rewards (combat loop without map) ### Task 1: Export shuffle from state.js, add getAllCards to cards.js **Files:** - Modify: `src/state.js:195` (export shuffle) - Modify: `src/cards.js` (add getAllCards) - Test: `src/cards.test.js` (add getAllCards test) **Step 1: Write failing test for getAllCards** Add to `src/cards.test.js`: ```javascript import { getAllCards, getCard, initCards } from "./cards.js"; describe("getAllCards", () => { test("returns all cards as an array", async () => { await initCards(); const cards = getAllCards(); expect(cards.length).toBeGreaterThan(100); expect(cards[0]).toHaveProperty("id"); expect(cards[0]).toHaveProperty("name"); }); }); ``` **Step 2: Run test to verify it fails** Run: `bun test src/cards.test.js` Expected: FAIL - getAllCards is not a function **Step 3: Implement getAllCards and export shuffle** In `src/cards.js`, add after getStarterDeck: ```javascript export function getAllCards() { return Object.values(cardDb); } ``` In `src/state.js` line 195, change `function shuffle` to `export function shuffle`. **Step 4: Run tests to verify they pass** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Export shuffle from state and add getAllCards to cards module ``` --- ### Task 2: Run state module — createRunState **Files:** - Create: `src/run.js` - Create: `src/run.test.js` **Step 1: Write failing tests for createRunState** ```javascript import { beforeAll, describe, expect, test } from "bun:test"; import { initCards } from "./cards.js"; import { createRunState } from "./run.js"; beforeAll(async () => { await initCards(); }); describe("createRunState", () => { test("initializes ironclad run with starter deck and HP", () => { 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); expect(run.potions).toEqual([]); }); test("builds rewards deck from common + uncommon cards only", () => { const run = createRunState("ironclad"); expect(run.cardRewardsDeck.length).toBeGreaterThan(20); // no starters, rares, or upgraded cards expect(run.cardRewardsDeck).not.toContain("strike_r"); expect(run.cardRewardsDeck).not.toContain("bash"); expect(run.cardRewardsDeck.every((id) => !id.endsWith("+"))).toBe(true); }); test("rewards deck is shuffled (not sorted)", () => { const run1 = createRunState("ironclad"); const run2 = createRunState("ironclad"); const sorted = [...run1.cardRewardsDeck].sort(); // at least one card is out of sorted order const isSorted = run1.cardRewardsDeck.every((c, i) => c === sorted[i]); expect(isSorted).toBe(false); }); }); ``` **Step 2: Run test to verify it fails** Run: `bun test src/run.test.js` Expected: FAIL - module not found **Step 3: Implement createRunState** Create `src/run.js`: ```javascript import { getAllCards, getStarterDeck } from "./cards.js"; import { shuffle } from "./state.js"; export function createRunState(character) { return { character, hp: 11, maxHp: 11, deck: [...getStarterDeck(character)], cardRewardsDeck: buildRewardsDeck(character), potions: [], combatCount: 0, }; } function buildRewardsDeck(character) { const ids = []; for (const card of getAllCards()) { if ( card.character === character && (card.rarity === "common" || card.rarity === "uncommon") && !card.id.endsWith("+") ) { ids.push(card.id); } } return shuffle(ids); } ``` **Step 4: Run tests to verify they pass** Run: `bun test src/run.test.js` Expected: PASS (3 tests) **Step 5: Commit** ``` Add run state module with createRunState and rewards deck builder ``` --- ### Task 3: Reward reveal, pick, and skip **Files:** - Modify: `src/run.js` - Modify: `src/run.test.js` **Step 1: Write failing tests** Add to `src/run.test.js`: ```javascript import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js"; describe("revealRewards", () => { test("reveals top 3 from rewards deck", () => { const run = createRunState("ironclad"); const top3 = run.cardRewardsDeck.slice(0, 3); const result = revealRewards(run); expect(result.revealed).toEqual(top3); expect(result.run.cardRewardsDeck.length).toBe(run.cardRewardsDeck.length - 3); }); test("reveals fewer if deck has < 3 cards", () => { let run = createRunState("ironclad"); run = { ...run, cardRewardsDeck: ["anger", "flex"] }; const result = revealRewards(run); expect(result.revealed).toEqual(["anger", "flex"]); expect(result.run.cardRewardsDeck.length).toBe(0); }); }); describe("pickReward", () => { test("adds picked card to deck", () => { 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); }); test("unpicked cards are discarded (not returned to rewards deck)", () => { let run = createRunState("ironclad"); run = { ...run, cardRewardsDeck: [] }; const revealed = ["anger", "flex", "cleave"]; const result = pickReward(run, revealed, 0); expect(result.cardRewardsDeck).not.toContain("flex"); expect(result.cardRewardsDeck).not.toContain("cleave"); }); }); describe("skipRewards", () => { test("shuffles all revealed back into rewards deck", () => { let run = createRunState("ironclad"); run = { ...run, cardRewardsDeck: ["a", "b"] }; const revealed = ["x", "y", "z"]; const result = skipRewards(run, revealed); expect(result.cardRewardsDeck.length).toBe(5); 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** 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 { revealed, run: { ...run, 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** Run: `bun test src/run.test.js` Expected: PASS **Step 5: Commit** ``` Add reward reveal, pick, and skip functions to run module ``` --- ### Task 4: Create combat state from run **Files:** - Modify: `src/state.js` - Modify: `src/state.test.js` **Step 1: Write failing tests** Add to `src/state.test.js`: ```javascript import { createCombatFromRun } from "./state.js"; import { createRunState } from "./run.js"; describe("createCombatFromRun", () => { test("uses run deck instead of starter deck", () => { let run = createRunState("ironclad"); run = { ...run, deck: [...run.deck, "anger"] }; 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 instead of max HP", () => { let run = createRunState("ironclad"); run = { ...run, hp: 7 }; const state = createCombatFromRun(run, "jaw_worm"); expect(state.players[0].hp).toBe(7); expect(state.players[0].maxHp).toBe(11); }); test("backward-compat aliases work", () => { const run = createRunState("ironclad"); const state = createCombatFromRun(run, "jaw_worm"); expect(state.player).toBeDefined(); expect(state.enemy).toBeDefined(); expect(state.player.hp).toBe(11); }); }); ``` **Step 2: Run tests to verify they fail** Run: `bun test src/state.test.js` Expected: FAIL - createCombatFromRun is not exported **Step 3: Implement** Add to `src/state.js` after createCombatState: ```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: "player_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: [], }; const state = { players: [player], enemies, combat: { turn: 1, phase: "player_turn", dieResult: null, selectedCard: null, log: [], playerCount: 1, activePlayerIndex: null, playersReady: [], }, }; state.player = player; state.enemy = enemies[0]; return state; } ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add createCombatFromRun to create combat state from run ``` --- ### Task 5: Wire run loop in main.js **Files:** - Modify: `src/main.js` - Modify: `src/render.js` - Modify: `index.html` This is DOM wiring — verified by manual play, not unit tests. **Step 1: Update index.html overlay structure** Replace the current `` with: ```html ``` **Step 2: Update render.js to handle rewards phase** Change the `render` function signature and update `renderOverlay`: ```javascript export function render(state, revealed) { renderEnemy(state); renderInfoBar(state); renderHand(state); renderOverlay(state, revealed); } ``` Replace `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; } } ``` **Step 3: Rewrite main.js for run loop** Replace the contents of `src/main.js`: ```javascript import { getCard, initCards } from "./cards.js"; import { checkCombatEnd, resolveEnemyTurn, startTurn } from "./combat.js"; import { initEnemies } from "./enemies.js"; import { render } from "./render.js"; import { createRunState, pickReward, revealRewards, skipRewards } from "./run.js"; import { createCombatFromRun, endTurn, playCard } from "./state.js"; let state = null; let run = null; let revealed = null; async function init() { await Promise.all([initCards(), initEnemies()]); startNewRun(); bindEvents(); } function startNewRun() { run = createRunState("ironclad"); startNextCombat(); } function startNextCombat() { state = createCombatFromRun(run, "jaw_worm"); state = startTurn(state); revealed = null; render(state); } function handleVictory() { state = { ...state, combat: { ...state.combat, phase: "rewards" } }; const result = revealRewards(run); run = result.run; revealed = result.revealed; render(state, revealed); } function handleDefeat() { state = { ...state, combat: { ...state.combat, phase: "ended", result: "defeat" }, }; render(state); } function checkEnd() { const result = checkCombatEnd(state); if (result === "victory") { handleVictory(); return true; } if (result === "defeat") { handleDefeat(); return true; } return false; } function syncRunHp() { run = { ...run, hp: state.players[0].hp }; } function bindEvents() { document.getElementById("hand").addEventListener("click", (e) => { const cardEl = e.target.closest(".card"); if (!cardEl || state.combat.phase !== "player_turn") return; const index = Number(cardEl.dataset.index); if (state.combat.selectedCard === index) { state = { ...state, combat: { ...state.combat, selectedCard: null } }; render(state); return; } state = { ...state, combat: { ...state.combat, selectedCard: index } }; const cardId = state.player.hand[index]; const card = getCard(cardId); if (card.type === "skill") { const result = playCard(state, index); if (result === null) { state = { ...state, combat: { ...state.combat, selectedCard: null } }; render(state); return; } state = { ...result, combat: { ...result.combat, selectedCard: null } }; if (checkEnd()) return; render(state); return; } render(state); }); document.getElementById("enemy-zone").addEventListener("click", () => { if (state.combat.selectedCard === null) return; if (state.combat.phase !== "player_turn") return; const result = playCard(state, state.combat.selectedCard); if (result === null) { state = { ...state, combat: { ...state.combat, selectedCard: null } }; render(state); return; } state = { ...result, combat: { ...result.combat, selectedCard: null } }; if (checkEnd()) return; render(state); }); document .getElementById("end-turn-btn") .addEventListener("click", async () => { if (state.combat.phase !== "player_turn") return; state = endTurn(state); render(state); await delay(800); state = resolveEnemyTurn(state); if (checkEnd()) return; state = startTurn(state); render(state); }); 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; syncRunHp(); run = pickReward(run, revealed, index); run = { ...run, combatCount: run.combatCount + 1 }; startNextCombat(); }); document.getElementById("skip-btn").addEventListener("click", () => { if (!revealed) return; syncRunHp(); run = skipRewards(run, revealed); run = { ...run, combatCount: run.combatCount + 1 }; startNextCombat(); }); document.getElementById("overlay").addEventListener("click", (e) => { if (e.target.closest("#reward-cards") || e.target.closest("#skip-btn")) return; if (state.combat.phase === "ended" && state.combat.result === "defeat") { startNewRun(); } }); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } init(); ``` **Step 4: Run checks** Run: `bun run check` Expected: PASS **Step 5: 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 - Skip rewards -> next combat starts - Lose -> "defeat — click to restart" -> click -> fresh run **Step 6: Commit** ``` Wire run loop with victory rewards, defeat restart, and combat chaining ``` --- ## Phase B: Full Combat Keywords ### Task 6: Exhaust keyword **Files:** - Modify: `src/state.js` (playCard) - Modify: `src/state.test.js` Cards with exhaust should go to exhaustPile instead of discardPile when played. **Step 1: Write failing test** Add to `src/state.test.js`: ```javascript describe("playCard - exhaust", () => { test("card with exhaust keyword goes to exhaustPile", () => { let state = createCombatState("ironclad", "jaw_worm"); // manually put an exhaust card in hand state = { ...state, players: [{ ...state.players[0], hand: ["true_grit"], energy: 3 }], }; state = { ...state, player: state.players[0] }; const next = playCard(state, 0); expect(next.players[0].exhaustPile).toContain("true_grit"); expect(next.players[0].discardPile).not.toContain("true_grit"); }); test("card without exhaust keyword goes to discardPile as before", () => { let state = createCombatState("ironclad", "jaw_worm"); state = drawCards(state, 5); const strikeIdx = state.player.hand.indexOf("strike_r"); const next = playCard(state, strikeIdx); expect(next.players[0].discardPile).toContain("strike_r"); expect(next.players[0].exhaustPile).not.toContain("strike_r"); }); }); ``` Note: this test requires `true_grit` to exist in cards.json with an "exhaust" keyword. Verify that before writing the test. If no ironclad card currently has `keywords: ["exhaust"]` in the data, you'll need to check the card data and pick an appropriate card id, or add the keyword to a card that should have it. **Step 2: Run test to verify it fails** Run: `bun test src/state.test.js` Expected: FAIL - card goes to discardPile **Step 3: Implement exhaust in playCard** In `src/state.js`, modify `playCard` around line 127. Change: ```javascript const discardPile = [...player.discardPile, cardId]; ``` To: ```javascript const hasExhaust = card.keywords?.includes("exhaust"); const discardPile = hasExhaust ? [...player.discardPile] : [...player.discardPile, cardId]; const exhaustPile = hasExhaust ? [...player.exhaustPile, cardId] : [...player.exhaustPile]; ``` And update the updatedPlayer to include `exhaustPile`: ```javascript const updatedPlayer = { ...player, hand, discardPile, exhaustPile, energy }; ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add exhaust keyword support to playCard ``` --- ### Task 7: Ethereal keyword **Files:** - Modify: `src/state.js` (endTurn) - Modify: `src/state.test.js` Cards with ethereal that are still in hand at end of turn get exhausted (moved to exhaustPile) instead of discarded. **Step 1: Write failing test** Add to `src/state.test.js`: ```javascript describe("endTurn - ethereal", () => { test("ethereal cards in hand go to exhaustPile at end of turn", () => { let state = createCombatState("ironclad", "jaw_worm"); // put an ethereal card in hand (check cards.json for an actual ethereal card id) state = { ...state, players: [{ ...state.players[0], hand: ["strike_r", "apparition"] }], }; state = { ...state, player: state.players[0] }; const next = endTurn(state); expect(next.players[0].exhaustPile).toContain("apparition"); expect(next.players[0].discardPile).not.toContain("apparition"); expect(next.players[0].discardPile).toContain("strike_r"); }); }); ``` Note: pick an actual card id that has `keywords: ["ethereal"]` in cards.json. If none exist yet, add the keyword to an appropriate card. **Step 2: Run test to verify it fails** Run: `bun test src/state.test.js` Expected: FAIL **Step 3: Implement ethereal in endTurn** In `src/state.js`, modify `endTurn` (the single-player path around line 154). Replace: ```javascript const updatedPlayer = { ...player, hand: [], discardPile: [...player.discardPile, ...player.hand], }; ``` With: ```javascript const discarded = []; const exhausted = [...player.exhaustPile]; for (const cardId of player.hand) { const card = getCard(cardId); if (card.keywords?.includes("ethereal")) { exhausted.push(cardId); } else { discarded.push(cardId); } } const updatedPlayer = { ...player, hand: [], discardPile: [...player.discardPile, ...discarded], exhaustPile: exhausted, }; ``` Apply the same change to the indexed-player endTurn path (around line 171). **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add ethereal keyword — cards exhaust at end of turn if unplayed ``` --- ### Task 8: Retain keyword **Files:** - Modify: `src/state.js` (endTurn) - Modify: `src/state.test.js` Cards with retain stay in hand at end of turn instead of being discarded. **Step 1: Write failing test** ```javascript describe("endTurn - retain", () => { test("retained cards stay in hand at end of turn", () => { let state = createCombatState("ironclad", "jaw_worm"); // use a card with keywords: ["retain"] from cards.json state = { ...state, players: [{ ...state.players[0], hand: ["strike_r", "well_laid_plans"] }], }; state = { ...state, player: state.players[0] }; const next = endTurn(state); expect(next.players[0].hand).toContain("well_laid_plans"); expect(next.players[0].discardPile).toContain("strike_r"); expect(next.players[0].discardPile).not.toContain("well_laid_plans"); }); }); ``` Note: pick an actual card id with `keywords: ["retain"]` from cards.json. **Step 2: Run test to verify it fails** **Step 3: Implement retain in endTurn** Extend the endTurn hand-processing loop from Task 7 to also check for retain: ```javascript const retained = []; const discarded = []; const exhausted = [...player.exhaustPile]; for (const cardId of player.hand) { const card = getCard(cardId); if (card.keywords?.includes("retain")) { retained.push(cardId); } else if (card.keywords?.includes("ethereal")) { exhausted.push(cardId); } else { discarded.push(cardId); } } const updatedPlayer = { ...player, hand: retained, discardPile: [...player.discardPile, ...discarded], exhaustPile: exhausted, }; ``` Apply to both endTurn paths. **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add retain keyword — cards stay in hand at end of turn ``` --- ### Task 9: Poison mechanic **Files:** - Modify: `src/effects.js` (add poison effect type) - Modify: `src/combat.js` (add poison tick at start of enemy turn) - Modify: `src/render.js` (display poison on enemies) - Create: `src/poison.test.js` **Step 1: Write failing tests** Create `src/poison.test.js`: ```javascript import { beforeAll, describe, expect, test } from "bun:test"; import { initCards } from "./cards.js"; import { resolveEffects } from "./effects.js"; import { initEnemies } from "./enemies.js"; import { createCombatState } from "./state.js"; beforeAll(async () => { await Promise.all([initCards(), initEnemies()]); }); describe("poison effect", () => { test("applying poison adds to enemy poison counter", () => { const state = createCombatState("ironclad", "jaw_worm"); const effects = [{ type: "poison", value: 3 }]; const next = resolveEffects(state, effects, "player", "enemy"); expect(next.enemy.poison).toBe(3); }); test("poison stacks", () => { let state = createCombatState("ironclad", "jaw_worm"); state = { ...state, enemy: { ...state.enemy, poison: 2 }, enemies: [{ ...state.enemies[0], poison: 2 }] }; const effects = [{ type: "poison", value: 3 }]; const next = resolveEffects(state, effects, "player", "enemy"); expect(next.enemy.poison).toBe(5); }); test("poison caps at 30", () => { let state = createCombatState("ironclad", "jaw_worm"); state = { ...state, enemy: { ...state.enemy, poison: 28 }, enemies: [{ ...state.enemies[0], poison: 28 }] }; const effects = [{ type: "poison", value: 5 }]; const next = resolveEffects(state, effects, "player", "enemy"); expect(next.enemy.poison).toBe(30); }); }); ``` **Step 2: Run test to verify it fails** Run: `bun test src/poison.test.js` Expected: FAIL - poison is undefined **Step 3: Implement poison effect** Add `poison: 0` to the enemy template in `makeEnemy` in `src/state.js` (after `weak: 0`). Add case to `resolveSingleEffect` in `src/effects.js`: ```javascript case "poison": return applyStatus(state, target, "poison", effect.value, 30); ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add poison effect type with stacking and cap at 30 ``` --- ### Task 10: Poison tick at start of enemy turn **Files:** - Modify: `src/combat.js` (resolveEnemyTurn) - Modify: `src/poison.test.js` **Step 1: Write failing tests** Add to `src/poison.test.js`: ```javascript import { resolveEnemyTurn } from "./combat.js"; describe("poison tick", () => { test("poisoned enemy takes damage and loses 1 poison at start of enemy turn", () => { let state = createCombatState("ironclad", "jaw_worm"); const enemy = { ...state.enemies[0], poison: 3 }; state = { ...state, enemies: [enemy], enemy, combat: { ...state.combat, dieResult: 1 } }; const next = resolveEnemyTurn(state); expect(next.enemies[0].hp).toBe(enemy.hp - 3); expect(next.enemies[0].poison).toBe(2); }); test("poison does not apply block — goes straight to HP", () => { let state = createCombatState("ironclad", "jaw_worm"); const enemy = { ...state.enemies[0], poison: 2, block: 5 }; state = { ...state, enemies: [enemy], enemy, combat: { ...state.combat, dieResult: 1 } }; const next = resolveEnemyTurn(state); // poison ignores block expect(next.enemies[0].hp).toBe(enemy.hp - 2); }); test("enemy with 0 poison takes no poison damage", () => { let state = createCombatState("ironclad", "jaw_worm"); state = { ...state, combat: { ...state.combat, dieResult: 1 } }; const hpBefore = state.enemies[0].hp; const next = resolveEnemyTurn(state); // enemy should only take damage from their own action if any, not poison // just verify poison field stays 0 expect(next.enemies[0].poison).toBe(0); }); }); ``` **Step 2: Run test to verify it fails** **Step 3: Implement poison tick** In `src/combat.js`, in `resolveEnemyTurn`, after resetting enemy block but before enemies act, add poison tick: ```javascript // poison tick: each poisoned enemy takes damage and loses 1 poison const afterPoison = enemies.map((e) => { if (e.poison > 0) { return { ...e, hp: Math.max(0, e.hp - e.poison), poison: e.poison - 1, }; } return e; }); let next = { ...state, enemies: afterPoison, enemy: afterPoison[0] }; ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Add poison to render** In `src/render.js`, in the `renderEnemy` function, add poison to the status tokens: ```javascript if (enemy.poison > 0) tokens.push(`poison ${enemy.poison}`); ``` **Step 6: Commit** ``` Add poison tick at start of enemy turn and render poison status ``` --- ### Task 11: Unplayable keyword **Files:** - Modify: `src/state.js` (playCard) - Modify: `src/state.test.js` Cards with unplayable keyword cannot be played. **Step 1: Write failing test** ```javascript describe("playCard - unplayable", () => { test("returns null for unplayable cards even with enough energy", () => { let state = createCombatState("ironclad", "jaw_worm"); // use a status card with keywords: ["unplayable"] state = { ...state, players: [{ ...state.players[0], hand: ["slime"], energy: 3 }], }; state = { ...state, player: state.players[0] }; const result = playCard(state, 0); expect(result).toBeNull(); }); }); ``` Note: requires a card with `keywords: ["unplayable"]` in cards.json. If status cards (slime, wound, etc) aren't in the data yet, this task will need to add them first. **Step 2: Run test to verify it fails** **Step 3: Implement** In `src/state.js`, in `playCard`, after the energy check add: ```javascript if (card.keywords?.includes("unplayable")) return null; ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add unplayable keyword — card cannot be played ``` --- ### Task 12: Ironclad end-of-combat heal **Files:** - Modify: `src/run.js` - Modify: `src/run.test.js` Ironclad heals 1 HP at end of combat (board game passive). This happens when syncing HP back to run state after victory. **Step 1: Write failing test** Add to `src/run.test.js`: ```javascript import { endCombat } from "./run.js"; describe("endCombat", () => { test("ironclad heals 1 HP after combat", () => { let run = createRunState("ironclad"); run = { ...run, hp: 8 }; const result = endCombat(run, 8); expect(result.hp).toBe(9); }); test("ironclad does not heal above maxHp", () => { let run = createRunState("ironclad"); const result = endCombat(run, 11); expect(result.hp).toBe(11); }); }); ``` **Step 2: Run test to verify it fails** **Step 3: Implement** Add to `src/run.js`: ```javascript export function endCombat(run, combatHp) { let hp = combatHp; // ironclad passive: heal 1 HP at end of combat if (run.character === "ironclad") { hp = Math.min(hp + 1, run.maxHp); } return { ...run, hp, combatCount: run.combatCount + 1 }; } ``` **Step 4: Update main.js to use endCombat** In main.js, replace the manual `syncRunHp` + `combatCount` increment in the reward pick/skip handlers with `endCombat`: ```javascript // in reward pick handler: run = endCombat(run, state.players[0].hp); run = pickReward(run, revealed, index); startNextCombat(); // in skip handler: run = endCombat(run, state.players[0].hp); run = skipRewards(run, revealed); startNextCombat(); ``` **Step 5: Run tests** Run: `bun test` Expected: all PASS **Step 6: Commit** ``` Add ironclad end-of-combat heal and endCombat function ``` --- ## Phase C: Linear Map ### Task 13: Map state module **Files:** - Create: `src/map.js` - Create: `src/map.test.js` **Step 1: Write failing tests** ```javascript import { describe, expect, test } from "bun:test"; import { createMap, advanceMap } from "./map.js"; describe("createMap", () => { test("creates a linear Act 1 map with 10 nodes", () => { const map = createMap(); expect(map.nodes.length).toBe(10); expect(map.currentNode).toBe(0); }); test("first node is encounter, last node is boss", () => { const map = createMap(); expect(map.nodes[0].type).toBe("encounter"); expect(map.nodes[9].type).toBe("boss"); }); test("contains campfire, elite, and encounter nodes", () => { const map = createMap(); const types = map.nodes.map((n) => n.type); expect(types).toContain("campfire"); expect(types).toContain("elite"); expect(types.filter((t) => t === "encounter").length).toBeGreaterThanOrEqual(3); }); test("each node has id, type, and cleared fields", () => { const map = createMap(); for (const node of map.nodes) { expect(node).toHaveProperty("id"); expect(node).toHaveProperty("type"); expect(node).toHaveProperty("cleared"); expect(node.cleared).toBe(false); } }); }); describe("advanceMap", () => { test("marks current node cleared and moves to next", () => { let map = createMap(); map = advanceMap(map); expect(map.nodes[0].cleared).toBe(true); expect(map.currentNode).toBe(1); }); test("does not advance past last node", () => { let map = createMap(); // advance to the end for (let i = 0; i < 10; i++) { map = advanceMap(map); } expect(map.currentNode).toBe(9); }); }); ``` **Step 2: Run test to verify it fails** Run: `bun test src/map.test.js` Expected: FAIL **Step 3: Implement** Create `src/map.js`: ```javascript // fixed linear Act 1 map layout const ACT1_LAYOUT = [ "encounter", "encounter", "campfire", "encounter", "elite", "encounter", "campfire", "encounter", "elite", "boss", ]; export function createMap() { return { nodes: ACT1_LAYOUT.map((type, i) => ({ id: i, type, cleared: false, })), currentNode: 0, }; } export function advanceMap(map) { const nodes = map.nodes.map((n, i) => i === map.currentNode ? { ...n, cleared: true } : n, ); const nextNode = Math.min(map.currentNode + 1, map.nodes.length - 1); return { nodes, currentNode: nextNode }; } export function getCurrentNode(map) { return map.nodes[map.currentNode]; } ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add linear Act 1 map module with fixed 10-node layout ``` --- ### Task 14: Enemy pools for map nodes **Files:** - Modify: `src/map.js` - Modify: `src/map.test.js` Each node type needs to produce an enemy id for combat. Encounters draw from act 1 regular enemies, elites from act 1 elites, boss is one of the act 1 bosses. **Step 1: Write failing tests** Add to `src/map.test.js`: ```javascript import { getNodeEnemy } from "./map.js"; describe("getNodeEnemy", () => { test("encounter returns an act 1 regular enemy", () => { const enemy = getNodeEnemy("encounter"); expect(typeof enemy).toBe("string"); expect(enemy.length).toBeGreaterThan(0); }); test("elite returns an act 1 elite enemy", () => { const enemy = getNodeEnemy("elite"); expect(["lagavulin", "gremlin_nob", "sentry"]).toContain(enemy); }); test("boss returns an act 1 boss", () => { const enemy = getNodeEnemy("boss"); expect(["slime_boss", "the_guardian"]).toContain(enemy); }); }); ``` **Step 2: Run test to verify it fails** **Step 3: Implement** Add to `src/map.js`: ```javascript const ACT1_ENCOUNTERS = [ "jaw_worm", "cultist", "fungi_beast", "small_slime", "red_louse", "green_louse", "blue_slaver", ]; const ACT1_ELITES = ["lagavulin", "gremlin_nob", "sentry"]; const ACT1_BOSSES = ["slime_boss", "the_guardian"]; export function getNodeEnemy(nodeType) { const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; switch (nodeType) { case "encounter": return pick(ACT1_ENCOUNTERS); case "elite": return pick(ACT1_ELITES); case "boss": return pick(ACT1_BOSSES); default: return null; } } ``` **Step 4: Run tests** Run: `bun test` Expected: all PASS **Step 5: Commit** ``` Add enemy pools for encounter, elite, and boss map nodes ``` --- ### Task 15: Map rendering (wireframe) **Files:** - Modify: `src/render.js` - Modify: `index.html` **Step 1: Add map container to HTML** In `index.html`, add before the `
`: ```html ``` **Step 2: Add renderMap to render.js** ```javascript export function renderMap(map) { const game = document.getElementById("game"); const mapScreen = document.getElementById("map-screen"); const overlay = document.getElementById("overlay"); game.hidden = true; overlay.hidden = true; mapScreen.hidden = false; const container = document.getElementById("map-nodes"); container.innerHTML = ""; for (const node of map.nodes) { const div = document.createElement("div"); div.className = "map-node"; div.dataset.id = node.id; div.dataset.type = node.type; const isCurrent = node.id === map.currentNode; const label = node.cleared ? `[${node.type}] ✓` : `[${node.type}]`; div.textContent = label; if (isCurrent) { div.style.fontWeight = "bold"; div.textContent = `> ${label}`; } container.appendChild(div); // add connector line between nodes (except last) if (node.id < map.nodes.length - 1) { const line = document.createElement("div"); line.textContent = "|"; line.className = "map-connector"; container.appendChild(line); } } } export function showGame() { document.getElementById("game").hidden = false; document.getElementById("map-screen").hidden = true; } ``` **Step 3: Run checks** Run: `bun run check` Expected: PASS **Step 4: Commit** ``` Add wireframe map rendering with node list and proceed button ``` --- ### Task 16: Wire map into main.js game loop **Files:** - Modify: `src/main.js` - Modify: `src/run.js` The game loop becomes: map screen -> click proceed -> handle node (combat or campfire) -> after resolution -> advance map -> map screen. **Step 1: Add map to run state** In `src/run.js`, add import and update createRunState: ```javascript import { createMap } from "./map.js"; export function createRunState(character) { return { character, hp: 11, maxHp: 11, deck: [...getStarterDeck(character)], cardRewardsDeck: buildRewardsDeck(character), potions: [], combatCount: 0, map: createMap(), }; } ``` **Step 2: Update main.js to use map flow** This is a significant rewrite of the game flow. The key changes: - `startNewRun()` shows the map instead of immediately starting combat - "proceed" button reads current node type and dispatches to combat or campfire - After reward pick/skip, advance map and show map screen - After defeat, show restart option Update main.js to import map functions and add the map flow: ```javascript import { advanceMap, getCurrentNode, getNodeEnemy } from "./map.js"; import { renderMap, showGame } from "./render.js"; ``` Replace `startNewRun`: ```javascript function startNewRun() { run = createRunState("ironclad"); showMapScreen(); } function showMapScreen() { renderMap(run.map); } function proceedFromMap() { const node = getCurrentNode(run.map); if (node.type === "campfire") { showCampfire(); } else if (["encounter", "elite", "boss"].includes(node.type)) { const enemyId = getNodeEnemy(node.type); showGame(); state = createCombatFromRun(run, enemyId); state = startTurn(state); revealed = null; render(state); } else { // stubbed node types (event, merchant, treasure) run = { ...run, map: advanceMap(run.map) }; showMapScreen(); } } ``` Replace reward/skip handlers to advance map: ```javascript // after picking or skipping reward: run = endCombat(run, state.players[0].hp); run = pickReward(run, revealed, index); // or skipRewards run = { ...run, map: advanceMap(run.map) }; // check if boss was defeated (act complete) const clearedNode = run.map.nodes[run.map.currentNode - 1]; if (clearedNode?.type === "boss") { // act 1 complete! state = { ...state, combat: { ...state.combat, phase: "ended", result: "act_complete" } }; render(state); return; } showMapScreen(); ``` Add proceed button handler: ```javascript document.getElementById("map-proceed-btn").addEventListener("click", () => { proceedFromMap(); }); ``` **Step 3: Update renderOverlay to handle act_complete** In `src/render.js`, add to renderOverlay: ```javascript } else if (state?.combat?.result === "act_complete") { overlay.hidden = false; overlayText.textContent = "act 1 complete!"; rewardCards.innerHTML = ""; skipBtn.hidden = true; ``` **Step 4: Run checks** Run: `bun run check` Expected: PASS **Step 5: Manual play test** Run: `bun run dev` Verify: - Game starts with map screen showing 10 nodes - Click proceed -> first encounter starts - Win -> rewards -> pick/skip -> back to map (node marked cleared) - Navigate through encounters, campfires, elites - Stubbed nodes (if any) auto-advance - Boss victory -> "act 1 complete!" - Defeat -> restart option **Step 6: Commit** ``` Wire linear map into game loop with node progression ``` --- ## Phase D: Campfire ### Task 17: Campfire — rest (heal) **Files:** - Modify: `src/run.js` - Modify: `src/run.test.js` **Step 1: Write failing test** ```javascript import { campfireRest } from "./run.js"; describe("campfireRest", () => { test("heals 3 HP", () => { let run = createRunState("ironclad"); run = { ...run, hp: 5 }; const result = campfireRest(run); expect(result.hp).toBe(8); }); test("does not heal above maxHp", () => { let run = createRunState("ironclad"); run = { ...run, hp: 10 }; const result = campfireRest(run); expect(result.hp).toBe(11); }); }); ``` **Step 2: Implement** Add to `src/run.js`: ```javascript export function campfireRest(run) { return { ...run, hp: Math.min(run.hp + 3, run.maxHp) }; } ``` **Step 3: Run tests** Run: `bun test` Expected: PASS **Step 4: Commit** ``` Add campfireRest function — heals 3 HP ``` --- ### Task 18: Campfire — smith (upgrade card) **Files:** - Modify: `src/run.js` - Modify: `src/run.test.js` **Step 1: Write failing tests** ```javascript import { campfireSmith, getUpgradableCards } from "./run.js"; describe("campfireSmith", () => { test("replaces base card with upgraded version in deck", () => { let run = createRunState("ironclad"); const result = campfireSmith(run, "strike_r"); expect(result.deck).toContain("strike_r+"); expect(result.deck.filter((c) => c === "strike_r").length).toBe(4); }); test("does nothing if card not in deck", () => { let run = createRunState("ironclad"); const result = campfireSmith(run, "anger"); expect(result.deck).toEqual(run.deck); }); }); describe("getUpgradableCards", () => { test("returns cards that have an upgraded version", () => { const run = createRunState("ironclad"); const upgradable = getUpgradableCards(run); expect(upgradable.length).toBeGreaterThan(0); // should not include already-upgraded cards expect(upgradable.every((id) => !id.endsWith("+"))).toBe(true); }); }); ``` **Step 2: Implement** Add to `src/run.js`: ```javascript import { getCard } from "./cards.js"; export function campfireSmith(run, cardId) { const card = getCard(cardId); if (!card?.upgraded) return run; const idx = run.deck.indexOf(cardId); if (idx === -1) return run; const deck = [...run.deck]; deck[idx] = card.upgraded; return { ...run, deck }; } export function getUpgradableCards(run) { const seen = new Set(); const upgradable = []; for (const id of run.deck) { if (seen.has(id)) continue; seen.add(id); const card = getCard(id); if (card?.upgraded && !id.endsWith("+")) { upgradable.push(id); } } return upgradable; } ``` **Step 3: Run tests** Run: `bun test` Expected: PASS **Step 4: Commit** ``` Add campfireSmith and getUpgradableCards for card upgrading ``` --- ### Task 19: Campfire UI and wiring **Files:** - Modify: `index.html` - Modify: `src/render.js` - Modify: `src/main.js` **Step 1: Add campfire HTML** Add to `index.html`, after map-screen section: ```html ``` **Step 2: Add renderCampfire to render.js** ```javascript export function renderCampfire(run, upgradableCards) { document.getElementById("game").hidden = true; document.getElementById("map-screen").hidden = true; document.getElementById("overlay").hidden = true; document.getElementById("campfire-screen").hidden = false; document.getElementById("campfire-hp").textContent = `HP: ${run.hp}/${run.maxHp}`; document.getElementById("smith-cards").hidden = true; if (upgradableCards) { const container = document.getElementById("smith-cards"); container.hidden = false; container.innerHTML = ""; for (const cardId of upgradableCards) { const card = getCard(cardId); const btn = document.createElement("button"); btn.textContent = `${card.name} -> ${card.upgraded}`; btn.dataset.cardId = cardId; btn.className = "smith-card-btn"; container.appendChild(btn); } } } export function hideCampfire() { document.getElementById("campfire-screen").hidden = true; } ``` **Step 3: Wire campfire in main.js** Add a `showCampfire` function and event handlers: ```javascript function showCampfire() { renderCampfire(run); } document.getElementById("campfire-rest-btn").addEventListener("click", () => { run = campfireRest(run); run = { ...run, map: advanceMap(run.map) }; hideCampfire(); showMapScreen(); }); document.getElementById("campfire-smith-btn").addEventListener("click", () => { const upgradable = getUpgradableCards(run); renderCampfire(run, upgradable); }); document.getElementById("smith-cards").addEventListener("click", (e) => { const btn = e.target.closest(".smith-card-btn"); if (!btn) return; run = campfireSmith(run, btn.dataset.cardId); run = { ...run, map: advanceMap(run.map) }; hideCampfire(); showMapScreen(); }); ``` **Step 4: Run checks** Run: `bun run check` Expected: PASS **Step 5: Manual play test** Verify: - Reach campfire node on map -> proceed shows campfire screen - Rest heals 3 HP, returns to map - Smith shows upgradable cards, picking one upgrades it, returns to map **Step 6: Commit** ``` Add campfire UI with rest and smith options ``` --- ## Phase E: Boss + Final Polish ### Task 20: Boss encounter and act complete screen **Files:** - Modify: `src/render.js` - Modify: `src/main.js` The boss fight itself uses the existing combat system — it's just a tougher enemy. The only new thing is the act-complete screen on victory. **Step 1: Verify boss enemies exist in data** Check that `slime_boss` and `the_guardian` are in enemies.json with correct data. If they have high HP and cube/die actions, they should work with the existing combat engine. **Step 2: Update act-complete overlay** The overlay text for act complete should offer a "play again" option: ```javascript // in renderOverlay } else if (state?.combat?.result === "act_complete") { overlay.hidden = false; overlayText.textContent = "act 1 complete — click to play again"; rewardCards.innerHTML = ""; skipBtn.hidden = true; ``` Add handler in main.js: ```javascript // in overlay click handler, add: if (state.combat.phase === "ended" && state.combat.result === "act_complete") { startNewRun(); } ``` **Step 3: Run checks** Run: `bun run check` Expected: PASS **Step 4: Full playthrough test** Run: `bun run dev` Play through the entire Act 1: - Start on map - Fight encounters, pick rewards - Rest or smith at campfires - Fight elites - Defeat boss - See "act 1 complete" screen - Click to start new run **Step 5: Commit** ``` Add act 1 boss encounter and completion screen ``` --- ### Task 21: Final checks and cleanup **Step 1: Run full check suite** Run: `bun run check` Expected: PASS (lint + typecheck + all tests) **Step 2: Play test edge cases** - Die on first encounter -> restart works - Skip all rewards -> deck stays at 10 - Take every reward -> deck grows each combat - Rest at every campfire -> HP stays healthy - Smith upgrades actually change cards in combat - Boss fight is noticeably harder than regular encounters **Step 3: Commit any fixes found** --- ## Task checklist Phase A — Run State + Rewards: - [x] Task 1: Export shuffle, add getAllCards - [x] Task 2: Run state module (createRunState) - [x] Task 3: Reward reveal, pick, skip - [x] Task 4: Create combat from run state - [x] Task 5: Wire run loop in main.js Phase B — Combat Keywords: - [x] Task 6: Exhaust keyword - [x] Task 7: Ethereal keyword - [x] Task 8: Retain keyword - [x] Task 9: Poison effect - [x] Task 10: Poison tick - [x] Task 11: Unplayable keyword - [x] Task 12: Ironclad end-of-combat heal Phase C — Linear Map: - [x] Task 13: Map state module - [x] Task 14: Enemy pools for map nodes - [x] Task 15: Map rendering (wireframe) - [x] Task 16: Wire map into game loop Phase D — Campfire: - [x] Task 17: Campfire rest - [x] Task 18: Campfire smith - [x] Task 19: Campfire UI and wiring Phase E — Boss + Polish: - [x] Task 20: Boss encounter and act complete - [x] Task 21: Final checks and cleanup