Add run loop implementation plan
This commit is contained in:
parent
22d7b1a54e
commit
a2f61b0128
1 changed files with 586 additions and 0 deletions
586
docs/plans/2026-02-24-run-loop-plan.md
Normal file
586
docs/plans/2026-02-24-run-loop-plan.md
Normal 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
|
||||
Loading…
Reference in a new issue