Add effect resolver with hit, block, status, draw
This commit is contained in:
parent
a1f242d54e
commit
0bb8f236c0
2 changed files with 255 additions and 2 deletions
113
src/effects.js
vendored
113
src/effects.js
vendored
|
|
@ -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) },
|
||||
};
|
||||
}
|
||||
|
|
|
|||
144
src/effects.test.js
Normal file
144
src/effects.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue