fix(combat): channel energy targets living vs undead
This commit is contained in:
@@ -381,11 +381,15 @@ Règles modélisées :
|
|||||||
toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré
|
toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré
|
||||||
(`tests/test_feats.py`).
|
(`tests/test_feats.py`).
|
||||||
- Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource
|
- Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource
|
||||||
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, allié < 50 % ou
|
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive :
|
||||||
ennemis groupés à 30 ft), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
|
si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au
|
||||||
|
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
|
||||||
|
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
|
||||||
|
modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
|
||||||
(tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty)
|
(tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty)
|
||||||
— couvertes par `tests/test_class_abilities.py`.
|
— couvertes par `tests/test_class_abilities.py`.
|
||||||
- Le Liche (`data/monsters/lich.json`) : aura de terreur (30 ft, Volonté DD 22 ou
|
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
|
||||||
|
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
|
||||||
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
|
`shaken` 1 round, au début de son tour) et contact paralysant (au toucher,
|
||||||
`paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire
|
`paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire
|
||||||
`spell_slots` sans clés dupliquées.
|
`spell_slots` sans clés dupliquées.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "Lich",
|
"name": "Lich",
|
||||||
"level": 11,
|
"level": 11,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
|
"creature_type": "undead",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
"str_score": 12,
|
"str_score": 12,
|
||||||
"dex_score": 14,
|
"dex_score": 14,
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ class Action:
|
|||||||
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
hex_name: str | None = None # "evil_eye", "slumber", "cackle"
|
||||||
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||||
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
||||||
|
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -1156,7 +1157,9 @@ class CombatEngine:
|
|||||||
"""Dispatch a class-ability action by kind, reading its carrier field."""
|
"""Dispatch a class-ability action by kind, reading its carrier field."""
|
||||||
kind = action.kind
|
kind = action.kind
|
||||||
if kind == "channel_energy":
|
if kind == "channel_energy":
|
||||||
self._execute_channel_energy(state, target)
|
self._execute_channel_energy(
|
||||||
|
state, target, action.channel_mode or "heal"
|
||||||
|
)
|
||||||
elif kind == "hex":
|
elif kind == "hex":
|
||||||
self._execute_hex(state, target, action.hex_name)
|
self._execute_hex(state, target, action.hex_name)
|
||||||
elif kind == "deed":
|
elif kind == "deed":
|
||||||
@@ -1393,9 +1396,18 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute_channel_energy(
|
def _execute_channel_energy(
|
||||||
self, state: CombatantState, target: CombatantState | None
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
target: CombatantState | None,
|
||||||
|
channel_mode: Literal["heal", "harm"],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute channel energy (burst 30ft, 2d6 heal/damage, spends channel energy)."""
|
"""Execute channel energy (positive energy, burst 30ft, 2d6).
|
||||||
|
|
||||||
|
Heal mode heals all living creatures in the burst; harm mode damages
|
||||||
|
all undead creatures in the burst — the target's side does not matter
|
||||||
|
(PF1e Channel Energy: positive energy heals living creatures and
|
||||||
|
damages undead creatures within 30 feet).
|
||||||
|
"""
|
||||||
if not state.spend_resource("channel_energy", 1):
|
if not state.spend_resource("channel_energy", 1):
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
@@ -1407,8 +1419,7 @@ class CombatEngine:
|
|||||||
|
|
||||||
# Channel energy affects all creatures within 30ft (6 cells)
|
# Channel energy affects all creatures within 30ft (6 cells)
|
||||||
radius_cells = 30 // _SQUARE_FT # 6 cells
|
radius_cells = 30 // _SQUARE_FT # 6 cells
|
||||||
center = state.pos
|
affected = self._creatures_in_range(state.pos, radius_cells)
|
||||||
affected = self._creatures_in_range(center, radius_cells)
|
|
||||||
|
|
||||||
if not affected:
|
if not affected:
|
||||||
self._log(
|
self._log(
|
||||||
@@ -1417,22 +1428,21 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Determine if healing or harming based on target relationship
|
wanted_type = "living" if channel_mode == "heal" else "undead"
|
||||||
is_harm = target.side != state.side
|
hit = [s for s in affected if s.combatant.creature_type == wanted_type]
|
||||||
|
|
||||||
# 2d6 damage/healing
|
# 2d6 damage/healing
|
||||||
dmg = 1 + self._rng.roll_die(11) # 2d6
|
dmg = 1 + self._rng.roll_die(11) # 2d6
|
||||||
|
|
||||||
for affected_state in affected:
|
for affected_state in hit:
|
||||||
is_ally = affected_state.side == state.side
|
if channel_mode == "heal":
|
||||||
if is_ally and not is_harm:
|
|
||||||
self._channel_heal(state, affected_state, dmg)
|
self._channel_heal(state, affected_state, dmg)
|
||||||
elif not is_ally and is_harm:
|
else:
|
||||||
self._channel_damage(state, affected_state, dmg)
|
self._channel_damage(state, affected_state, dmg)
|
||||||
|
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"channel energy ({'harm' if is_harm else 'heal'}) {dmg} -> {len(affected)} targets"
|
f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _channel_heal(
|
def _channel_heal(
|
||||||
@@ -2524,8 +2534,15 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
|
|||||||
return (Action(kind="withdraw", target_id=target.combatant.id),)
|
return (Action(kind="withdraw", target_id=target.combatant.id),)
|
||||||
|
|
||||||
# Channel energy (oni): standard action that heal/harms in a 30ft burst.
|
# Channel energy (oni): standard action that heal/harms in a 30ft burst.
|
||||||
if _emit_channel_energy(engine, state):
|
channel_mode = _emit_channel_energy(engine, state)
|
||||||
return (Action(kind="channel_energy", target_id=target.combatant.id),)
|
if channel_mode is not None:
|
||||||
|
return (
|
||||||
|
Action(
|
||||||
|
kind="channel_energy",
|
||||||
|
target_id=target.combatant.id,
|
||||||
|
channel_mode=channel_mode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Hex (nairda): standard action when the target is within close range.
|
# Hex (nairda): standard action when the target is within close range.
|
||||||
if _emit_hex(engine, state, target):
|
if _emit_hex(engine, state, target):
|
||||||
@@ -2558,21 +2575,24 @@ _GROUPED_ENEMY_COUNT = 2
|
|||||||
_HEX_RANGE_FT = 25
|
_HEX_RANGE_FT = 25
|
||||||
|
|
||||||
|
|
||||||
def _emit_channel_energy(engine: CombatEngine, state: CombatantState) -> bool:
|
def _emit_channel_energy(
|
||||||
"""True when oni should channel energy: resource available and a trigger is met."""
|
engine: CombatEngine, state: CombatantState
|
||||||
|
) -> Literal["heal", "harm"] | None:
|
||||||
|
"""Channel mode when oni should channel (heal living / harm undead)."""
|
||||||
if state.resources_left.get("channel_energy", 0) <= 0:
|
if state.resources_left.get("channel_energy", 0) <= 0:
|
||||||
return False
|
return None
|
||||||
if _find_ally_needing_heal(engine, state) is not None:
|
if _find_ally_needing_heal(engine, state) is not None:
|
||||||
return True
|
return "heal"
|
||||||
radius_cells = 30 // _SQUARE_FT
|
radius_cells = 30 // _SQUARE_FT
|
||||||
grouped_enemies = [
|
grouped_undead_enemies = [
|
||||||
s
|
s
|
||||||
for s in engine.states
|
for s in engine.states
|
||||||
if s.side != state.side
|
if s.side != state.side
|
||||||
and s.active
|
and s.active
|
||||||
|
and s.combatant.creature_type == "undead"
|
||||||
and engine.grid.distance(state.pos, s.pos) <= radius_cells
|
and engine.grid.distance(state.pos, s.pos) <= radius_cells
|
||||||
]
|
]
|
||||||
return len(grouped_enemies) >= _GROUPED_ENEMY_COUNT
|
return "harm" if len(grouped_undead_enemies) >= _GROUPED_ENEMY_COUNT else None
|
||||||
|
|
||||||
|
|
||||||
def _emit_hex(
|
def _emit_hex(
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ class Combatant(_FrozenModel):
|
|||||||
name: str
|
name: str
|
||||||
level: int = Field(ge=1)
|
level: int = Field(ge=1)
|
||||||
size: str = "Medium"
|
size: str = "Medium"
|
||||||
|
creature_type: Literal["living", "undead", "construct"] = "living"
|
||||||
abilities: AbilityScores
|
abilities: AbilityScores
|
||||||
hp_max: int = Field(ge=1)
|
hp_max: int = Field(ge=1)
|
||||||
ac: ACProfile
|
ac: ACProfile
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ tammara (deeds), misty (spell_combat + inspire_courage). Each class has an
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
||||||
from pf1e_simulator.dice import parse_dice
|
from pf1e_simulator.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
@@ -29,6 +31,7 @@ def _combatant(
|
|||||||
resources: dict[str, int] | None = None,
|
resources: dict[str, int] | None = None,
|
||||||
spells: list[str] | None = None,
|
spells: list[str] | None = None,
|
||||||
hp_max: int = 20,
|
hp_max: int = 20,
|
||||||
|
creature_type: Literal["living", "undead", "construct"] = "living",
|
||||||
) -> Combatant:
|
) -> Combatant:
|
||||||
attack = AttackSpec(
|
attack = AttackSpec(
|
||||||
id=f"{cid}-w",
|
id=f"{cid}-w",
|
||||||
@@ -55,6 +58,7 @@ def _combatant(
|
|||||||
attacks=[attack],
|
attacks=[attack],
|
||||||
resources=resources or {},
|
resources=resources or {},
|
||||||
spells=spells 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
|
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 ---
|
# --- nairda: hex ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ def _make_grid() -> Grid:
|
|||||||
|
|
||||||
|
|
||||||
def _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
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":
|
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)
|
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 (cleric) casts against data/spells/ once it drops below 50% HP.
|
||||||
|
|
||||||
oni's known spells overlap the registry's heal spells (Cure Light/Moderate
|
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
|
loop mirrors run_encounter: build fresh states and one CombatEngine per run
|
||||||
with the registry from SPELLS_DIR.
|
with the registry from SPELLS_DIR.
|
||||||
"""
|
"""
|
||||||
|
oni = load_combatant(ONI).model_copy(update={"resources": {}})
|
||||||
spec = EncounterSpec(
|
spec = EncounterSpec(
|
||||||
map=load_map(MAP),
|
map=load_map(MAP),
|
||||||
round_cap=100,
|
round_cap=100,
|
||||||
sides=(
|
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"),
|
Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user