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)
This commit is contained in:
@@ -862,6 +862,22 @@ class CombatantState:
|
|||||||
def dead(self) -> bool:
|
def dead(self) -> bool:
|
||||||
return self.hp <= self.death_threshold
|
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
|
@property
|
||||||
def death_threshold(self) -> int:
|
def death_threshold(self) -> int:
|
||||||
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule).
|
"""PF1e (CRB): death when negative hp reaches the CON score (no -10 rule).
|
||||||
@@ -1679,7 +1695,7 @@ class CombatEngine:
|
|||||||
others = frozenset(
|
others = frozenset(
|
||||||
cell
|
cell
|
||||||
for s in self._states
|
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()
|
for cell in s.occupied_cells()
|
||||||
)
|
)
|
||||||
cover_bonus = cover_bonus_cells(
|
cover_bonus = cover_bonus_cells(
|
||||||
@@ -3092,11 +3108,16 @@ class CombatEngine:
|
|||||||
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
self, attacker: CombatantState, target: CombatantState, *, ranged: bool
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Cover the target has against one attack from ``attacker`` (terrain +
|
"""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(
|
occupied = frozenset(
|
||||||
cell
|
cell
|
||||||
for s in self._states
|
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()
|
for cell in s.occupied_cells()
|
||||||
)
|
)
|
||||||
return cover_bonus_cells(
|
return cover_bonus_cells(
|
||||||
@@ -7160,7 +7181,10 @@ class CombatEngine:
|
|||||||
effect from its origin"). Cone/line: the template originates at the
|
effect from its origin"). Cone/line: the template originates at the
|
||||||
caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a
|
caster's anchor cell and opens toward ``aim`` (PF1e CRB "Aiming a
|
||||||
Spell" — see ``areas.py``); a creature is affected when ANY of its
|
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):
|
``effect.targets`` restricts the selection (SRD bless/bane bursts):
|
||||||
"allies" keeps only creatures on the caster's side (the caster
|
"allies" keeps only creatures on the caster's side (the caster
|
||||||
@@ -7171,7 +7195,9 @@ class CombatEngine:
|
|||||||
if effect.shape == "burst":
|
if effect.shape == "burst":
|
||||||
affected = [
|
affected = [
|
||||||
state
|
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(
|
if has_line_of_effect_cells(
|
||||||
self._grid, frozenset({aim}), state.occupied_cells()
|
self._grid, frozenset({aim}), state.occupied_cells()
|
||||||
)
|
)
|
||||||
@@ -7185,7 +7211,7 @@ class CombatEngine:
|
|||||||
affected = [
|
affected = [
|
||||||
state
|
state
|
||||||
for state in self._states
|
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":
|
if effect.targets == "allies":
|
||||||
return [s for s in affected if s.side == caster.side]
|
return [s for s in affected if s.side == caster.side]
|
||||||
@@ -7194,12 +7220,23 @@ class CombatEngine:
|
|||||||
return affected
|
return affected
|
||||||
|
|
||||||
def _creatures_in_range(
|
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]:
|
) -> 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] = []
|
affected: list[CombatantState] = []
|
||||||
for state in self._states:
|
for state in self._states:
|
||||||
if not state.active:
|
if not (state.active if active_only else state.occupies_space):
|
||||||
continue
|
continue
|
||||||
dist = min(
|
dist = min(
|
||||||
self._grid.distance(center, cell) for cell in state.occupied_cells()
|
self._grid.distance(center, cell) for cell in state.occupied_cells()
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user