feat(combat): enforce prone/staggered/nauseated/paralyzed

This commit is contained in:
ctan
2026-09-01 14:57:27 +02:00
parent ce83356931
commit 1a4d7d8a2b
2 changed files with 355 additions and 1 deletions
+68 -1
View File
@@ -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, ...]]