From 792cec140bb231acd5996109a28bea5a40110c5d Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 16:19:23 +0200 Subject: [PATCH] fix(rules): dying-but-alive creatures still occupy their squares (SRD cover.md: soft cover from 'a square occupied by a creature'; SRD aiming-a-spell.md: a burst 'affects whatever it catches in its area, including creatures that you can't see') - soft-cover interposer sets and area templates filtered on active, so a dying creature's square was ignored; new CombatantState.occupies_space (not dead) gates both; corpses stay excluded (documented); channel energy keeps its active-only filter (pinned) --- src/pf1e_simulator/combat.py | 55 ++++++-- tests/test_conformance_dying_occupancy.py | 148 ++++++++++++++++++++++ 2 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 tests/test_conformance_dying_occupancy.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 960cf74..d8a0add 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -862,6 +862,22 @@ class CombatantState: def dead(self) -> bool: return self.hp <= self.death_threshold + @property + def occupies_space(self) -> bool: + """True while the creature's squares count as creature-occupied. + + A dying/unconscious (inactive but not dead) creature still occupies + its squares: its square grants soft cover on a ranged line (SRD + cover.md: cover exists when a line passes "through a square + occupied by a creature"), and an area effect catches it ("A burst + spell affects whatever it catches in its area, including creatures + that you can't see", SRD aiming-a-spell.md). Fully dead creatures — + corpses — are EXCLUDED (documented choice): the SRD occupancy rule + speaks of creatures; a corpse is an object, and the engine models + no corpse obstacles. + """ + return not self.dead + @property def death_threshold(self) -> int: """PF1e (CRB): death when negative hp reaches the CON score (no -10 rule). @@ -1679,7 +1695,7 @@ class CombatEngine: others = frozenset( cell for s in self._states - if s.active and s is not state and s is not target + if s.occupies_space and s is not state and s is not target for cell in s.occupied_cells() ) cover_bonus = cover_bonus_cells( @@ -3092,11 +3108,16 @@ class CombatEngine: self, attacker: CombatantState, target: CombatantState, *, ranged: bool ) -> int: """Cover the target has against one attack from ``attacker`` (terrain + - soft cover from interposed creatures on ranged lines only).""" + soft cover from interposed creatures on ranged lines only). + + A dying/unconscious creature still grants soft cover (SRD cover.md + counts "a square occupied by a creature", not an active one — see + ``CombatantState.occupies_space``); a corpse does not. + """ occupied = frozenset( cell for s in self._states - if s.active and s is not attacker and s is not target + if s.occupies_space and s is not attacker and s is not target for cell in s.occupied_cells() ) return cover_bonus_cells( @@ -7160,7 +7181,10 @@ class CombatEngine: effect from its origin"). Cone/line: the template originates at the caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a Spell" — see ``areas.py``); a creature is affected when ANY of its - occupied cells lies inside the template. + occupied cells lies inside the template. Dying/unconscious (inactive + but alive) creatures are caught like any other (SRD aiming-a-spell: + an area affects "whatever it catches in its area, including creatures + that you can't see"); corpses are not (``occupies_space``). ``effect.targets`` restricts the selection (SRD bless/bane bursts): "allies" keeps only creatures on the caster's side (the caster @@ -7171,7 +7195,9 @@ class CombatEngine: if effect.shape == "burst": affected = [ state - for state in self._creatures_in_range(aim, span_cells) + for state in self._creatures_in_range( + aim, span_cells, active_only=False + ) if has_line_of_effect_cells( self._grid, frozenset({aim}), state.occupied_cells() ) @@ -7185,7 +7211,7 @@ class CombatEngine: affected = [ state for state in self._states - if state.active and state.occupied_cells() & cells + if state.occupies_space and state.occupied_cells() & cells ] if effect.targets == "allies": return [s for s in affected if s.side == caster.side] @@ -7194,12 +7220,23 @@ class CombatEngine: return affected def _creatures_in_range( - self, center: tuple[int, int], radius_cells: int + self, + center: tuple[int, int], + radius_cells: int, + *, + active_only: bool = True, ) -> list[CombatantState]: - """Return active creatures within radius_cells of center.""" + """Return creatures within radius_cells of center. + + ``active_only`` (default) keeps only acting creatures; an area spell + passes False so its burst also catches dying/unconscious creatures + (SRD aiming-a-spell.md: a burst "affects whatever it catches in its + area, including creatures that you can't see"). Corpses stay excluded + either way (see ``CombatantState.occupies_space``). + """ affected: list[CombatantState] = [] for state in self._states: - if not state.active: + if not (state.active if active_only else state.occupies_space): continue dist = min( self._grid.distance(center, cell) for cell in state.occupied_cells() diff --git a/tests/test_conformance_dying_occupancy.py b/tests/test_conformance_dying_occupancy.py new file mode 100644 index 0000000..43275cf --- /dev/null +++ b/tests/test_conformance_dying_occupancy.py @@ -0,0 +1,148 @@ +"""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