From aaa1ef5661b3d040e71567753e692e1672128727 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 29 Aug 2026 20:58:38 +0200 Subject: [PATCH] feat(combat): enforce behavioral condition flags --- src/pf1e_simulator/combat.py | 60 ++++++++++++++++++++++++++++++------ tests/test_conditions.py | 7 +++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b352ad6..49a9847 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_conditions.py b/tests/test_conditions.py index d21a540..fe86564 100644 --- a/tests/test_conditions.py +++ b/tests/test_conditions.py @@ -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()