Files
pf1e-simulator/tests/test_conformance_dying_occupancy.py

149 lines
5.3 KiB
Python

"""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