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: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """Execute one combatant's full turn; return True if the battle is over."""
self._decrement_durations(state) 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.moved_this_turn = False
state.power_attack = False state.power_attack = False
actions = self._policy(self, state) actions = self._policy(self, state)
@@ -313,6 +317,25 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
return self._winner_side() is not None 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: def _decrement_durations(self, state: CombatantState) -> None:
"""Decrement duration rounds for buffs and conditions; remove expired ones.""" """Decrement duration rounds for buffs and conditions; remove expired ones."""
state.active_buffs[:] = [ state.active_buffs[:] = [
@@ -464,7 +487,12 @@ class CombatEngine:
attack_mod = resolve_modifiers(atk_mods) attack_mod = resolve_modifiers(atk_mods)
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
total = roll + base_bonus + penalty + flank + attack_mod - pa_amt 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( occupied = frozenset(
s.pos for s in self._states if s.active and s is not attacker and s is not target 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). AoOs are always taken (PF1e allows declining, but the simulator always strikes).
Returns True if the target dropped. Returns True if the target dropped.
""" """
if self.has_condition_flag(attacker, "cant_aoo"):
return False
self._aoo_used.add(attacker.combatant.id) self._aoo_used.add(attacker.combatant.id)
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None) weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None: if weapon is None:
@@ -802,6 +832,9 @@ class CombatEngine:
) -> None: ) -> None:
if target is None or not target.active: if target is None or not target.active:
return 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) weapon = self.weapon_for(state, target)
if weapon is None: if weapon is None:
return return
@@ -809,12 +842,12 @@ class CombatEngine:
self._single_attack(state, target, weapon) self._single_attack(state, target, weapon)
elif kind == "full_attack": elif kind == "full_attack":
self._full_attack(state, target, weapon) self._full_attack(state, target, weapon)
else:
self._attack(state, target, weapon)
def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None: def _execute_charge(self, state: CombatantState, target: CombatantState | None) -> None:
if target is None or not target.active: if target is None or not target.active:
return 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) weapon = self.can_charge(state, target)
if weapon is None: if weapon is None:
return return
@@ -1106,9 +1139,10 @@ class CombatEngine:
def _step5(self, state: CombatantState, target: CombatantState) -> None: def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn: if state.moved_this_turn:
return return
path = self._move_path(state, target) if self.has_condition_flag(state, "cant_move"):
if not path: self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return return
path = self._move_path(state, target)
start = state.pos start = state.pos
state.pos = path[0] state.pos = path[0]
state.moved_this_turn = True state.moved_this_turn = True
@@ -1120,9 +1154,10 @@ class CombatEngine:
def _move(self, state: CombatantState, target: CombatantState) -> None: def _move(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn: if state.moved_this_turn:
return return
path = self._move_path(state, target) if self.has_condition_flag(state, "cant_move"):
if not path: self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
return return
path = self._move_path(state, target)
start = state.pos start = state.pos
actual_path: list[Pos] = [] actual_path: list[Pos] = []
for step in path: for step in path:
@@ -1143,7 +1178,8 @@ class CombatEngine:
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed.""" """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: if speed_cells <= 0:
return [] return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active) 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 Uses the Dijkstra cost field from the threat and greedily picks the
step that maximizes distance (cost) from the threat each cell. 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: if max_cells <= 0:
return [] return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active) 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) target = engine.nearest_enemy(state)
if target is None: if target is None:
return (Action(kind="wait"),) 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: if engine.weapon_for(state, target) is not None:
return (Action(kind="full_attack", target_id=target.combatant.id),) return (Action(kind="full_attack", target_id=target.combatant.id),)
charge_weapon = engine.can_charge(state, target) 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]) engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(STUNNED) f.conditions.append(STUNNED)
result = engine.resolve_attack(h, f, hero.attacks[0]) result = engine.resolve_attack(h, f, hero.attacks[0])
# foe AC 15 - 2 (stunned) = 13 # foe flat-footed AC 13 - 2 (stunned) = 11 (denies DEX to AC)
assert result.ac == 13 assert result.ac == 11
def test_blinded_defender_minus2_ac(self) -> None: def test_blinded_defender_minus2_ac(self) -> None:
hero = _make_hero() hero = _make_hero()
@@ -234,7 +234,8 @@ class TestConditionOnAC:
engine, h, f = _make_engine(hero, foe, [10, 4]) engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(BLINDED) f.conditions.append(BLINDED)
result = engine.resolve_attack(h, f, hero.attacks[0]) 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: def test_entangled_defender_minus2_ac(self) -> None:
hero = _make_hero() hero = _make_hero()