From 45d62144bf1b4fae5061fd4e174fd269bfd7dc5d Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 23 Feb 2026 17:38:02 -0500 Subject: [PATCH] Add combat orchestration with turn flow and win/loss check --- src/combat.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++ src/combat.test.js | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/combat.js create mode 100644 src/combat.test.js diff --git a/src/combat.js b/src/combat.js new file mode 100644 index 0000000..b0654f3 --- /dev/null +++ b/src/combat.js @@ -0,0 +1,65 @@ +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) { + let next = { + ...state, + enemy: { ...state.enemy, block: 0 }, + }; + + const action = resolveEnemyAction( + next.enemy, + next.combat.dieResult, + next.enemy.trackPosition, + ); + + if (action && action.effects) { + next = resolveEffects(next, action.effects, "enemy", "player"); + } + + 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; +} diff --git a/src/combat.test.js b/src/combat.test.js new file mode 100644 index 0000000..99c3b91 --- /dev/null +++ b/src/combat.test.js @@ -0,0 +1,60 @@ +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"); + state = { ...state, combat: { ...state.combat, dieResult: 1 } }; + const next = resolveEnemyTurn(state); + 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); + // die result 1 maps to an attack action (hit 2), so no block regained + expect(next.enemy.block).toBe(0); + }); +}); + +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(); + }); +});