Add run loop implementation plan

This commit is contained in:
Jared Miller 2026-02-24 10:07:28 -05:00
parent 22d7b1a54e
commit a2f61b0128
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

View file

@ -0,0 +1,586 @@
# Run Loop Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Create a fight → reward → fight → die → restart loop so the game never dead-ends.
**Architecture:** Add a run state layer above combat state that tracks the player's evolving deck, HP, and card rewards deck across multiple combats. After victory, show 3 cards from the rewards deck for the player to pick from (or skip). After defeat, restart everything fresh. Combat state is created from run state each fight.
**Tech Stack:** Vanilla JS, ES modules, bun test, biome
---
### Task 1: Run state module
**Files:**
- Create: `src/run.js`
- Test: `src/run.test.js`
**Step 1: Write failing tests for createRunState**
```javascript
import { describe, test, expect } from "bun:test";
import { createRunState } from "./run.js";
import { initCards } from "./cards.js";
describe("createRunState", () => {
test("initializes ironclad run with starter deck", async () => {
await initCards();
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);
});
test("builds rewards deck from common + uncommon cards", async () => {
await initCards();
const run = createRunState("ironclad");
expect(run.cardRewardsDeck.length).toBe(56);
// should not contain starters, rares, or upgraded cards
expect(run.cardRewardsDeck).not.toContain("strike_r");
expect(run.cardRewardsDeck).not.toContain("bash");
expect(run.cardRewardsDeck).not.toContain("barricade");
expect(run.cardRewardsDeck).not.toContain("anger+");
// should contain common and uncommon
expect(run.cardRewardsDeck).toContain("anger");
expect(run.cardRewardsDeck).toContain("battle_trance");
});
test("rewards deck is shuffled (not alphabetical)", async () => {
await initCards();
const run1 = createRunState("ironclad");
const run2 = createRunState("ironclad");
// extremely unlikely both are identical if shuffled
const same = run1.cardRewardsDeck.every((c, i) => c === run2.cardRewardsDeck[i]);
expect(same).toBe(false);
});
});
```
**Step 2: Run tests to verify they fail**
Run: `bun test src/run.test.js`
Expected: FAIL - module not found
**Step 3: Write createRunState implementation**
In `src/run.js`:
```javascript
import { getStarterDeck, getCard } from "./cards.js";
import { shuffle } from "./state.js";
export function createRunState(character) {
return {
character,
hp: 11,
maxHp: 11,
deck: [...getStarterDeck(character)],
cardRewardsDeck: buildRewardsDeck(character),
combatCount: 0,
};
}
function buildRewardsDeck(character) {
const cards = [];
// getCard returns undefined for unknown ids, so we need to iterate all cards
// we need access to the card database - add a getAllCards export to cards.js
for (const card of getAllCards()) {
if (
card.character === character &&
(card.rarity === "common" || card.rarity === "uncommon") &&
!card.id.endsWith("+")
) {
cards.push(card.id);
}
}
return shuffle([...cards]);
}
```
Note: this requires exporting `shuffle` from state.js and adding `getAllCards` to cards.js. See step 4.
**Step 4: Export shuffle from state.js, add getAllCards to cards.js**
In `src/state.js`, the `shuffle` function needs to be exported. Find the existing shuffle function and add `export` keyword.
In `src/cards.js`, add:
```javascript
export function getAllCards() {
return Object.values(cardDb);
}
```
**Step 5: Run tests to verify they pass**
Run: `bun test src/run.test.js`
Expected: PASS (3 tests)
**Step 6: Commit**
```
Add run state module with createRunState and rewards deck builder
```
---
### Task 2: Reveal and pick reward cards
**Files:**
- Modify: `src/run.js`
- Test: `src/run.test.js`
**Step 1: Write failing tests for revealRewards and pickReward**
Add to `src/run.test.js`:
```javascript
import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js";
describe("revealRewards", () => {
test("reveals top 3 cards from rewards deck", async () => {
await initCards();
const run = createRunState("ironclad");
const top3 = run.cardRewardsDeck.slice(0, 3);
const result = revealRewards(run);
expect(result.revealed).toEqual(top3);
expect(result.cardRewardsDeck.length).toBe(53);
});
test("reveals fewer if deck has < 3 cards", async () => {
await initCards();
const run = createRunState("ironclad");
run.cardRewardsDeck = ["anger", "flex"];
const result = revealRewards(run);
expect(result.revealed).toEqual(["anger", "flex"]);
expect(result.cardRewardsDeck.length).toBe(0);
});
});
describe("pickReward", () => {
test("adds picked card to deck", async () => {
await initCards();
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); // 10 starter + 1
});
test("does not return unpicked cards to rewards deck", async () => {
await initCards();
const run = createRunState("ironclad");
// remove these from rewards deck so we can track them
run.cardRewardsDeck = run.cardRewardsDeck.filter(
(c) => !["anger", "flex", "cleave"].includes(c)
);
const revealed = ["anger", "flex", "cleave"];
const result = pickReward(run, revealed, 0);
// flex and cleave are gone (not in deck, not in rewards)
expect(result.deck).not.toContain("flex");
expect(result.cardRewardsDeck).not.toContain("flex");
});
});
describe("skipRewards", () => {
test("shuffles all revealed cards back into rewards deck", async () => {
await initCards();
const run = createRunState("ironclad");
run.cardRewardsDeck = ["a", "b", "c"];
const revealed = ["x", "y", "z"];
const result = skipRewards(run, revealed);
expect(result.cardRewardsDeck.length).toBe(6);
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 revealRewards, pickReward, skipRewards**
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 { ...run, revealed, 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 to verify they pass**
Run: `bun test src/run.test.js`
Expected: PASS (all tests)
**Step 5: Commit**
```
Add reward reveal, pick, and skip functions
```
---
### Task 3: Create combat from run state
**Files:**
- Modify: `src/state.js`
- Test: `src/state.test.js` (or add to existing tests)
**Step 1: Write failing tests for createCombatFromRun**
Find existing state tests and add (or create `src/run.test.js` additions):
```javascript
import { createCombatFromRun } from "./state.js";
import { initCards } from "./cards.js";
import { initEnemies } from "./enemies.js";
import { createRunState } from "./run.js";
describe("createCombatFromRun", () => {
test("uses run deck instead of starter deck", async () => {
await initCards();
await initEnemies();
const run = createRunState("ironclad");
run.deck = [...run.deck, "anger"]; // 11 cards
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", async () => {
await initCards();
await initEnemies();
const run = createRunState("ironclad");
run.hp = 7;
const state = createCombatFromRun(run, "jaw_worm");
expect(state.players[0].hp).toBe(7);
expect(state.players[0].maxHp).toBe(11);
});
test("increments combatCount on run", async () => {
await initCards();
await initEnemies();
const run = createRunState("ironclad");
expect(run.combatCount).toBe(0);
const state = createCombatFromRun(run, "jaw_worm");
// combat state should carry the combat number
expect(state.combat.combatCount).toBe(1);
});
});
```
**Step 2: Run tests to verify they fail**
Run: `bun test src/state.test.js` (or whichever file)
Expected: FAIL
**Step 3: Implement createCombatFromRun**
Add to `src/state.js`:
```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: 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: [],
};
return {
players: [player],
enemies,
combat: {
turn: 1,
phase: "player_turn",
dieResult: null,
selectedCard: null,
log: [],
playerCount: 1,
activePlayerIndex: null,
playersReady: [],
combatCount: run.combatCount + 1,
},
player,
enemy: enemies[0],
};
}
```
**Step 4: Run tests to verify they pass**
Run: `bun test`
Expected: PASS
**Step 5: Commit**
```
Add createCombatFromRun to build combat state from run
```
---
### Task 4: Wire the run loop in main.js
**Files:**
- Modify: `src/main.js`
This task connects the run state, combat, rewards, and restart flow. No unit tests for this task (it's DOM wiring) - we'll verify by playing.
**Step 1: Update init() to create run state**
Replace the current init:
```javascript
import { createRunState, revealRewards, pickReward, skipRewards } from "./run.js";
import { createCombatFromRun } from "./state.js";
let state = null;
let run = null;
let revealed = null;
async function init() {
await Promise.all([initCards(), initEnemies()]);
startNewRun();
}
function startNewRun() {
run = createRunState("ironclad");
startNextCombat();
}
function startNextCombat() {
run = { ...run, combatCount: run.combatCount + 1 };
state = createCombatFromRun(run, "jaw_worm");
state = startTurn(state);
revealed = null;
render(state);
}
```
**Step 2: Update victory handling to show rewards phase**
When combat ends in victory, transition to rewards instead of dead-ending. In the places where `checkCombatEnd` returns "victory":
```javascript
const result = checkCombatEnd(state);
if (result === "victory") {
state = { ...state, combat: { ...state.combat, phase: "rewards" } };
const rewardResult = revealRewards(run);
run = rewardResult;
revealed = rewardResult.revealed;
render(state, revealed);
return;
}
if (result === "defeat") {
state = { ...state, combat: { ...state.combat, phase: "ended", result: "defeat" } };
render(state);
return;
}
```
**Step 3: Update defeat handling to allow restart**
Add click handler on overlay for restart:
```javascript
document.getElementById("overlay").addEventListener("click", () => {
if (state.combat.phase === "ended" && state.combat.result === "defeat") {
startNewRun();
}
});
```
**Step 4: Commit**
```
Wire run loop in main.js with victory rewards and defeat restart
```
---
### Task 5: Render rewards screen and handle pick/skip
**Files:**
- Modify: `src/render.js`
- Modify: `src/main.js`
- Modify: `index.html`
**Step 1: Add skip button to HTML**
In `index.html`, inside or near the overlay, add a skip button (can be dynamically shown):
```html
<div id="overlay" hidden>
<div id="overlay-text"></div>
<div id="reward-cards"></div>
<button id="skip-btn" hidden>skip</button>
</div>
```
**Step 2: Update renderOverlay to handle rewards phase**
In `src/render.js`, update `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;
}
}
```
Update `render` function signature to pass `revealed`:
```javascript
export function render(state, revealed) {
renderEnemy(state);
renderInfoBar(state);
renderHand(state);
renderOverlay(state, revealed);
}
```
**Step 3: Add reward pick and skip event handlers in main.js**
```javascript
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;
run = pickReward(run, revealed, index);
// sync hp back from combat
run = { ...run, hp: state.players[0].hp };
revealed = null;
startNextCombat();
});
document.getElementById("skip-btn").addEventListener("click", () => {
if (!revealed) return;
run = skipRewards(run, revealed);
run = { ...run, hp: state.players[0].hp };
revealed = null;
startNextCombat();
});
```
**Step 4: Commit**
```
Add reward card rendering with pick and skip handlers
```
---
### Task 6: Run checks and verify
**Step 1: Run biome check**
Run: `bun run check`
Expected: PASS (lint + format + tests)
**Step 2: 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 now)
- Lose → "defeat - click to restart" → click → fresh run
- Skip rewards → next combat starts, rewards deck has those cards back
**Step 3: Fix any issues found**
**Step 4: Commit any fixes**
---
## Task checklist
- [ ] Task 1: Run state module (createRunState, rewards deck builder)
- [ ] Task 2: Reveal and pick reward cards (revealRewards, pickReward, skipRewards)
- [ ] Task 3: Create combat from run state (createCombatFromRun)
- [ ] Task 4: Wire the run loop in main.js
- [ ] Task 5: Render rewards screen and handle pick/skip
- [ ] Task 6: Run checks and verify