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, ...]]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user