feat(combat): enforce behavioral condition flags
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user