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:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user