diff --git a/docs/plans/2026-02-23-single-combat-design.md b/docs/plans/2026-02-23-single-combat-design.md new file mode 100644 index 0000000..25b8891 --- /dev/null +++ b/docs/plans/2026-02-23-single-combat-design.md @@ -0,0 +1,182 @@ +# single combat encounter - design doc + +first playable slice of slaywithfriends. one ironclad player vs one +enemy. proves the core card engine works. + +## decisions + +- rendering: html/css with card images (no canvas) +- framework: none. vanilla js, es modules, bun serve +- networking: client-only. all state in browser +- character: ironclad (simplest mechanics) +- interaction: two-tap (select card, tap target) +- mobile: design for 375px baseline, scales up naturally + +## state model + +single object drives everything. pure functions produce new state. +nothing mutates directly. + + state = { + player: { + hp, maxHp, + energy, maxEnergy, + block, + strength, + drawPile: [...cardIds], + hand: [...cardIds], + discardPile: [...cardIds], + exhaustPile: [...cardIds], + powers: [...cardIds], + }, + enemy: { + id, name, hp, maxHp, block, + strength, vulnerable, weak, + action, + actionTrack: [...], + trackPosition, + }, + combat: { + turn, + phase: 'player_turn' | 'enemy_turn' | 'rewards' | 'ended', + dieResult, + selectedCard, + log: [...], + } + } + +## card data schema + +cards are json entries keyed by id. effects are typed arrays. + + { + "strike_r": { + "name": "Strike", + "cost": 1, + "type": "attack", + "effects": [{"type": "hit", "value": 6}], + "image": "assets/images/ironclad/starter/0.png", + "keywords": [], + "description": "Deal 6 damage.", + "upgraded": "strike_r+" + } + } + +effect types for first slice: hit, block, draw, strength, vulnerable, +weak, exhaust, lose_hp. + +## enemy data schema + + { + "jaw_worm": { + "name": "Jaw Worm", + "hp": 6, + "actionType": "die", + "actions": { + "1": {"intent": "attack", "effects": [{"type": "hit", "value": 3}]}, + ... + } + } + } + +three action types: single (same every turn), die (keyed to die roll), +cube (ordered list with advancing pointer, gray actions don't repeat). + +## rendering layout + +three vertical zones: + + enemy zone (~40%) enemy art, hp bar, block, status tokens, intent + info bar (fixed) energy pips, hp bar, block shield, strength, pile counts + hand (~35%) cards fan out, overlap if many, selected card lifts + +pile overlays: tap draw/discard count to see scrollable card grid. + +## two-tap interaction + +1. tap card in hand - it lifts and highlights +2. enemy zone becomes valid target (highlighted border) +3. skills that don't need a target auto-play +4. tap enemy - card plays, effects resolve, card animates to discard +5. not enough energy - card shakes and drops back +6. tap selected card again to deselect + +## combat loop + +player turn: + 1. reset energy to 3, block to 0 + 2. draw 5 cards (shuffle discard into draw if needed) + 3. roll die (1-6), determines enemy intent + 4. resolve start-of-turn triggers + 5. play phase - player acts freely + 6. "end turn" button ends play phase + 7. end-of-turn triggers, discard remaining hand + +enemy turn (auto-resolves): + 1. remove enemy block + 2. enemy executes action + 3. advance cube-action pointer if applicable + 4. brief pause (~1s) for player to read + +combat ends when enemy hp hits 0 (victory) or player hp hits 0 (defeat). + +## damage formula + +1. start with base hit value +2. add attacker strength (+1 per token) +3. if target vulnerable, double result +4. if attacker weak, subtract 1 +5. weak AND vulnerable cancel out - skip both +6. subtract target block, remainder hits hp +7. consume 1 vulnerable from target after attack +8. consume 1 weak from attacker after attack +9. multi-hit: loop N times, consume tokens only after all hits +10. aoe/multi-hit: only 1 weak consumed total + +## enemy ai + +data-driven. each enemy's action is an effects array resolved the +same way as card effects. intent displayed as icon + number after +die roll so player can plan. + +## project structure + + slaywithfriends/ + index.html + style.css + src/ + main.js entry point, init, render loop + state.js state creation, pure action functions + effects.js effect resolver + combat.js turn flow orchestration + enemy-ai.js resolve enemy actions + render.js state to dom + cards.js load/query card data + enemies.js load/query enemy data + die.js die roll + data/ + cards.json card database + enemies.json enemy database + potions.json potion database + relics.json relic database + assets/ video game card images + StS_BG_assets/ board game assets + docs/ rules and plans + +## out of scope + +- map / room navigation +- deck building / rewards / card selection +- merchant / campfire / events +- potions and relics +- multiplayer / networking +- persistent state / save / load +- sound +- ironclad passive (heal 1 hp end of combat) +- tutorial + +## data pipeline + +board game card sheet images being extracted to json by agent. +video game wiki data (wiki.gg Module:Cards/data) available as +secondary reference. board game values are source of truth. diff --git a/docs/plans/2026-02-23-single-combat-plan.md b/docs/plans/2026-02-23-single-combat-plan.md new file mode 100644 index 0000000..709169b --- /dev/null +++ b/docs/plans/2026-02-23-single-combat-plan.md @@ -0,0 +1,1478 @@ +# Single Combat Encounter Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Playable single Ironclad combat encounter vs one enemy in the browser. + +**Architecture:** Vanilla JS with ES modules, pure state functions tested with bun:test, HTML/CSS rendering, no build step. State drives rendering — all game logic is in testable pure functions, render function projects state to DOM. + +**Tech Stack:** Bun (serve + test), Biome (lint/format, 4 spaces), vanilla JS, HTML/CSS + +**Note on card values:** An agent is currently extracting board game card data from sheet images into data/cards.json. Starter deck values in this plan use board game scale. If the extracted data is available when you start, use those values. If not, use the values here and update later. + +--- + +### Task 1: Project scaffold + +**Files:** +- Create: `package.json` (via bun init) +- Create: `biome.json` (via bunx biome init, set to 4 spaces) + +**Step 1: Initialize project** + +Run: `cd /home/jtm/projects/slaywithfriends && bun init -y` + +**Step 2: Add biome** + +Run: `bun add -d @biomejs/biome && bunx biome init` + +**Step 3: Configure biome for 4-space indent** + +Edit `biome.json` — set formatter indent to 4 spaces, add check script to package.json: + +```json +{ + "scripts": { + "check": "bunx biome check --write && bun test", + "dev": "bun run src/serve.js" + } +} +``` + +biome.json should have: +```json +{ + "formatter": { + "indentStyle": "space", + "indentWidth": 4 + } +} +``` + +**Step 4: Create directory structure** + +```bash +mkdir -p src data +``` + +**Step 5: Commit** + +``` +Project scaffold with bun and biome +``` + +--- + +### Task 2: Card data module + +**Files:** +- Create: `data/starter-ironclad.json` +- Create: `data/enemies.json` +- Create: `src/cards.js` +- Test: `src/cards.test.js` + +**Step 1: Write starter deck JSON** + +`data/starter-ironclad.json` — the ironclad starter deck (board game values). Check if `data/cards.json` exists from the extraction agent. If it does, pull ironclad starter values from there. If not, use these values and confirm against the card sheet images in `StS_BG_assets/Cards_Ironclad_Start.png`: + +```json +{ + "strike_r": { + "id": "strike_r", + "name": "Strike", + "cost": 1, + "type": "attack", + "effects": [{ "type": "hit", "value": 1 }], + "keywords": [], + "description": "Deal 1 damage." + }, + "defend_r": { + "id": "defend_r", + "name": "Defend", + "cost": 1, + "type": "skill", + "effects": [{ "type": "block", "value": 1 }], + "keywords": [], + "description": "Gain 1 Block." + }, + "bash": { + "id": "bash", + "name": "Bash", + "cost": 2, + "type": "attack", + "effects": [ + { "type": "hit", "value": 1 }, + { "type": "vulnerable", "value": 2 } + ], + "keywords": [], + "description": "Deal 1 damage. Apply 2 Vulnerable." + } +} +``` + +IMPORTANT: Read `StS_BG_assets/Cards_Ironclad_Start.png` to verify exact board game values before writing. The values above are estimates. + +**Step 2: Write enemy data JSON** + +`data/enemies.json` — one Act I enemy. Read `StS_BG_assets/Enemies_Act1.png` (or the first encounter sheet) to pick a simple enemy and get exact stats. + +```json +{ + "jaw_worm": { + "id": "jaw_worm", + "name": "Jaw Worm", + "hp": 6, + "actionType": "die", + "actions": { + "1": { "intent": "attack", "effects": [{ "type": "hit", "value": 2 }] }, + "2": { "intent": "attack", "effects": [{ "type": "hit", "value": 2 }] }, + "3": { "intent": "defend", "effects": [{ "type": "block", "value": 2 }] }, + "4": { "intent": "defend", "effects": [{ "type": "block", "value": 2 }] }, + "5": { "intent": "buff", "effects": [{ "type": "strength", "value": 1 }] }, + "6": { "intent": "buff", "effects": [{ "type": "strength", "value": 1 }] } + } + } +} +``` + +IMPORTANT: Verify against the actual board game enemy card image. + +**Step 3: Write failing test for cards module** + +`src/cards.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { getCard, getStarterDeck } from "./cards.js"; + +describe("cards", () => { + test("getCard returns card by id", () => { + const card = getCard("strike_r"); + expect(card.name).toBe("Strike"); + expect(card.cost).toBe(1); + expect(card.type).toBe("attack"); + expect(card.effects[0].type).toBe("hit"); + }); + + test("getCard returns undefined for unknown id", () => { + expect(getCard("nonexistent")).toBeUndefined(); + }); + + test("getStarterDeck returns 10 card ids for ironclad", () => { + const deck = getStarterDeck("ironclad"); + expect(deck).toHaveLength(10); + expect(deck.filter((id) => id === "strike_r")).toHaveLength(5); + expect(deck.filter((id) => id === "defend_r")).toHaveLength(4); + expect(deck.filter((id) => id === "bash")).toHaveLength(1); + }); +}); +``` + +**Step 4: Run test to verify it fails** + +Run: `bun test src/cards.test.js` +Expected: FAIL — module not found + +**Step 5: Implement cards module** + +`src/cards.js`: +```js +import starterIronclad from "../data/starter-ironclad.json"; + +const cardDb = { ...starterIronclad }; + +export function getCard(id) { + return cardDb[id]; +} + +export function getStarterDeck(character) { + if (character === "ironclad") { + return [ + ...Array(5).fill("strike_r"), + ...Array(4).fill("defend_r"), + "bash", + ]; + } + return []; +} +``` + +**Step 6: Run test to verify it passes** + +Run: `bun test src/cards.test.js` +Expected: PASS + +**Step 7: Commit** + +``` +Add card data module with ironclad starter deck +``` + +--- + +### Task 3: State module + +**Files:** +- Create: `src/state.js` +- Test: `src/state.test.js` + +**Step 1: Write failing tests** + +`src/state.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { createCombatState, drawCards, playCard, endTurn } from "./state.js"; + +describe("createCombatState", () => { + test("creates initial state with shuffled deck and correct values", () => { + const state = createCombatState("ironclad", "jaw_worm"); + expect(state.player.hp).toBe(11); + expect(state.player.maxHp).toBe(11); + expect(state.player.energy).toBe(3); + expect(state.player.block).toBe(0); + expect(state.player.strength).toBe(0); + expect(state.player.hand).toHaveLength(0); + expect(state.player.drawPile).toHaveLength(10); + expect(state.enemy.name).toBe("Jaw Worm"); + expect(state.enemy.hp).toBeGreaterThan(0); + expect(state.combat.turn).toBe(1); + expect(state.combat.phase).toBe("player_turn"); + }); +}); + +describe("drawCards", () => { + test("moves cards from draw pile to hand", () => { + const state = createCombatState("ironclad", "jaw_worm"); + const next = drawCards(state, 5); + expect(next.player.hand).toHaveLength(5); + expect(next.player.drawPile).toHaveLength(5); + }); + + test("shuffles discard into draw when draw pile runs out", () => { + let state = createCombatState("ironclad", "jaw_worm"); + // move all but 2 cards to discard + state = { + ...state, + player: { + ...state.player, + drawPile: state.player.drawPile.slice(0, 2), + discardPile: state.player.drawPile.slice(2), + }, + }; + const next = drawCards(state, 5); + expect(next.player.hand).toHaveLength(5); + expect(next.player.discardPile).toHaveLength(0); + }); +}); + +describe("playCard", () => { + test("deducts energy and moves card to discard", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = drawCards(state, 5); + const cardIndex = state.player.hand.indexOf("strike_r"); + const next = playCard(state, cardIndex); + expect(next.player.energy).toBe(2); + expect(next.player.hand).toHaveLength(4); + }); + + test("returns null if not enough energy", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = drawCards(state, 5); + state = { ...state, player: { ...state.player, energy: 0 } }; + const cardIndex = state.player.hand.indexOf("strike_r"); + const result = playCard(state, cardIndex); + expect(result).toBeNull(); + }); +}); + +describe("endTurn", () => { + test("discards hand and switches to enemy phase", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = drawCards(state, 5); + const next = endTurn(state); + expect(next.player.hand).toHaveLength(0); + expect(next.player.discardPile.length).toBeGreaterThan(0); + expect(next.combat.phase).toBe("enemy_turn"); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/state.test.js` +Expected: FAIL + +**Step 3: Implement state module** + +`src/state.js`: +```js +import { getCard, getStarterDeck } from "./cards.js"; +import { resolveEffects } from "./effects.js"; +import { getEnemy } from "./enemies.js"; + +export function createCombatState(character, enemyId) { + const enemy = getEnemy(enemyId); + return { + player: { + hp: 11, + maxHp: 11, + energy: 3, + maxEnergy: 3, + block: 0, + strength: 0, + vulnerable: 0, + weak: 0, + drawPile: shuffle([...getStarterDeck(character)]), + hand: [], + discardPile: [], + exhaustPile: [], + powers: [], + }, + enemy: { + id: enemy.id, + name: enemy.name, + hp: enemy.hp, + maxHp: enemy.hp, + block: 0, + strength: 0, + vulnerable: 0, + weak: 0, + actionType: enemy.actionType, + actions: enemy.actions, + actionTrack: enemy.actionTrack || null, + trackPosition: 0, + }, + combat: { + turn: 1, + phase: "player_turn", + dieResult: null, + selectedCard: null, + log: [], + }, + }; +} + +export function drawCards(state, count) { + let drawPile = [...state.player.drawPile]; + let discardPile = [...state.player.discardPile]; + const hand = [...state.player.hand]; + + for (let i = 0; i < count; i++) { + if (drawPile.length === 0) { + drawPile = shuffle(discardPile); + discardPile = []; + } + if (drawPile.length > 0) { + hand.push(drawPile.pop()); + } + } + + return { + ...state, + player: { ...state.player, drawPile, hand, discardPile }, + }; +} + +export function playCard(state, handIndex) { + const cardId = state.player.hand[handIndex]; + const card = getCard(cardId); + if (state.player.energy < card.cost) return null; + + const hand = [...state.player.hand]; + hand.splice(handIndex, 1); + const discardPile = [...state.player.discardPile, cardId]; + const energy = state.player.energy - card.cost; + + let next = { + ...state, + player: { ...state.player, hand, discardPile, energy }, + }; + + next = resolveEffects(next, card.effects, "player", "enemy"); + return next; +} + +export function endTurn(state) { + return { + ...state, + player: { + ...state.player, + hand: [], + discardPile: [...state.player.discardPile, ...state.player.hand], + }, + combat: { ...state.combat, phase: "enemy_turn" }, + }; +} + +function shuffle(arr) { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} +``` + +Note: this depends on effects.js and enemies.js — those are the next two tasks. For this task to pass tests, you may need to create stub versions first: + +Stub `src/effects.js`: +```js +export function resolveEffects(state) { + return state; +} +``` + +Stub `src/enemies.js` (and create `data/enemies.json` from Task 2): +```js +import enemyDb from "../data/enemies.json"; + +export function getEnemy(id) { + return enemyDb[id]; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test src/state.test.js` +Expected: PASS + +**Step 5: Commit** + +``` +Add state module with combat init, draw, play, end turn +``` + +--- + +### Task 4: Effect resolver + +**Files:** +- Create: `src/effects.js` (replace stub) +- Test: `src/effects.test.js` + +This is the most test-heavy module. Each effect type and modifier interaction needs coverage. + +**Step 1: Write failing tests** + +`src/effects.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { resolveEffects, calculateHitDamage } from "./effects.js"; +import { createCombatState } from "./state.js"; + +function makeState(overrides = {}) { + const base = createCombatState("ironclad", "jaw_worm"); + return { + ...base, + player: { ...base.player, ...overrides.player }, + enemy: { ...base.enemy, ...overrides.enemy }, + }; +} + +describe("calculateHitDamage", () => { + test("base damage with no modifiers", () => { + expect(calculateHitDamage(1, 0, false, false)).toBe(1); + }); + + test("strength adds to damage", () => { + expect(calculateHitDamage(1, 2, false, false)).toBe(3); + }); + + test("vulnerable doubles damage", () => { + expect(calculateHitDamage(2, 0, true, false)).toBe(4); + }); + + test("weak reduces damage by 1", () => { + expect(calculateHitDamage(3, 0, false, true)).toBe(2); + }); + + test("weak and vulnerable cancel out", () => { + expect(calculateHitDamage(2, 0, true, true)).toBe(2); + }); + + test("strength applied before vulnerable doubling", () => { + expect(calculateHitDamage(1, 1, true, false)).toBe(4); + }); + + test("damage cannot go below 0", () => { + expect(calculateHitDamage(0, 0, false, true)).toBe(0); + }); +}); + +describe("resolveEffects - hit", () => { + test("hit reduces enemy hp", () => { + const state = makeState(); + const effects = [{ type: "hit", value: 1 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.hp).toBe(state.enemy.hp - 1); + }); + + test("hit absorbed by block first", () => { + const state = makeState({ enemy: { block: 3 } }); + const effects = [{ type: "hit", value: 2 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.block).toBe(1); + expect(next.enemy.hp).toBe(state.enemy.hp); + }); + + test("hit overflow past block damages hp", () => { + const state = makeState({ enemy: { block: 1 } }); + const effects = [{ type: "hit", value: 3 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.block).toBe(0); + expect(next.enemy.hp).toBe(state.enemy.hp - 2); + }); + + test("vulnerable consumed after hit", () => { + const state = makeState({ enemy: { vulnerable: 2 } }); + const effects = [{ type: "hit", value: 1 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.vulnerable).toBe(1); + }); +}); + +describe("resolveEffects - block", () => { + test("block adds to player block", () => { + const state = makeState(); + const effects = [{ type: "block", value: 2 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.block).toBe(2); + }); + + test("block caps at 20 for player", () => { + const state = makeState({ player: { block: 19 } }); + const effects = [{ type: "block", value: 5 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.block).toBe(20); + }); +}); + +describe("resolveEffects - status effects", () => { + test("vulnerable applies to target", () => { + const state = makeState(); + const effects = [{ type: "vulnerable", value: 2 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.vulnerable).toBe(2); + }); + + test("vulnerable caps at 3", () => { + const state = makeState({ enemy: { vulnerable: 2 } }); + const effects = [{ type: "vulnerable", value: 3 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.vulnerable).toBe(3); + }); + + test("weak applies to target", () => { + const state = makeState(); + const effects = [{ type: "weak", value: 1 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.enemy.weak).toBe(1); + }); + + test("strength applies to source", () => { + const state = makeState(); + const effects = [{ type: "strength", value: 1 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.strength).toBe(1); + }); + + test("strength caps at 8", () => { + const state = makeState({ player: { strength: 7 } }); + const effects = [{ type: "strength", value: 3 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.strength).toBe(8); + }); +}); + +describe("resolveEffects - draw", () => { + test("draw effect draws cards", () => { + const state = makeState(); + // put all cards in draw pile + const effects = [{ type: "draw", value: 2 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.hand).toHaveLength(2); + expect(next.player.drawPile).toHaveLength(8); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/effects.test.js` +Expected: FAIL + +**Step 3: Implement effects module** + +Replace `src/effects.js`: +```js +import { drawCards } from "./state.js"; + +export function calculateHitDamage(base, strength, targetVulnerable, attackerWeak) { + if (targetVulnerable && attackerWeak) { + return base + strength; + } + let damage = base + strength; + if (targetVulnerable) { + damage *= 2; + } + if (attackerWeak) { + damage -= 1; + } + return Math.max(0, damage); +} + +export function resolveEffects(state, effects, source, target) { + let current = state; + for (const effect of effects) { + current = resolveSingleEffect(current, effect, source, target); + } + return current; +} + +function resolveSingleEffect(state, effect, source, target) { + switch (effect.type) { + case "hit": + return resolveHit(state, effect.value, source, target); + case "block": + return resolveBlock(state, effect.value, source); + case "vulnerable": + return applyStatus(state, target, "vulnerable", effect.value, 3); + case "weak": + return applyStatus(state, target, "weak", effect.value, 3); + case "strength": + return applyStatus(state, source, "strength", effect.value, 8); + case "draw": + return drawCards(state, effect.value); + case "lose_hp": + return directDamage(state, target, effect.value); + default: + console.debug(`unhandled effect type: ${effect.type}`); + return state; + } +} + +function resolveHit(state, baseValue, source, target) { + const attacker = state[source]; + const defender = state[target]; + + const damage = calculateHitDamage( + baseValue, + attacker.strength, + defender.vulnerable > 0, + attacker.weak > 0, + ); + + let block = defender.block; + let hp = defender.hp; + let remaining = damage; + + if (block > 0) { + const absorbed = Math.min(block, remaining); + block -= absorbed; + remaining -= absorbed; + } + hp = Math.max(0, hp - remaining); + + // consume 1 vulnerable from defender + const vulnerable = Math.max(0, defender.vulnerable - 1); + + return { + ...state, + [target]: { ...defender, hp, block, vulnerable }, + }; +} + +function resolveBlock(state, value, source) { + const entity = state[source]; + const maxBlock = source === "player" ? 20 : 999; + const block = Math.min(entity.block + value, maxBlock); + return { + ...state, + [source]: { ...entity, block }, + }; +} + +function applyStatus(state, target, status, value, max) { + const entity = state[target]; + const current = entity[status] || 0; + return { + ...state, + [target]: { ...entity, [status]: Math.min(current + value, max) }, + }; +} + +function directDamage(state, target, value) { + const entity = state[target]; + return { + ...state, + [target]: { ...entity, hp: Math.max(0, entity.hp - value) }, + }; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `bun test src/effects.test.js` +Expected: PASS + +**Step 5: Commit** + +``` +Add effect resolver with hit, block, status, draw +``` + +--- + +### Task 5: Die and enemy AI + +**Files:** +- Create: `src/die.js` +- Modify: `src/enemies.js` (replace stub) +- Test: `src/die.test.js` +- Test: `src/enemies.test.js` + +**Step 1: Write failing tests** + +`src/die.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { rollDie } from "./die.js"; + +describe("rollDie", () => { + test("returns a number between 1 and 6", () => { + for (let i = 0; i < 100; i++) { + const result = rollDie(); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(6); + } + }); +}); +``` + +`src/enemies.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { getEnemy, resolveEnemyAction } from "./enemies.js"; + +describe("getEnemy", () => { + test("returns enemy by id", () => { + const enemy = getEnemy("jaw_worm"); + expect(enemy.name).toBe("Jaw Worm"); + expect(enemy.hp).toBeGreaterThan(0); + }); +}); + +describe("resolveEnemyAction", () => { + test("die action returns effects for given roll", () => { + const enemy = getEnemy("jaw_worm"); + const action = resolveEnemyAction(enemy, 1, 0); + expect(action.effects).toBeDefined(); + expect(action.effects.length).toBeGreaterThan(0); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/die.test.js src/enemies.test.js` +Expected: FAIL + +**Step 3: Implement** + +`src/die.js`: +```js +export function rollDie() { + return Math.floor(Math.random() * 6) + 1; +} +``` + +`src/enemies.js` (replace stub): +```js +import enemyDb from "../data/enemies.json"; + +export function getEnemy(id) { + return enemyDb[id]; +} + +export function resolveEnemyAction(enemy, dieResult, trackPosition) { + if (enemy.actionType === "single") { + return enemy.actions["1"]; + } + if (enemy.actionType === "die") { + return enemy.actions[String(dieResult)]; + } + if (enemy.actionType === "cube") { + const track = enemy.actionTrack; + const pos = Math.min(trackPosition, track.length - 1); + return track[pos]; + } + return { intent: "unknown", effects: [] }; +} +``` + +**Step 4: Run tests** + +Run: `bun test src/die.test.js src/enemies.test.js` +Expected: PASS + +**Step 5: Commit** + +``` +Add die roll and enemy action resolver +``` + +--- + +### Task 6: Combat orchestration + +**Files:** +- Create: `src/combat.js` +- Test: `src/combat.test.js` + +This module sequences turns. It uses state.js and effects.js but adds turn structure. + +**Step 1: Write failing tests** + +`src/combat.test.js`: +```js +import { describe, expect, test } from "bun:test"; +import { startTurn, resolveEnemyTurn, checkCombatEnd } from "./combat.js"; +import { createCombatState } from "./state.js"; + +describe("startTurn", () => { + test("resets energy and block, draws 5 cards", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = { + ...state, + player: { ...state.player, energy: 0, block: 5 }, + }; + const next = startTurn(state); + expect(next.player.energy).toBe(3); + expect(next.player.block).toBe(0); + expect(next.player.hand).toHaveLength(5); + expect(next.combat.dieResult).toBeGreaterThanOrEqual(1); + expect(next.combat.dieResult).toBeLessThanOrEqual(6); + }); +}); + +describe("resolveEnemyTurn", () => { + test("enemy attacks reduce player hp or block", () => { + let state = createCombatState("ironclad", "jaw_worm"); + // force a die result that maps to an attack + state = { ...state, combat: { ...state.combat, dieResult: 1 } }; + const before = state.player.hp + state.player.block; + const next = resolveEnemyTurn(state); + const after = next.player.hp + next.player.block; + // enemy should have done something + expect(next.combat.phase).toBe("player_turn"); + expect(next.combat.turn).toBe(state.combat.turn + 1); + }); + + test("enemy block resets before acting", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = { + ...state, + enemy: { ...state.enemy, block: 5 }, + combat: { ...state.combat, dieResult: 1 }, + }; + const next = resolveEnemyTurn(state); + // block should have been cleared before action + // (enemy might regain some depending on action) + }); +}); + +describe("checkCombatEnd", () => { + test("returns 'victory' when enemy hp is 0", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = { ...state, enemy: { ...state.enemy, hp: 0 } }; + expect(checkCombatEnd(state)).toBe("victory"); + }); + + test("returns 'defeat' when player hp is 0", () => { + let state = createCombatState("ironclad", "jaw_worm"); + state = { ...state, player: { ...state.player, hp: 0 } }; + expect(checkCombatEnd(state)).toBe("defeat"); + }); + + test("returns null when combat continues", () => { + const state = createCombatState("ironclad", "jaw_worm"); + expect(checkCombatEnd(state)).toBeNull(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `bun test src/combat.test.js` +Expected: FAIL + +**Step 3: Implement combat module** + +`src/combat.js`: +```js +import { drawCards } from "./state.js"; +import { resolveEffects } from "./effects.js"; +import { resolveEnemyAction } from "./enemies.js"; +import { rollDie } from "./die.js"; + +export function startTurn(state) { + const dieResult = rollDie(); + let next = { + ...state, + player: { + ...state.player, + energy: state.player.maxEnergy, + block: 0, + }, + combat: { + ...state.combat, + phase: "player_turn", + dieResult, + selectedCard: null, + }, + }; + next = drawCards(next, 5); + return next; +} + +export function resolveEnemyTurn(state) { + // clear enemy block + let next = { + ...state, + enemy: { ...state.enemy, block: 0 }, + }; + + // resolve enemy action + const action = resolveEnemyAction( + next.enemy, + next.combat.dieResult, + next.enemy.trackPosition, + ); + + if (action && action.effects) { + next = resolveEffects(next, action.effects, "enemy", "player"); + } + + // advance cube track if applicable + let trackPosition = next.enemy.trackPosition; + if (next.enemy.actionType === "cube") { + trackPosition = Math.min( + trackPosition + 1, + (next.enemy.actionTrack || []).length - 1, + ); + } + + return { + ...next, + enemy: { ...next.enemy, trackPosition }, + combat: { + ...next.combat, + phase: "player_turn", + turn: next.combat.turn + 1, + }, + }; +} + +export function checkCombatEnd(state) { + if (state.enemy.hp <= 0) return "victory"; + if (state.player.hp <= 0) return "defeat"; + return null; +} +``` + +**Step 4: Run tests** + +Run: `bun test src/combat.test.js` +Expected: PASS + +**Step 5: Commit** + +``` +Add combat orchestration with turn flow and win/loss check +``` + +--- + +### Task 7: Run all tests, biome check + +**Step 1: Run full test suite** + +Run: `bun test` +Expected: all tests pass + +**Step 2: Run biome check** + +Run: `bunx biome check --write` +Expected: clean or auto-fixed + +**Step 3: Commit any formatting fixes** + +``` +Format with biome +``` + +--- + +### Task 8: HTML shell and dev server + +**Files:** +- Create: `index.html` +- Create: `style.css` +- Create: `src/serve.js` + +**Step 1: Create bun dev server** + +`src/serve.js`: +```js +const server = Bun.serve({ + port: 3000, + async fetch(req) { + const url = new URL(req.url); + let path = url.pathname === "/" ? "/index.html" : url.pathname; + const file = Bun.file(`.${path}`); + if (await file.exists()) { + return new Response(file); + } + return new Response("not found", { status: 404 }); + }, +}); +console.debug(`dev server: http://localhost:${server.port}`); +``` + +Note: check with `ports check 3000` first. If 3000 is taken, use `ports next` to find an available one. + +**Step 2: Create index.html** + +`index.html` — bare structure with the three zones. No styling yet, just semantic HTML: +```html + + + + + + slay with friends + + + +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+ + +
+ +
+ +
+
+ + + + + + +``` + +**Step 3: Create style.css** + +`style.css` — minimal layout only, browser defaults for aesthetics: +```css +* { margin: 0; padding: 0; box-sizing: border-box; } + +#game { + display: flex; + flex-direction: column; + height: 100dvh; + max-width: 500px; + margin: 0 auto; +} + +#enemy-zone { + flex: 4; + display: flex; + align-items: center; + justify-content: center; +} + +#info-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-top: 1px solid #ccc; + border-bottom: 1px solid #ccc; +} + +#hand { + flex: 3; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + overflow-x: auto; +} + +#hand .card { + width: 80px; + cursor: pointer; + transition: transform 0.15s; +} + +#hand .card.selected { + transform: translateY(-20px); +} + +#hand .card.no-energy { + opacity: 0.5; +} + +#overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 2rem; +} + +#overlay[hidden] { display: none; } +``` + +**Step 4: Verify server starts and page loads** + +Run: `bun run src/serve.js` +Open browser to http://localhost:3000 — should see empty layout structure. + +**Step 5: Commit** + +``` +Add HTML shell, CSS layout, and dev server +``` + +--- + +### Task 9: Render module + +**Files:** +- Create: `src/render.js` + +No unit tests for render — it's DOM manipulation. We verify visually and in the integration task. + +**Step 1: Implement render function** + +`src/render.js` — takes state, updates DOM. Reads card data for images and names. + +```js +import { getCard } from "./cards.js"; +import { resolveEnemyAction } from "./enemies.js"; + +export function render(state) { + renderEnemy(state); + renderInfoBar(state); + renderHand(state); + renderOverlay(state); +} + +function renderEnemy(state) { + const { enemy, combat } = state; + document.getElementById("enemy-name").textContent = enemy.name; + document.getElementById("enemy-hp").textContent = `${enemy.hp}/${enemy.maxHp}`; + document.getElementById("enemy-hp").style.width = + `${(enemy.hp / enemy.maxHp) * 100}%`; + document.getElementById("enemy-block").textContent = + enemy.block > 0 ? `block: ${enemy.block}` : ""; + + // status tokens + const statusEl = document.getElementById("enemy-status"); + const tokens = []; + if (enemy.vulnerable > 0) tokens.push(`vuln ${enemy.vulnerable}`); + if (enemy.weak > 0) tokens.push(`weak ${enemy.weak}`); + if (enemy.strength > 0) tokens.push(`str ${enemy.strength}`); + statusEl.textContent = tokens.join(" | "); + + // intent + const intentEl = document.getElementById("enemy-intent"); + if (combat.dieResult && combat.phase === "player_turn") { + const action = resolveEnemyAction( + enemy, + combat.dieResult, + enemy.trackPosition, + ); + if (action) { + intentEl.textContent = formatIntent(action); + } + } else { + intentEl.textContent = ""; + } +} + +function formatIntent(action) { + if (!action || !action.effects) return "?"; + const parts = action.effects.map((e) => { + if (e.type === "hit") return `attack ${e.value}`; + if (e.type === "block") return `block ${e.value}`; + if (e.type === "strength") return `str +${e.value}`; + return e.type; + }); + return parts.join(", "); +} + +function renderInfoBar(state) { + const { player } = state; + document.getElementById("energy").textContent = + `energy: ${player.energy}/${player.maxEnergy}`; + document.getElementById("player-hp").textContent = + `${player.hp}/${player.maxHp}`; + document.getElementById("player-hp").style.width = + `${(player.hp / player.maxHp) * 100}%`; + document.getElementById("player-block").textContent = + player.block > 0 ? `block: ${player.block}` : ""; + document.getElementById("player-strength").textContent = + player.strength > 0 ? `str: ${player.strength}` : ""; + document.getElementById("draw-count").textContent = player.drawPile.length; + document.getElementById("discard-count").textContent = + player.discardPile.length; +} + +function renderHand(state) { + const handEl = document.getElementById("hand"); + const { player, combat } = state; + + handEl.innerHTML = ""; + player.hand.forEach((cardId, index) => { + const card = getCard(cardId); + const img = document.createElement("img"); + img.src = card.image || ""; + img.alt = `${card.name} (${card.cost})`; + img.title = card.description; + img.className = "card"; + img.dataset.index = index; + + if (index === combat.selectedCard) { + img.classList.add("selected"); + } + if (player.energy < card.cost) { + img.classList.add("no-energy"); + } + + handEl.appendChild(img); + }); +} + +function renderOverlay(state) { + const overlay = document.getElementById("overlay"); + const result = state.combat.phase === "ended" ? state.combat.result : null; + if (result === "victory") { + overlay.hidden = false; + overlay.textContent = "victory"; + } else if (result === "defeat") { + overlay.hidden = false; + overlay.textContent = "defeat"; + } else { + overlay.hidden = true; + } +} +``` + +**Step 2: Commit** + +``` +Add render module for state-to-DOM projection +``` + +--- + +### Task 10: Main module — wire it all together + +**Files:** +- Create: `src/main.js` + +**Step 1: Implement main.js** + +`src/main.js` — initializes state, binds events, runs the game loop: + +```js +import { createCombatState, playCard, endTurn } from "./state.js"; +import { startTurn, resolveEnemyTurn, checkCombatEnd } from "./combat.js"; +import { render } from "./render.js"; + +let state = null; + +function init() { + state = createCombatState("ironclad", "jaw_worm"); + state = startTurn(state); + render(state); + bindEvents(); +} + +function bindEvents() { + // card selection (two-tap) + document.getElementById("hand").addEventListener("click", (e) => { + const card = e.target.closest(".card"); + if (!card || state.combat.phase !== "player_turn") return; + const index = Number(card.dataset.index); + + if (state.combat.selectedCard === index) { + // deselect + state = { ...state, combat: { ...state.combat, selectedCard: null } }; + render(state); + return; + } + + // select card + state = { ...state, combat: { ...state.combat, selectedCard: index } }; + render(state); + }); + + // target enemy (second tap) + 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) { + // not enough energy — shake animation would go here + state = { ...state, combat: { ...state.combat, selectedCard: null } }; + render(state); + return; + } + + state = { ...result, combat: { ...result.combat, selectedCard: null } }; + + // check if enemy died + const end = checkCombatEnd(state); + if (end) { + state = { + ...state, + combat: { ...state.combat, phase: "ended", result: end }, + }; + render(state); + return; + } + + render(state); + }); + + // end turn + document.getElementById("end-turn-btn").addEventListener("click", async () => { + if (state.combat.phase !== "player_turn") return; + + state = endTurn(state); + render(state); + + // enemy turn with pause + await delay(800); + state = resolveEnemyTurn(state); + + const end = checkCombatEnd(state); + if (end) { + state = { + ...state, + combat: { ...state.combat, phase: "ended", result: end }, + }; + render(state); + return; + } + + // next player turn + state = startTurn(state); + render(state); + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +init(); +``` + +**Step 2: Start dev server and test manually** + +Run: `bun run dev` +Open browser. Should see: +- 5 cards in hand +- enemy with HP +- enemy intent displayed +- can select a card, tap enemy to attack +- can end turn, enemy attacks back +- game ends on victory or defeat + +**Step 3: Commit** + +``` +Wire up main module with event handling and game loop +``` + +--- + +### Task 11: Card image mapping + +**Files:** +- Modify: `data/starter-ironclad.json` + +The card images in `assets/images/ironclad/starter/` are numbered. We need to identify which number maps to which card. + +**Step 1: Read each starter card image** + +Read the files in `assets/images/ironclad/starter/` (0.png, 1.png, 2.png) to identify them by the card name visible on the image. Also check the `upgraded/` subfolder. + +**Step 2: Update image paths in starter JSON** + +Map each card id to its correct image file path. + +**Step 3: Commit** + +``` +Map ironclad starter card images to data +``` + +--- + +### Task 12: Final check + +**Step 1: Run full test suite** + +Run: `bun test` +Expected: all pass + +**Step 2: Run biome check** + +Run: `bun run check` +Expected: clean + +**Step 3: Manual playthrough** + +Run: `bun run dev` +Play one full combat. Verify: +- cards draw and display correctly +- selecting and playing cards works +- energy deducts properly +- enemy takes damage +- enemy intent shows +- end turn triggers enemy attack +- player takes damage (block absorbs first) +- bash applies vulnerable, next attack does double +- game ends with victory or defeat overlay + +**Step 4: Commit** + +``` +First playable single combat encounter +```