slaywithfriends/docs/plans/2026-02-24-run-loop-plan.md

16 KiB

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

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:

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:

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:

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:

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

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:

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:

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

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:

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

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

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:

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

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