slaywithfriends/docs/plans/2026-02-24-act1-single-player-plan.md

48 KiB

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:

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:

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

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:

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:

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:

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:

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:

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 <div id="overlay" hidden></div> with:

<div id="overlay" hidden>
  <div id="overlay-text"></div>
  <div id="reward-cards"></div>
  <button type="button" id="skip-btn" hidden>skip</button>
</div>

Step 2: Update render.js to handle rewards phase

Change the render function signature and update renderOverlay:

export function render(state, revealed) {
  renderEnemy(state);
  renderInfoBar(state);
  renderHand(state);
  renderOverlay(state, revealed);
}

Replace renderOverlay:

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:

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:

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:

const discardPile = [...player.discardPile, cardId];

To:

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:

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:

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:

const updatedPlayer = {
  ...player,
  hand: [],
  discardPile: [...player.discardPile, ...player.hand],
};

With:

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

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:

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:

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:

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:

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:

// 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:

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

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:

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:

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:

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:

// 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

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:

// 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:

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:

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 <div id="overlay">:

<section id="map-screen" hidden>
  <h2>act 1</h2>
  <div id="map-nodes"></div>
  <button type="button" id="map-proceed-btn">proceed</button>
</section>

Step 2: Add renderMap to render.js

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:

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:

import { advanceMap, getCurrentNode, getNodeEnemy } from "./map.js";
import { renderMap, showGame } from "./render.js";

Replace startNewRun:

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:

// 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:

document.getElementById("map-proceed-btn").addEventListener("click", () => {
  proceedFromMap();
});

Step 3: Update renderOverlay to handle act_complete

In src/render.js, add to renderOverlay:

} 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

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:

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

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:

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:

<section id="campfire-screen" hidden>
  <h2>campfire</h2>
  <p id="campfire-hp"></p>
  <div id="campfire-choices">
    <button type="button" id="campfire-rest-btn">rest (heal 3 HP)</button>
    <button type="button" id="campfire-smith-btn">smith (upgrade a card)</button>
  </div>
  <div id="smith-cards" hidden></div>
</section>

Step 2: Add renderCampfire to render.js

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:

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:

// 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:

// 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:

  • Task 1: Export shuffle, add getAllCards
  • Task 2: Run state module (createRunState)
  • Task 3: Reward reveal, pick, skip
  • Task 4: Create combat from run state
  • Task 5: Wire run loop in main.js

Phase B — Combat Keywords:

  • Task 6: Exhaust keyword
  • Task 7: Ethereal keyword
  • Task 8: Retain keyword
  • Task 9: Poison effect
  • Task 10: Poison tick
  • Task 11: Unplayable keyword
  • Task 12: Ironclad end-of-combat heal

Phase C — Linear Map:

  • Task 13: Map state module
  • Task 14: Enemy pools for map nodes
  • Task 15: Map rendering (wireframe)
  • Task 16: Wire map into game loop

Phase D — Campfire:

  • Task 17: Campfire rest
  • Task 18: Campfire smith
  • Task 19: Campfire UI and wiring

Phase E — Boss + Polish:

  • Task 20: Boss encounter and act complete
  • Task 21: Final checks and cleanup