fix(combat): sunder damages weapon not HP
This commit is contained in:
@@ -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} "
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user