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
+
+ campfire
+
+
+
+
+
+
+
+```
+
+**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