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

1976 lines
48 KiB
Markdown

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