From 88bde16e174fc5e0bfb1695cfa75e7bd3023ea89 Mon Sep 17 00:00:00 2001 From: ctan Date: Tue, 1 Sep 2026 15:25:09 +0200 Subject: [PATCH] fix(combat): sunder damages weapon not HP --- src/pf1e_simulator/combat.py | 44 +++++++++++++++-- tests/test_maneuvers.py | 93 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 tests/test_maneuvers.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2feb279..8cd7f14 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -117,6 +117,7 @@ _MAX_RANGE_INCREMENTS = 10 # PF1e: projectile weapons shoot up to 10 increments _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side _ITERATIVE_PENALTY = 5 # PF1e: each BAB iterative is at -5 from the previous _MAX_ITERATIVES = 4 # PF1e: BAB +16 gives 4 attacks (at +16, +11, +6, +1) +_WEAPON_DURABILITY = 10 # Accumulated sunder damage that destroys a weapon. _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) @@ -202,6 +203,13 @@ class CombatantState: deed_up_close_and_deadly: bool = False summon_duration: int = 0 summon_caster_id: str | None = None + # Runtime weapon durability (not in the frozen data model): accumulated + # sunder damage per weapon id, and the set of weapon ids rendered unusable. + weapon_damage: dict[str, int] = field(default_factory=dict) + weapon_broken: set[str] = field(default_factory=set) + + def usable_attacks(self) -> list[AttackSpec]: + return [w for w in self.combatant.attacks if w.id not in self.weapon_broken] def __post_init__(self) -> None: if not self.spell_slots_left: @@ -497,7 +505,7 @@ class CombatEngine: if not has_line_of_effect(self._grid, attacker.pos, target.pos): return None dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT - for weapon in attacker.combatant.attacks: + for weapon in attacker.usable_attacks(): if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft: return weapon if ( @@ -797,7 +805,7 @@ class CombatEngine: """Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach).""" if not state.active: return frozenset() - if not any(w.kind == "melee" for w in state.combatant.attacks): + if not any(w.kind == "melee" for w in state.usable_attacks()): return frozenset() r, c = state.pos return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS) @@ -822,7 +830,7 @@ class CombatEngine: if self.has_condition_flag(attacker, "cant_aoo"): return False self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1 - weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None) + weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None) if weapon is None: return False return self._resolve_swing( @@ -936,7 +944,7 @@ class CombatEngine: blocked = frozenset( s.pos for s in self._states if s is not state and s is not target and s.active ) - for weapon in state.combatant.attacks: + for weapon in state.usable_attacks(): if weapon.kind != "melee": continue reach_cells = weapon.reach_ft // _SQUARE_FT @@ -1226,8 +1234,34 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "sunder", m) return + weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None) + if weapon is None: + self._log( + f"round {self._current_round} {state.combatant.id}: sunder vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (no weapon to damage)" + ) + return dmg = state.combatant.abilities.str_mod + 2 - target.hp -= dmg + target.weapon_damage[weapon.id] = target.weapon_damage.get(weapon.id, 0) + dmg + if target.weapon_damage[weapon.id] >= _WEAPON_DURABILITY: + target.weapon_broken.add(weapon.id) + target.active_conditions.append( + ActiveCondition( + condition=Condition( + name="disarmed", + modifiers=(StatModifier(target="attack", value=-4),), + ), + rounds_left=1, + source="maneuver: sunder", + ) + ) + self._log( + f"round {self._current_round} {state.combatant.id}: sunder vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (weapon destroyed)" + ) + return self._log( f"round {self._current_round} {state.combatant.id}: sunder vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py new file mode 100644 index 0000000..ed47b4b --- /dev/null +++ b/tests/test_maneuvers.py @@ -0,0 +1,93 @@ +"""Tests for combat maneuvers, focused on the sunder result. + +A successful sunder damages the defender's WEAPON, never its HP. Repeated +sunders can destroy the weapon, after which the defender can no longer use +it (effectively disarmed). See the plan task-12. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.grid import Grid +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.models import ( + AbilityScores, + ACProfile, + AttackSpec, + Combatant, + DamageComponent, + Saves, +) +from pf1e_simulator.rng import ScriptedRng + + +def make_combatant( + cid: str, *, attack_bonus: int = 2, weapon_name: str = "sword" +) -> Combatant: + attack = AttackSpec( + id=f"{cid}-w", + name=weapon_name, + kind="melee", + attack_bonus=attack_bonus, + damage=[DamageComponent(formula="1d4", types=["slashing"])], + ) + return Combatant( + id=cid, + name=cid, + level=1, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=6, + ac=ACProfile(total=13, touch=13, flat_footed=13), + bab=attack_bonus, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=0, ref=0, will=0), + attacks=[attack], + ) + + +def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: + return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) + + +def make_grid() -> Grid: + legend = {".": TerrainType(type="floor", move_cost=1)} + spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) + return Grid.from_spec(spec) + + +def make_engine( + queue: list[int], states: list[CombatantState] +) -> CombatEngine: + return CombatEngine(ScriptedRng(queue), make_grid(), states) + + +def test_sunder_damages_weapon_not_hp() -> None: + a_spec = make_combatant("a", attack_bonus=2) + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + hp_before = b.hp + engine = make_engine([12], [a, b]) + engine._execute_maneuver(a, b, "sunder") + assert b.hp == hp_before + assert b.weapon_damage["b-w"] == 2 + assert "b-w" not in b.weapon_broken + + +def test_sunder_destroys_weapon_and_disarms() -> None: + a_spec = make_combatant("a", attack_bonus=2) + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([12, 12, 12, 12, 12], [a, b]) + for _ in range(5): + engine._execute_maneuver(a, b, "sunder") + assert b.hp == 6 + assert "b-w" in b.weapon_broken + assert engine.weapon_for(b, a) is None + assert any(c.condition.name == "disarmed" for c in b.active_conditions)