feat(combat): enforce prone/staggered/nauseated/paralyzed
This commit is contained in:
@@ -367,6 +367,10 @@ class CombatEngine:
|
||||
state.moved_this_turn = False
|
||||
state.power_attack = False
|
||||
actions = self._policy(self, state)
|
||||
if self._has_condition_named(state, "staggered"):
|
||||
actions = _staggered_filter(actions)
|
||||
elif self.has_condition_flag(state, "cant_attack"):
|
||||
actions = _nauseated_filter(actions)
|
||||
before = len(self._transcript)
|
||||
for action in actions:
|
||||
if not state.active:
|
||||
@@ -386,6 +390,24 @@ class CombatEngine:
|
||||
# Also check old conditions field for backwards compatibility
|
||||
return any(getattr(cond, flag, False) for cond in state.conditions)
|
||||
|
||||
def _has_condition_named(self, state: CombatantState, name: str) -> bool:
|
||||
"""Check if the state has an active or old condition with the given name."""
|
||||
return (
|
||||
any(ac.condition.name == name for ac in state.active_conditions)
|
||||
or any(cond.name == name for cond in state.conditions)
|
||||
)
|
||||
|
||||
def _modifier_condition_holds(self, _state: CombatantState, _condition_name: str) -> bool:
|
||||
"""Evaluate whether a StatModifier's runtime condition holds.
|
||||
|
||||
Returns False for unknown or unresolvable conditions (modifier excluded).
|
||||
Currently no conditions are evaluable from _stat_modifiers call sites
|
||||
alone (they require action-level context like target range or spell
|
||||
descriptor), so this always returns False. When the engine gains that
|
||||
context, specific conditions can be added here.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _get_speed_mult(self, state: CombatantState) -> float:
|
||||
"""Get the minimum speed multiplier from all active conditions."""
|
||||
speed_mult = 1.0
|
||||
@@ -518,6 +540,11 @@ class CombatEngine:
|
||||
``weapon_name`` filters out modifiers with a ``weapon_filter`` that
|
||||
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
|
||||
Modifiers without a ``weapon_filter`` are always included.
|
||||
|
||||
Modifiers whose ``condition`` is set are only included when the named
|
||||
runtime condition holds for ``state`` (evaluated via
|
||||
``_modifier_condition_holds``). Unsupported condition names are treated
|
||||
as NOT holding (modifier excluded).
|
||||
"""
|
||||
mods: list[StatModifier] = []
|
||||
# Old effects field (backwards compatibility)
|
||||
@@ -539,7 +566,16 @@ class CombatEngine:
|
||||
if m.target == target
|
||||
and (m.weapon_filter is None or m.weapon_filter == weapon_name)
|
||||
)
|
||||
return mods
|
||||
if target == "ac":
|
||||
if self._has_condition_named(state, "prone"):
|
||||
mods.append(StatModifier(target="ac", value=-4))
|
||||
if self._has_condition_named(state, "paralyzed"):
|
||||
mods.append(StatModifier(target="ac", value=-4))
|
||||
return [
|
||||
m
|
||||
for m in mods
|
||||
if m.condition is None or self._modifier_condition_holds(state, m.condition)
|
||||
]
|
||||
|
||||
def _has_feat(self, state: CombatantState, feat_name: str) -> bool:
|
||||
"""True if the combatant has a feature with the given name."""
|
||||
@@ -566,6 +602,8 @@ class CombatEngine:
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||
if weapon.kind == "ranged" and self._has_condition_named(attacker, "prone"):
|
||||
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
||||
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt
|
||||
@@ -2299,6 +2337,35 @@ def _choose_spell_action(
|
||||
return None
|
||||
|
||||
|
||||
_FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdraw", "full_round"})
|
||||
_STANDARD_KINDS: frozenset[str] = frozenset({
|
||||
"attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy",
|
||||
})
|
||||
|
||||
|
||||
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
"""Staggered: one move OR one standard action per round, no full-round.
|
||||
|
||||
A leading ``full_attack`` (already in range) becomes a single standard
|
||||
``attack``; charge/withdraw/full_round are dropped.
|
||||
"""
|
||||
for action in actions:
|
||||
if action.kind == "full_attack":
|
||||
return (Action(kind="attack", target_id=action.target_id),)
|
||||
if action.kind in _FULL_ROUND_KINDS:
|
||||
continue
|
||||
return (action,)
|
||||
return ()
|
||||
|
||||
|
||||
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||
"""Nauseated: single standard action only."""
|
||||
for action in actions:
|
||||
if action.kind in _STANDARD_KINDS:
|
||||
return (action,)
|
||||
return ()
|
||||
|
||||
|
||||
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Tests for mechanical condition effects (prone, staggered, nauseated, paralyzed)
|
||||
and StatModifier.condition filtering.
|
||||
|
||||
Part (a): StatModifier with condition= is only included when the condition holds.
|
||||
Part (b): Condition behavioral effects enforced via action economy / AC modifiers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.effects import ActiveCondition, StatModifier
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
|
||||
|
||||
def _make_hero(cid: str = "hero", bab: int = 5) -> Combatant:
|
||||
melee = AttackSpec(
|
||||
id=f"{cid}-sword",
|
||||
name="sword",
|
||||
kind="melee",
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
|
||||
damage_bonus=2,
|
||||
)
|
||||
ranged = AttackSpec(
|
||||
id=f"{cid}-bow",
|
||||
name="bow",
|
||||
kind="ranged",
|
||||
attack_bonus=5,
|
||||
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
|
||||
damage_bonus=2,
|
||||
range_increment_ft=60,
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=14, dex_score=12, con_score=12,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=20,
|
||||
ac=ACProfile(total=18, touch=12, flat_footed=16),
|
||||
bab=bab,
|
||||
initiative_mod=2,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=5, ref=4, will=3),
|
||||
attacks=[melee, ranged],
|
||||
)
|
||||
|
||||
|
||||
def _make_foe(cid: str = "foe") -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="club",
|
||||
kind="melee",
|
||||
attack_bonus=3,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=12, dex_score=10, con_score=10,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=15,
|
||||
ac=ACProfile(total=15, touch=10, flat_footed=13),
|
||||
bab=3,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=3, ref=2, will=1),
|
||||
attacks=[attack],
|
||||
)
|
||||
|
||||
|
||||
def _make_engine(
|
||||
hero: Combatant, foe: Combatant, queue: list[int]
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
|
||||
f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
engine = CombatEngine(ScriptedRng(queue), grid, [h, f])
|
||||
return engine, h, f
|
||||
|
||||
|
||||
def _make_engine_3(
|
||||
c1: Combatant, c2: Combatant, c3: Combatant, queue: list[int]
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
|
||||
s1 = CombatantState(combatant=c1, side="players", pos=(0, 0), hp=c1.hp_max)
|
||||
s2 = CombatantState(combatant=c2, side="monsters", pos=(0, 1), hp=c2.hp_max)
|
||||
s3 = CombatantState(combatant=c3, side="monsters", pos=(0, 2), hp=c3.hp_max)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
engine = CombatEngine(ScriptedRng(queue), grid, [s1, s2, s3])
|
||||
return engine, s1, s2, s3
|
||||
|
||||
|
||||
# ── Test 1: prone → -4 AC vs melee attacks ───────────────────────────────────
|
||||
|
||||
|
||||
class TestProneACMelee:
|
||||
"""Given: a prone target
|
||||
When: a melee attacker resolves an attack
|
||||
Then: the target's AC is reduced by 4 (prone penalty)."""
|
||||
|
||||
def test_prone_target_minus4_ac_vs_melee(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
f.active_conditions.append(
|
||||
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
|
||||
)
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# foe total AC 15, prone penalty -4 → effective AC 11
|
||||
assert result.ac == 11
|
||||
|
||||
|
||||
# ── Test 2: prone ranged attacker → -4 on ranged attack ───────────────────────
|
||||
|
||||
|
||||
class TestProneRangedAttack:
|
||||
"""Given: an attacker who is prone
|
||||
When: they make a ranged attack
|
||||
Then: their attack total is reduced by 4."""
|
||||
|
||||
def test_prone_attacker_minus4_ranged_attack(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [15, 4])
|
||||
h.active_conditions.append(
|
||||
ActiveCondition(condition=PRONE, rounds_left=5, source="test")
|
||||
)
|
||||
ranged_weapon = hero.attacks[1] # bow
|
||||
result = engine.resolve_attack(h, f, ranged_weapon)
|
||||
# base_bonus=5, roll=15, no flank (ranged), no range penalty (same cell), prone -4
|
||||
# total = 15 + 5 - 4 = 16
|
||||
assert result.total == 16
|
||||
|
||||
|
||||
# ── Test 3: staggered → reduced action economy ────────────────────────────────
|
||||
|
||||
|
||||
class TestStaggeredActionEconomy:
|
||||
"""Given: a staggered combatant
|
||||
When: _take_turn is called
|
||||
Then: they can only do one move OR one standard action (no full attack)."""
|
||||
|
||||
def test_staggered_full_attack_becomes_single_attack(self) -> None:
|
||||
hero = _make_hero(bab=6)
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 10, 10, 4])
|
||||
h.active_conditions.append(
|
||||
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
|
||||
)
|
||||
engine._roll_initiative()
|
||||
engine._take_turn(h)
|
||||
# Staggered: no full-round. A leading full_attack (adjacent, BAB 6 →
|
||||
# 2 swings) becomes a single standard-action attack → exactly 1 swing.
|
||||
swings = [e for e in engine._transcript if " vs foe " in e and "sword" in e]
|
||||
assert len(swings) == 1
|
||||
|
||||
def test_staggered_can_still_act(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
||||
h.active_conditions.append(
|
||||
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
|
||||
)
|
||||
engine._roll_initiative()
|
||||
result = engine._take_turn(h)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── Test 4: nauseated → single standard action only ───────────────────────────
|
||||
|
||||
|
||||
class TestNauseatedActionEconomy:
|
||||
"""Given: a nauseated combatant
|
||||
When: _take_turn is called
|
||||
Then: they can only take standard actions (no full attack, no charge, no move)."""
|
||||
|
||||
def test_nauseated_no_attack_and_no_full_round(self) -> None:
|
||||
hero = _make_hero(bab=6)
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
||||
h.active_conditions.append(
|
||||
ActiveCondition(condition=NAUSEATED, rounds_left=5, source="test")
|
||||
)
|
||||
engine._roll_initiative()
|
||||
engine._take_turn(h)
|
||||
for entry in engine._transcript:
|
||||
assert "sword vs foe" not in entry, (
|
||||
f"nauseated combatant should not attack: {entry}"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 5: paralyzed → no actions + effective DEX 0 (-4 AC) ─────────────────
|
||||
|
||||
|
||||
class TestParalyzedNoActions:
|
||||
"""Given: a paralyzed combatant
|
||||
When: _take_turn is called
|
||||
Then: they take no actions and are effectively helpless."""
|
||||
|
||||
def test_paralyzed_no_action(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 10, 4])
|
||||
h.active_conditions.append(
|
||||
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
|
||||
)
|
||||
engine._roll_initiative()
|
||||
engine._take_turn(h)
|
||||
# Paralyzed: cant_act=True → should only log "wait (cannot act)"
|
||||
found_wait = False
|
||||
for entry in engine._transcript:
|
||||
if "cannot act" in entry:
|
||||
found_wait = True
|
||||
# Should not have executed any attack
|
||||
assert "attack" not in entry.lower() or "cannot" in entry.lower(), (
|
||||
f"paralyzed combatant should not attack: {entry}"
|
||||
)
|
||||
assert found_wait, "paralyzed combatant should log 'wait (cannot act)'"
|
||||
|
||||
def test_paralyzed_defender_helpless_ac(self) -> None:
|
||||
"""Paralyzed target: denies_dex_to_ac=True (flat-footed) + -4 AC (helpless)."""
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
f.active_conditions.append(
|
||||
ActiveCondition(condition=PARALYZED, rounds_left=5, source="test")
|
||||
)
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# Paralyzed: denies_dex_to_ac → flat_footed AC 13; plus -4 helpless → 9
|
||||
assert result.ac == 9
|
||||
|
||||
|
||||
# ── Test 6: StatModifier condition filtering ──────────────────────────────────
|
||||
|
||||
|
||||
class TestStatModifierConditionFiltering:
|
||||
"""Given: a StatModifier with condition= set
|
||||
When: the condition does not hold → modifier excluded
|
||||
When: the condition holds → modifier included."""
|
||||
|
||||
def test_modifier_excluded_when_condition_not_held(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
# Add a modifier with condition="mounted" that doesn't hold
|
||||
h.effects.append(
|
||||
StatModifier(target="attack", value=5, condition="mounted")
|
||||
)
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# The +5 should NOT apply because "mounted" condition doesn't hold
|
||||
# base_bonus=5, roll=10, no modifiers from condition
|
||||
assert result.total == 10 + 5 # 15
|
||||
|
||||
def test_modifier_included_when_condition_holds(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
# Add a modifier with no condition (always active)
|
||||
h.effects.append(
|
||||
StatModifier(target="attack", value=3)
|
||||
)
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# base_bonus=5, roll=10, +3 from always-active modifier
|
||||
assert result.total == 10 + 5 + 3 # 18
|
||||
Reference in New Issue
Block a user