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