Update effect resolver for indexed player/enemy targets

Added tests and verified resolveEffects handles {type, index}
descriptors for multi-player and multi-enemy state. getEntity and
setEntity helpers resolve either legacy string keys or typed index
objects, so all existing string-based call sites continue working.
This commit is contained in:
Jared Miller 2026-02-23 18:47:23 -05:00
parent 86287a30c2
commit e03b9b2dd7
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 117 additions and 7 deletions

16
src/effects.js vendored
View file

@ -36,11 +36,7 @@ function getEntity(state, descriptor) {
function setEntity(state, descriptor, updated) {
if (typeof descriptor === "string") {
const next = { ...state, [descriptor]: updated };
// keep compat alias in sync
if (descriptor === "player") next.player = updated;
if (descriptor === "enemy") next.enemy = updated;
return next;
return { ...state, [descriptor]: updated };
}
if (descriptor.type === "player") {
const players = state.players.map((p, i) =>
@ -77,9 +73,15 @@ function resolveSingleEffect(state, effect, source, target) {
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 "draw": {
const playerIdx =
typeof source === "object" && source.type === "player"
? source.index
: 0;
return drawCards(state, playerIdx, effect.value);
}
case "lose_hp":
// lose_hp damages the caster (source), not the target — e.g. Offering, Bloodletting
return directDamage(state, source, effect.value);
case "exhaust":
// handled by playCard — card goes to exhaustPile instead of discardPile

View file

@ -148,3 +148,111 @@ describe("resolveEffects - draw", () => {
expect(next.player.drawPile).toHaveLength(8);
});
});
describe("resolveEffects - lose_hp", () => {
test("lose_hp damages the caster (source), not the target", () => {
const state = makeState();
const enemyHpBefore = state.enemy.hp;
const playerHpBefore = state.player.hp;
const effects = [{ type: "lose_hp", value: 3 }];
const next = resolveEffects(state, effects, "player", "enemy");
expect(next.player.hp).toBe(playerHpBefore - 3);
expect(next.enemy.hp).toBe(enemyHpBefore);
});
});
describe("resolveEffects - indexed descriptors", () => {
function makeMultiState(overrides = {}) {
const base = createCombatState(
["ironclad", "ironclad"],
["jaw_worm", "jaw_worm"],
);
return {
...base,
players: base.players.map((p, i) =>
overrides.players?.[i] ? { ...p, ...overrides.players[i] } : p,
),
enemies: base.enemies.map((e, i) =>
overrides.enemies?.[i] ? { ...e, ...overrides.enemies[i] } : e,
),
};
}
test("hit from player 0 reduces enemy 0 hp, enemy 1 unchanged", () => {
const state = makeMultiState();
const effects = [{ type: "hit", value: 3 }];
const next = resolveEffects(
state,
effects,
{ type: "player", index: 0 },
{ type: "enemy", index: 0 },
);
expect(next.enemies[0].hp).toBeLessThan(state.enemies[0].hp);
expect(next.enemies[1].hp).toBe(state.enemies[1].hp);
});
test("hit from player 1 reduces enemy 1 hp, enemy 0 unchanged", () => {
const state = makeMultiState();
const effects = [{ type: "hit", value: 2 }];
const next = resolveEffects(
state,
effects,
{ type: "player", index: 1 },
{ type: "enemy", index: 1 },
);
expect(next.enemies[1].hp).toBeLessThan(state.enemies[1].hp);
expect(next.enemies[0].hp).toBe(state.enemies[0].hp);
});
test("block applies to player 1 only", () => {
const state = makeMultiState();
const effects = [{ type: "block", value: 4 }];
const next = resolveEffects(
state,
effects,
{ type: "player", index: 1 },
{ type: "enemy", index: 0 },
);
expect(next.players[1].block).toBe(4);
expect(next.players[0].block).toBe(0);
});
test("vulnerable applies to enemy 1 only", () => {
const state = makeMultiState();
const effects = [{ type: "vulnerable", value: 2 }];
const next = resolveEffects(
state,
effects,
{ type: "player", index: 0 },
{ type: "enemy", index: 1 },
);
expect(next.enemies[1].vulnerable).toBe(2);
expect(next.enemies[0].vulnerable).toBe(0);
});
test("enemy hits player 0, player 1 hp unchanged", () => {
const state = makeMultiState();
const effects = [{ type: "hit", value: 2 }];
const next = resolveEffects(
state,
effects,
{ type: "enemy", index: 0 },
{ type: "player", index: 0 },
);
expect(next.players[0].hp).toBeLessThan(state.players[0].hp);
expect(next.players[1].hp).toBe(state.players[1].hp);
});
test("draw effect draws for player 1, player 0 hand unchanged", () => {
const state = makeMultiState();
const effects = [{ type: "draw", value: 3 }];
const next = resolveEffects(
state,
effects,
{ type: "player", index: 1 },
{ type: "enemy", index: 0 },
);
expect(next.players[1].hand).toHaveLength(3);
expect(next.players[0].hand).toHaveLength(0);
});
});