1976 lines
48 KiB
Markdown
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
|