From 8ae3252a9ced86e9cb65608b9c139bbc3afb5372 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 25 Feb 2026 11:27:15 -0500 Subject: [PATCH] Mark all Act 1 plan tasks complete --- .../2026-02-24-act1-single-player-plan.md | 1976 +++++++++++++++++ 1 file changed, 1976 insertions(+) create mode 100644 docs/plans/2026-02-24-act1-single-player-plan.md diff --git a/docs/plans/2026-02-24-act1-single-player-plan.md b/docs/plans/2026-02-24-act1-single-player-plan.md new file mode 100644 index 0000000..534bebf --- /dev/null +++ b/docs/plans/2026-02-24-act1-single-player-plan.md @@ -0,0 +1,1976 @@ +# 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