feat(combat): enforce behavioral condition flags

This commit is contained in:
2026-09-04 22:55:40 +02:00
parent d2c80c0d61
commit 0c2e3383d7
2 changed files with 54 additions and 13 deletions
+50 -10
View File
@@ -299,6 +299,10 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
self._decrement_durations(state)
# Check cant_act flag from conditions
if self.has_condition_flag(state, "cant_act"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot act)")
return self._winner_side() is not None
state.moved_this_turn = False
state.power_attack = False
actions = self._policy(self, state)
@@ -313,6 +317,25 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: wait")
return self._winner_side() is not None
def has_condition_flag(self, state: CombatantState, flag: str) -> bool:
"""Check if any active condition has the given behavioral flag set to True."""
for ac in state.active_conditions:
if getattr(ac.condition, flag, False):
return True
# Also check old conditions field for backwards compatibility
return any(getattr(cond, flag, False) for cond in state.conditions)
def _get_speed_mult(self, state: CombatantState) -> float:
"""Get the minimum speed multiplier from all active conditions."""
speed_mult = 1.0
for ac in state.active_conditions:
if ac.condition.speed_mult != 1.0:
speed_mult = min(speed_mult, ac.condition.speed_mult)
for cond in state.conditions:
if cond.speed_mult != 1.0:
speed_mult = min(speed_mult, cond.speed_mult)
return speed_mult
def _decrement_durations(self, state: CombatantState) -> None:
"""Decrement duration rounds for buffs and conditions; remove expired ones."""
state.active_buffs[:] = [
@@ -464,7 +487,12 @@ class CombatEngine:
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
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
# Check if target denies DEX to AC (flat-footed)
if self.has_condition_flag(target, "denies_dex_to_ac"):
base_ac = target.combatant.ac.flat_footed
else:
base_ac = target.combatant.ac.total
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target
)
@@ -587,6 +615,8 @@ class CombatEngine:
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
Returns True if the target dropped.
"""
if self.has_condition_flag(attacker, "cant_aoo"):
return False
self._aoo_used.add(attacker.combatant.id)
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None:
@@ -802,6 +832,9 @@ class CombatEngine:
) -> None:
if target is None or not target.active:
return
if self.has_condition_flag(state, "cant_attack"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
return
weapon = self.weapon_for(state, target)
if weapon is None:
return
@@ -809,12 +842,12 @@ class CombatEngine:
self._single_attack(state, target, weapon)
elif kind == "full_attack":
self._full_attack(state, target, weapon)
else:
self._attack(state, target, weapon)
def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None:
if target is None or not target.active:
return
if self.has_condition_flag(state, "cant_charge"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot charge)")
return
weapon = self.can_charge(state, target)
if weapon is None:
return
@@ -1106,9 +1139,10 @@ class CombatEngine:
def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return
path = self._move_path(state, target)
start = state.pos
state.pos = path[0]
state.moved_this_turn = True
@@ -1120,9 +1154,10 @@ class CombatEngine:
def _move(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
if self.has_condition_flag(state, "cant_move"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return
path = self._move_path(state, target)
start = state.pos
actual_path: list[Pos] = []
for step in path:
@@ -1143,7 +1178,8 @@ class CombatEngine:
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed."""
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
speed_mult = self._get_speed_mult(state)
speed_cells = int(state.combatant.speed_land_ft // _SQUARE_FT * speed_mult)
if speed_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
@@ -1183,7 +1219,8 @@ class CombatEngine:
Uses the Dijkstra cost field from the threat and greedily picks the
step that maximizes distance (cost) from the threat each cell.
"""
max_cells = 2 * (state.combatant.speed_land_ft // _SQUARE_FT)
speed_mult = self._get_speed_mult(state)
max_cells = int(2 * (state.combatant.speed_land_ft // _SQUARE_FT) * speed_mult)
if max_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
@@ -1283,6 +1320,9 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
target = engine.nearest_enemy(state)
if target is None:
return (Action(kind="wait"),)
# Must flee: withdraw from the nearest enemy
if engine.has_condition_flag(state, "must_flee"):
return (Action(kind="withdraw", target_id=target.combatant.id),)
if engine.weapon_for(state, target) is not None:
return (Action(kind="full_attack", target_id=target.combatant.id),)
charge_weapon = engine.can_charge(state, target)
+4 -3
View File
@@ -225,8 +225,8 @@ class TestConditionOnAC:
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(STUNNED)
result = engine.resolve_attack(h, f, hero.attacks[0])
# foe AC 15 - 2 (stunned) = 13
assert result.ac == 13
# foe flat-footed AC 13 - 2 (stunned) = 11 (denies DEX to AC)
assert result.ac == 11
def test_blinded_defender_minus2_ac(self) -> None:
hero = _make_hero()
@@ -234,7 +234,8 @@ class TestConditionOnAC:
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(BLINDED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
# foe flat-footed AC 13 - 2 (blinded) = 11 (denies DEX to AC)
assert result.ac == 11
def test_entangled_defender_minus2_ac(self) -> None:
hero = _make_hero()