From 0bb8f236c0a8990a905d46359f558d5e4b01e4c5 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Mon, 23 Feb 2026 17:36:26 -0500 Subject: [PATCH] Add effect resolver with hit, block, status, draw --- src/effects.js | 113 +++++++++++++++++++++++++++++++++- src/effects.test.js | 144 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/effects.test.js diff --git a/src/effects.js b/src/effects.js index 6e984f7..a3272ea 100644 --- a/src/effects.js +++ b/src/effects.js @@ -1,3 +1,112 @@ -export function resolveEffects(state) { - return state; +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, source, effect.value); + case "exhaust": + // handled by playCard — card goes to exhaustPile instead of discardPile + return state; + 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); + + const vulnerable = Math.max(0, defender.vulnerable - 1); + const attackerWeak = Math.max(0, state[source].weak - 1); + + return { + ...state, + [target]: { ...defender, hp, block, vulnerable }, + [source]: { ...state[source], weak: attackerWeak }, + }; +} + +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) }, + }; } diff --git a/src/effects.test.js b/src/effects.test.js new file mode 100644 index 0000000..4ed72d3 --- /dev/null +++ b/src/effects.test.js @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { calculateHitDamage, resolveEffects } 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); + }); + + test("weak consumed from attacker after hit", () => { + const state = makeState({ player: { weak: 2 } }); + const effects = [{ type: "hit", value: 1 }]; + const next = resolveEffects(state, effects, "player", "enemy"); + expect(next.player.weak).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(); + 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); + }); +});