fix(combat): channel energy targets living vs undead
This commit is contained in:
@@ -7,6 +7,8 @@ tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
@@ -29,6 +31,7 @@ def _combatant(
|
||||
resources: dict[str, int] | None = None,
|
||||
spells: list[str] | None = None,
|
||||
hp_max: int = 20,
|
||||
creature_type: Literal["living", "undead", "construct"] = "living",
|
||||
) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
@@ -55,6 +58,7 @@ def _combatant(
|
||||
attacks=[attack],
|
||||
resources=resources or {},
|
||||
spells=spells or [],
|
||||
creature_type=creature_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,6 +138,69 @@ def test_oni_channel_energy_spends_resource() -> None:
|
||||
assert me.resources_left["channel_energy"] == 5
|
||||
|
||||
|
||||
def test_oni_channel_harm_does_not_damage_living_enemy() -> None:
|
||||
"""Regression: positive energy must not damage living creatures (reported bug:
|
||||
oni harmed a Giant Belostomatid — living vermin). Harm mode only hits undead."""
|
||||
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||
vermin = _combatant("belostomatid", hp_max=30)
|
||||
engine, me, enemy = _engine(oni, [vermin], rng_queue=[11])
|
||||
|
||||
action = Action(
|
||||
kind="channel_energy", target_id=vermin.id, channel_mode="harm"
|
||||
)
|
||||
engine._execute(me, action)
|
||||
|
||||
assert me.hp == 30 # living caster unaffected by harm mode
|
||||
assert enemy[0].hp == 30 # living vermin unaffected by harm mode
|
||||
|
||||
|
||||
def test_oni_channel_harm_damages_undead() -> None:
|
||||
"""Harm mode damages undead creatures in the burst regardless of side."""
|
||||
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||
lich = _combatant("lich", hp_max=100, creature_type="undead")
|
||||
engine, me, enemy = _engine(oni, [lich], rng_queue=[11])
|
||||
|
||||
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
|
||||
engine._execute(me, action)
|
||||
|
||||
assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11)
|
||||
|
||||
|
||||
def test_oni_channel_heal_heals_living_enemies_in_range() -> None:
|
||||
"""Heal mode heals all living creatures in the burst — including enemies
|
||||
(Selective Channeling, which would exclude them, is not modeled)."""
|
||||
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
engine, me, enemy = _engine(oni, [goblin], rng_queue=[5])
|
||||
enemy[0].hp = 2
|
||||
|
||||
action = Action(
|
||||
kind="channel_energy", target_id=me.combatant.id, channel_mode="heal"
|
||||
)
|
||||
engine._execute(me, action)
|
||||
|
||||
assert enemy[0].hp == min(2 + (1 + 5), 6) # healed, capped at hp_max
|
||||
|
||||
|
||||
def test_oni_emits_channel_harm_only_for_grouped_undead() -> None:
|
||||
"""Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft
|
||||
— living enemies are never harmed by positive energy."""
|
||||
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
|
||||
lich1 = _combatant("lich1", hp_max=100, creature_type="undead")
|
||||
|
||||
engine, me, _ = _engine(oni, [lich1])
|
||||
assert not any(a.kind == "channel_energy" for a in default_policy(engine, me))
|
||||
|
||||
lich2 = _combatant("lich2", hp_max=100, creature_type="undead")
|
||||
engine, me, _ = _engine(oni, [lich1, lich2])
|
||||
actions = default_policy(engine, me)
|
||||
|
||||
assert any(
|
||||
a.kind == "channel_energy" and a.channel_mode == "harm"
|
||||
for a in actions
|
||||
)
|
||||
|
||||
|
||||
# --- nairda: hex ---
|
||||
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ def _make_grid() -> Grid:
|
||||
|
||||
|
||||
def _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
"""Emit a channel_energy on an alpha, otherwise the default policy."""
|
||||
"""Emit a channel_energy (heal mode) on an alpha, otherwise the default policy."""
|
||||
if state.combatant.id == "oni-a":
|
||||
return (Action(kind="channel_energy", target_id="oni-b"),)
|
||||
return (Action(kind="channel_energy", target_id="oni-b", channel_mode="heal"),)
|
||||
return default_policy(_engine, state)
|
||||
|
||||
|
||||
|
||||
@@ -37,15 +37,19 @@ def test_monte_carlo_caster_pc_casts_spell() -> None:
|
||||
"""oni (cleric) casts against data/spells/ once it drops below 50% HP.
|
||||
|
||||
oni's known spells overlap the registry's heal spells (Cure Light/Moderate
|
||||
Wounds); with enough damage taken it heals via cast_spell. The Monte Carlo
|
||||
Wounds); with enough damage taken it heals via cast_spell. Channel energy is
|
||||
stripped from the resource pool: it now heals living creatures in the burst,
|
||||
so below 50% HP oni self-heals by channeling and never reaches the spell
|
||||
branch — this test stays a pure spell-wiring check. The Monte Carlo
|
||||
loop mirrors run_encounter: build fresh states and one CombatEngine per run
|
||||
with the registry from SPELLS_DIR.
|
||||
"""
|
||||
oni = load_combatant(ONI).model_copy(update={"resources": {}})
|
||||
spec = EncounterSpec(
|
||||
map=load_map(MAP),
|
||||
round_cap=100,
|
||||
sides=(
|
||||
Side(name="players", combatants=(load_combatant(ONI),), zone="A"),
|
||||
Side(name="players", combatants=(oni,), zone="A"),
|
||||
Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user