"""Dying/unconscious creatures still occupy their squares (conformance). PF1e SRD "Cover" (cover.md): a ranged target has cover when a line from the attacker's corner to a target corner passes "through a square occupied by a creature" -- the rule counts the creature, not its consciousness. SRD "Aiming a Spell" (aiming-a-spell.md): "A burst spell affects whatever it catches in its area, including creatures that you can't see." A dying (hp < 0, not yet dead) or unconscious creature therefore still grants soft cover (+4, ranged only) and is still caught by area templates. Documented choice: fully dead creatures (corpses) are EXCLUDED from both -- the SRD occupancy rule speaks of creatures; the engine models a corpse as inert scenery, not an occupying creature. """ from __future__ import annotations from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import AreaEffect from tests.test_combat import make_combatant, make_state _FLOOR = TerrainType(type="floor", move_cost=1) def _open_grid(size: int = 8) -> Grid: spec = MapSpec( name="dying-occupancy", terrain=tuple(["." * size] * size), legend={".": _FLOOR}, ) return Grid.from_spec(spec) def _engine(states: list[CombatantState]) -> CombatEngine: return CombatEngine(ScriptedRng([]), _open_grid(), states) # con 12 -> death threshold -12: hp -5 is dying (inactive, alive), # hp -12 is dead (a corpse). _DYING_HP = -5 _DEAD_HP = -12 def _trio( interposer_hp: int, ) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]: archer = make_combatant("archer", kind="ranged", range_increment_ft=60) interposer = make_combatant("interposer") target = make_combatant("target", ac=13) a = make_state(archer, "players", (1, 0)) i = make_state(interposer, "monsters", (1, 2)) i.hp = interposer_hp t = make_state(target, "monsters", (1, 4)) return _engine([a, i, t]), a, i, t # --------------------------------------------------------------------------- # (a) soft cover on ranged lines # --------------------------------------------------------------------------- def test_dying_creature_grants_soft_cover_on_ranged_line() -> None: engine, a, i, t = _trio(_DYING_HP) assert not i.active # sanity: the interposer is inactive assert engine._attack_cover_bonus(a, t, ranged=True) == 4 def test_corpse_grants_no_soft_cover() -> None: engine, a, i, t = _trio(_DEAD_HP) assert i.dead # sanity: the interposer is a corpse assert engine._attack_cover_bonus(a, t, ranged=True) == 0 def test_active_interposer_still_grants_soft_cover() -> None: engine, a, _, t = _trio(6) assert engine._attack_cover_bonus(a, t, ranged=True) == 4 def test_dying_creature_grants_no_cover_in_melee() -> None: # Melee attacks ignore creature squares entirely (SRD cover.md only # grants creature cover against ranged attacks) -- unchanged behavior. engine, a, _, t = _trio(_DYING_HP) assert engine._attack_cover_bonus(a, t, ranged=False) == 0 def test_pair_snapshot_cover_bonus_includes_dying_interposer() -> None: engine, a, _, t = _trio(_DYING_HP) obs: dict = engine._build_observation(a).to_dict() pairs = obs["pairs"] pair = next( p for p in pairs if p["target_index"] == engine._states.index(t) ) assert pair["cover_bonus"] == 4 # --------------------------------------------------------------------------- # (b) area templates catch dying creatures, not corpses # --------------------------------------------------------------------------- def _area_states(creature_hp: int) -> tuple[CombatEngine, CombatantState, CombatantState]: caster = make_combatant("caster") victim = make_combatant("victim") c = make_state(caster, "players", (1, 0)) v = make_state(victim, "monsters", (1, 3)) v.hp = creature_hp return _engine([c, v]), c, v def test_burst_catches_dying_creature() -> None: engine, c, v = _area_states(_DYING_HP) effect = AreaEffect(shape="burst", radius_ft=20) affected = engine._area_creatures(effect, c, (1, 3)) assert v in affected def test_burst_skips_corpse() -> None: engine, c, v = _area_states(_DEAD_HP) effect = AreaEffect(shape="burst", radius_ft=20) affected = engine._area_creatures(effect, c, (1, 3)) assert v not in affected def test_cone_catches_dying_creature() -> None: engine, c, v = _area_states(_DYING_HP) effect = AreaEffect(shape="cone", radius_ft=30) affected = engine._area_creatures(effect, c, (1, 4)) assert v in affected def test_line_catches_dying_creature() -> None: engine, c, v = _area_states(_DYING_HP) effect = AreaEffect(shape="line", radius_ft=30) affected = engine._area_creatures(effect, c, (1, 4)) assert v in affected def test_channel_energy_still_skips_dying_creatures() -> None: # Deliberate scope choice: channel energy is a class ability, not a # spell area template; its active-only filter is pinned here so the # occupancy fix above is not silently propagated to it. engine, c, v = _area_states(_DYING_HP) affected = engine._creatures_in_range((1, 3), 4) assert v not in affected assert c in affected