Add effect resolver with hit, block, status, draw

This commit is contained in:
Jared Miller 2026-02-23 17:36:26 -05:00
parent a1f242d54e
commit 0bb8f236c0
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 255 additions and 2 deletions

111
src/effects.js vendored
View file

@ -1,3 +1,112 @@
export function resolveEffects(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; 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) },
};
}

144
src/effects.test.js Normal file
View file

@ -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);
});
});