fix(combat): sunder damages weapon not HP

This commit is contained in:
ctan
2026-09-01 15:25:09 +02:00
parent e22c2e1d31
commit 88bde16e17
2 changed files with 132 additions and 5 deletions
+39 -5
View File
@@ -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 _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 _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) _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_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_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) _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 deed_up_close_and_deadly: bool = False
summon_duration: int = 0 summon_duration: int = 0
summon_caster_id: str | None = None 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: def __post_init__(self) -> None:
if not self.spell_slots_left: if not self.spell_slots_left:
@@ -497,7 +505,7 @@ class CombatEngine:
if not has_line_of_effect(self._grid, attacker.pos, target.pos): if not has_line_of_effect(self._grid, attacker.pos, target.pos):
return None return None
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT 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: if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_ft:
return weapon return weapon
if ( if (
@@ -797,7 +805,7 @@ class CombatEngine:
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach).""" """Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
if not state.active: if not state.active:
return frozenset() 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() return frozenset()
r, c = state.pos r, c = state.pos
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS) 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"): if self.has_condition_flag(attacker, "cant_aoo"):
return False return False
self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1 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: if weapon is None:
return False return False
return self._resolve_swing( return self._resolve_swing(
@@ -936,7 +944,7 @@ class CombatEngine:
blocked = frozenset( blocked = frozenset(
s.pos for s in self._states if s is not state and s is not target and s.active 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": if weapon.kind != "melee":
continue continue
reach_cells = weapon.reach_ft // _SQUARE_FT reach_cells = weapon.reach_ft // _SQUARE_FT
@@ -1226,8 +1234,34 @@ class CombatEngine:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "sunder", m) self._maneuver_fail(state, target, "sunder", m)
return 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 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( self._log(
f"round {self._current_round} {state.combatant.id}: sunder vs " f"round {self._current_round} {state.combatant.id}: sunder vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
+93
View File
@@ -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)