feat(rules): sunder deals real weapon damage vs object hardness/hp; broken weapons usable at -2 with restricted crits

SRD Sunder + CRB 'Smashing an Object' / 'Conditions - Broken': weapons
carry Table 7-12 object stats (AttackSpec.hardness/hit_points/enhancement,
defaults by weapon class); a successful sunder deals the attacker's actual
physical weapon damage minus the target weapon's hardness, subtracted from
its hp. At <= half hp the weapon gains the broken condition - still usable
at -2 attack/damage, crits only on a natural 20 at x2. At <= 0 it is
destroyed (weapon_destroyed, unusable). The old flat str_mod+2 / durability
10 / synthetic 'disarmed' condition is gone. Firearm misfire carve-out kept
(broken firearms stay unusable until Quick Clear - misfire escalation not
modeled, documented).
This commit is contained in:
2026-09-20 15:43:45 +02:00
parent 544d870688
commit 74f5d64c43
6 changed files with 482 additions and 159 deletions
+27 -9
View File
@@ -1526,10 +1526,22 @@ Règles modélisées :
naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
20 while attempting a combat maneuver is always a success (except when
attempting to escape from bonds) » — un grapple n'est pas un lien), le 1
naturel échoue toujours. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2`
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) →
`disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via
`Action(kind="repair_weapon")`. overrun →
naturel échoue toujours. Un sunder endommage l'**arme** comme un OBJET, pas
les PV (SRD « Sunder » / CRB « Smashing an Object ») : les dégâts réels de
l'arme de l'attaquant (composantes physiques + bonus plat ; une arme brisée
frappe à −2), moins la **dureté** de l'arme cible, sont retranchés de ses
**points de vie d'objet**. Chaque arme porte donc dureté/pv d'objet
(`AttackSpec.hardness`/`hit_points`, défauts de la Table 7-12 par classe
d'arme : lame en acier 10/5 une main, 10/10 deux mains ; arme emmanchée
bois 5/5 et 5/10 ; arme de jet 5/5 ; `enhancement` ajoute +2 de dureté et
+10 pv par +1). À **pv ≤ moitié**, l'arme gagne la condition **brisée** :
elle reste **utilisable** à **−2 aux jets d'attaque et de dégâts**, ne
menace de critique que sur le **20 naturel** et ne fait que **×2** (CRB
« Broken »). À **pv ≤ 0**, l'arme est **détruite** (`weapon_destroyed`) :
excluante de `usable_attacks()`. Simplification documentée : l'option SRD
« réduire à 1 pv au lieu de détruire » n'est pas offerte (le moteur
détruit toujours). Une arme brisée (non détruite) est réparable via
`Action(kind="repair_weapon")` ; une arme détruite ne l'est pas. overrun →
passage au travers si la case au-delà est libre, et `prone` 1 round
**seulement si le jet dépasse le CMD de 5 ou plus** (conformance R3 — CRB
« Overrun » : « If your attack exceeds your opponent's CMD by 5 or more,
@@ -1645,7 +1657,12 @@ Règles modélisées :
feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`,
journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la
progression de la valeur de misfire et l'explosion des early firearms
re-cassées ne sont pas modélisées (une arme brisée ne peut plus tirer ici)),
re-cassées ne sont pas modélisées. Interaction avec la règle « Broken » :
une arme de mêlée brisée reste utilisable à −2, mais une **arme à feu
brisée reste exclue** de `usable_attacks()` (le SRD la laisserait tirer à
−2 avec une valeur de misfire majorée de 4 — cette progression n'étant
pas modélisée, le moteur interdit prudemment de tirer avec un pistolet
enrayé jusqu'à Quick Clear ou `repair_weapon`),
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
complet, incante un sort standard et porte toutes ses attaques de mêlée à
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
@@ -1685,7 +1702,7 @@ Règles modélisées :
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack,
weapon_broken/weapon_destroyed/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack,
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
@@ -1702,7 +1719,7 @@ Règles modélisées :
fraîche), casting_ability ou None pour non-jeteur
(E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium),
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts courants},
metamagic_feats (tuple trié), weapon_damage {weapon_id: dégâts cumulés au-delà de la dureté},
summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
(flags actuels), active_buffs {source, rounds_left, mods: tuple de
{target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft,
@@ -1710,8 +1727,9 @@ Règles modélisées :
en pieds), ac {total,
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm —
y compris les armes broken/stolen), features (noms des capacités),
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm,
hardness, hit_points (stats d'objet effectives, Table 7-12) —
y compris les armes broken/destroyed/stolen), features (noms des capacités),
feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft,
+161 -78
View File
@@ -190,7 +190,8 @@ _FLANK_BONUS = 2 # PF1e: +2 melee attack when ally threatens opposite side
_HIGH_GROUND_BONUS = 1 # PF1e: +1 melee attack when attacker stands higher than target
_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.
_BROKEN_WEAPON_PENALTY = -2 # PF1e (CRB "Broken"): -2 attack and damage rolls
_BROKEN_CRIT_MULT = 2 # PF1e (CRB "Broken"): broken weapons deal only x2 on a crit
_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)
@@ -840,10 +841,16 @@ class CombatantState:
swift_used_this_turn: 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.
# Runtime weapon durability (not in the frozen data model): damage dealt
# PAST the object's hardness per weapon id (CRB "Breaking and Entering":
# "Only damage in excess of its hardness is deducted from the object's
# hit points"), the set of weapon ids with the broken condition (usable
# at -2 with restricted crits — CRB "Broken"), and the set of weapon ids
# destroyed by a sunder (hp <= 0: "When an object's hit points reach 0,
# it's ruined" — unusable).
weapon_damage: dict[str, int] = field(default_factory=dict)
weapon_broken: set[str] = field(default_factory=set)
weapon_destroyed: set[str] = field(default_factory=set)
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set)
@@ -924,12 +931,23 @@ class CombatantState:
held_charge: HeldCharge | None = None
def usable_attacks(self) -> list[AttackSpec]:
# PF1e (CRB "Breaking Items" / "Conditions - Broken"): a broken
# weapon is still USABLE — it just suffers -2 on attack and damage
# rolls with crits restricted to natural 20 at x2 (applied in
# resolve_attack). Destroyed weapons (hp <= 0) are unusable.
# FIREARM EXCEPTION: a broken firearm (misfire, Ultimate Combat
# "Firearm Rules - Misfires") is kept unusable until Quick Clear or
# a repair — the SRD would let it fire at -2 with its misfire value
# raised by 4, but the misfire escalation and the exploding-early-
# firearm rules are not modeled, so re-firing a broken gun is
# represented as not possible (documented simplification).
return [
w
for w in self.combatant.attacks
if w.id not in self.weapon_broken
if w.id not in self.weapon_destroyed
and w.id not in self.weapon_stolen
and w.id not in self.weapon_dropped
and not (w.id in self.weapon_broken and w.firearm)
]
def occupied_cells(self) -> frozenset[Pos]:
@@ -1634,6 +1652,7 @@ class CombatEngine:
"conditions": conditions,
"resources_left": resources,
"weapon_broken": tuple(sorted(state.weapon_broken)),
"weapon_destroyed": tuple(sorted(state.weapon_destroyed)),
"weapon_stolen": tuple(sorted(state.weapon_stolen)),
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
"grappled_with": state.grappled_with,
@@ -1761,6 +1780,8 @@ class CombatEngine:
"range_increment_ft": attack.range_increment_ft,
"count": attack.count,
"firearm": attack.firearm,
"hardness": attack.object_stats()[0],
"hit_points": attack.object_stats()[1],
}
for attack in state.combatant.attacks
),
@@ -3112,6 +3133,11 @@ class CombatEngine:
else 0
)
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
# PF1e (CRB "Conditions - Broken"): "If the item is a weapon, any
# attacks made with the item suffer a -2 penalty on attack and
# damage rolls. Such weapons only score a critical hit on a natural
# 20 and only deal x2 damage on a confirmed critical hit."
broken = weapon.id in attacker.weapon_broken
fim_penalty = (
self._firing_into_melee_penalty(attacker, target)
if self._is_ranged_attack(weapon)
@@ -3149,6 +3175,7 @@ class CombatEngine:
total = (
roll + base_bonus + penalty + flank + high_ground + attack_roll_mod - pa_amt
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
+ (_BROKEN_WEAPON_PENALTY if broken else 0)
)
# Check if target denies DEX to AC (flat-footed); a running target
# loses its DEX bonus too (U5e), reusing the same AC path — unless it
@@ -3160,6 +3187,55 @@ class CombatEngine:
or self._running_denies_dex(target)
or self._has_condition_named(attacker, "invisible")
)
ac = self._attack_ac(attacker, target, weapon, dist_ft, denies_dex=denies_dex)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
# PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural
# result of your attack roll falls within a firearm's misfire value,
# that shot misses [...] When a firearm misfires, it gains the broken
# condition." The model uses the minimum misfire value (1): a natural
# 1 with a firearm (``firearm: true``) breaks the weapon — it joins
# ``weapon_broken`` and is unusable until Quick Clear or a repair.
# Bounded to firearms; the exploding-early-firearm and increased
# misfire-value rules are not modeled (a broken gun cannot be fired
# again here, so it cannot misfire twice).
if weapon.firearm and roll == _NATURAL_ONE:
attacker.weapon_broken.add(weapon.id)
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"{weapon.name} MISFIRE (broken)"
)
damage = 0
concealment_miss = False
crit = False
if hit:
crit, concealment_miss, damage = self._resolve_hit_damage(
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
penalty=penalty, flank=flank, high_ground=high_ground,
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
dist_ft=dist_ft, denies_dex=denies_dex, broken=broken,
)
if not concealment_miss:
self._lose_hp(target, damage)
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
attacker.aid_attack_bonus = 0
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus, melee_penalty=fim_penalty,
)
def _attack_ac(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
dist_ft: int,
*,
denies_dex: bool,
) -> int:
"""Effective AC of ``target`` against this attack (modifiers + cover)."""
full_ac_base = (
target.combatant.ac.flat_footed
if denies_dex
@@ -3193,43 +3269,7 @@ class CombatEngine:
ac += self._attack_cover_bonus(
attacker, target, ranged=self._is_ranged_attack(weapon)
)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
# PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural
# result of your attack roll falls within a firearm's misfire value,
# that shot misses [...] When a firearm misfires, it gains the broken
# condition." The model uses the minimum misfire value (1): a natural
# 1 with a firearm (``firearm: true``) breaks the weapon — it joins
# ``weapon_broken`` and is unusable until Quick Clear or a repair.
# Bounded to firearms; the exploding-early-firearm and increased
# misfire-value rules are not modeled (a broken gun cannot be fired
# again here, so it cannot misfire twice).
if weapon.firearm and roll == _NATURAL_ONE:
attacker.weapon_broken.add(weapon.id)
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"{weapon.name} MISFIRE (broken)"
)
damage = 0
concealment_miss = False
crit = False
if hit:
crit, concealment_miss, damage = self._resolve_hit_damage(
attacker, target, weapon, roll=roll, ac=ac, base_bonus=base_bonus,
penalty=penalty, flank=flank, high_ground=high_ground,
attack_mod=attack_mod, pa_amt=pa_amt, fim_penalty=fim_penalty,
dist_ft=dist_ft, denies_dex=denies_dex,
)
if not concealment_miss:
self._lose_hp(target, damage)
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
attacker.aid_attack_bonus = 0
return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus, melee_penalty=fim_penalty,
)
return ac
def _resolve_hit_damage(
self,
@@ -3248,6 +3288,7 @@ class CombatEngine:
fim_penalty: int,
dist_ft: int,
denies_dex: bool,
broken: bool = False,
) -> tuple[bool, bool, int]:
"""Resolve a successful attack roll: crit confirm, concealment miss
chance, sneak attack dice, then damage (PF1e order).
@@ -3259,8 +3300,15 @@ class CombatEngine:
the confirmation roll.
"""
crit = False
threat_range = weapon.crit_range
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE):
# CRB "Broken": a broken weapon "only score[s] a critical hit on a
# natural 20" — the threat range collapses to 20 and Improved
# Critical cannot widen it (a broken weapon is not being wielded to
# its full effect).
threat_range = _NATURAL_TWENTY if broken else weapon.crit_range
if (
not broken
and self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE)
):
threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
if roll != _NATURAL_ONE and roll >= threat_range:
confirm = self._rng.d20()
@@ -3268,6 +3316,7 @@ class CombatEngine:
confirm + base_bonus + penalty + flank + high_ground
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
+ (_BROKEN_WEAPON_PENALTY if broken else 0)
)
# PF1e (CRB): the confirmation roll is an attack roll — a natural
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss.
@@ -3322,6 +3371,10 @@ class CombatEngine:
energy_dice = sum(rolls) - physical_dice
damage = physical_dice + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
# CRB "Broken": the -2 rides the damage roll too (applied before the
# multiplier, like every other flat damage modifier).
if broken:
damage += _BROKEN_WEAPON_PENALTY
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
# bonus increased by half) with a two-handed weapon, a one-handed
# weapon used in two hands, or a primary natural with 1.5x Strength
@@ -3334,7 +3387,9 @@ class CombatEngine:
if weapon.kind == "melee":
damage += pa_amt * (3 if weapon.two_handed else 2)
if crit:
damage *= weapon.crit_mult
# CRB "Broken": a broken weapon "only deal[s] x2 damage on a
# confirmed critical hit" even if its multiplier is higher.
damage *= _BROKEN_CRIT_MULT if broken else weapon.crit_mult
damage += sneak_total + energy_dice
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
# energy damage dealt along with an attack" — DR reduces only the
@@ -5417,9 +5472,8 @@ class CombatEngine:
"""Repair a broken weapon: remove it from ``weapon_broken`` and reset damage.
No-op (with a log line) when ``weapon_id`` is None or the weapon was never
damaged. Also removes the transient ``disarmed`` condition originating
from the sunder that broke the weapon, so the repaired weapon is fully
usable again.
damaged. A DESTROYED weapon (hp <= 0, "ruined" per CRB "Smashing an
Object") cannot be repaired by this action.
"""
if weapon_id is None:
self._log(
@@ -5427,6 +5481,12 @@ class CombatEngine:
f"repair: nothing to repair"
)
return
if weapon_id in state.weapon_destroyed:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"repair {weapon_id}: weapon destroyed"
)
return
if weapon_id not in state.weapon_damage:
self._log(
f"round {self._current_round} {state.combatant.id}: "
@@ -5435,10 +5495,6 @@ class CombatEngine:
return
state.weapon_damage.pop(weapon_id, None)
state.weapon_broken.discard(weapon_id)
state.active_conditions = [
c for c in state.active_conditions
if not (c.condition.name == "disarmed" and c.source == "maneuver: sunder")
]
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"repair {weapon_id} (restored)"
@@ -5623,39 +5679,66 @@ 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.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),),
# PF1e (SRD Sunder): "you can attempt to sunder an item held or worn
# by your opponent" — a natural weapon is not an item, so only
# manufactured melee weapons can be targeted.
weapon = next(
(
w
for w in target.usable_attacks()
if w.kind == "melee" and not w.natural
),
rounds_left=1,
source="maneuver: sunder",
None,
)
)
self._log(
prefix = (
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)"
f"CMD {m.target_cmd}"
)
if weapon is None:
self._log(f"{prefix} -> SUCCESS (no weapon to damage)")
return
# PF1e (SRD Sunder): "If your attack is successful, you deal damage
# to the item normally" — the attacker's melee weapon damage (physical
# components only; objects are immune to energy per CRB "Smashing an
# Object" energy rules, which are out of scope). A broken attacker
# weapon deals -2 (CRB "Broken").
striker = next((w for w in state.usable_attacks() if w.kind == "melee"), None)
raw = 0
if striker is not None:
raw = sum(
comp.formula.roll(self._rng)
for comp in striker.damage
if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
) + striker.damage_bonus
if striker.id in state.weapon_broken:
raw += _BROKEN_WEAPON_PENALTY
# PF1e (CRB "Smashing an Object"): "subtract its hardness from the
# damage. Only damage in excess of its hardness is deducted from the
# object's hit points."
hardness, max_hp = weapon.object_stats()
dealt = max(0, raw - hardness)
accumulated = target.weapon_damage.get(weapon.id, 0) + dealt
target.weapon_damage[weapon.id] = accumulated
hp_left = max_hp - accumulated
if hp_left <= 0:
# SRD Sunder: "If the damage you deal would reduce the object to
# less than 0 hit points, you can choose to destroy it" — the
# engine always takes the destructive option (no choice payload
# on Action; documented simplification).
target.weapon_broken.discard(weapon.id)
target.weapon_destroyed.add(weapon.id)
self._log(f"{prefix} -> SUCCESS (weapon destroyed)")
return
if hp_left * 2 <= max_hp:
# SRD Sunder: "If an object has equal to or less than half its
# total hit points remaining, it gains the broken condition."
target.weapon_broken.add(weapon.id)
self._log(
f"{prefix} -> SUCCESS ({dealt} damage to weapon, broken)"
)
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} "
f"CMD {m.target_cmd} -> SUCCESS ({dmg} damage to weapon)"
)
self._log(f"{prefix} -> SUCCESS ({dealt} damage to weapon)")
def _nearest_anchor_adjacent_to(
self, grappler: CombatantState, target: CombatantState
+40
View File
@@ -232,6 +232,11 @@ class DamageComponent(_FrozenModel):
return value
_HAFTED_WEAPON_TOKENS: tuple[str, ...] = (
"axe", "spear", "hammer", "mace", "flail", "staff", "pick", "javelin",
)
class AttackSpec(_FrozenModel):
"""One attack menu entry (weapon or natural attack).
@@ -254,6 +259,13 @@ class AttackSpec(_FrozenModel):
weapons. `secondary` marks a secondary natural attack (tail, wing, etc.):
the engine applies -5 to the attack bonus (reduced to -2 with the
Multiattack feat).
Object statistics (CRB "Breaking and Entering", Table 7-12): `hardness`
and `hit_points` are authorable overrides; when absent the engine
derives them from the weapon class via `object_stats()`. `enhancement`
is the weapon's enhancement bonus (0 = masterwork or plain); each +1
adds +2 hardness and +10 hit points (CRB "Magic Armor, Shields, and
Weapons").
"""
id: str
@@ -272,6 +284,34 @@ class AttackSpec(_FrozenModel):
two_handed: bool = False
natural: bool = False
secondary: bool = False
hardness: int | None = Field(default=None, ge=0)
hit_points: int | None = Field(default=None, ge=1)
enhancement: int = Field(default=0, ge=0, le=20)
def object_stats(self) -> tuple[int, int]:
"""Hardness and hit points of the weapon as an OBJECT (Medium item).
Defaults from CRB Table 7-12 "Common Armor, Weapon, and Shield
Hardness and Hit Points" when not authored: projectile weapons 5/5;
hafted weapons (wooden haft: axe, spear, hammer, mace, flail,
staff, pick, javelin) hardness 5, 5 hp one-handed / 10 hp
two-handed; blades steel hardness 10, 5 hp one-handed / 10 hp
two-handed. The light/one-handed granularity is collapsed to
one-handed (the model has no weapon-weight field). Enhancement
adds +2 hardness and +10 hp per +1 (footnotes 1 and 3).
"""
if self.kind == "ranged" or self.firearm:
base_hardness, base_hp = 5, 5
elif any(token in self.name.lower() for token in _HAFTED_WEAPON_TOKENS):
base_hardness, base_hp = 5, (10 if self.two_handed else 5)
else:
base_hardness, base_hp = 10, (10 if self.two_handed else 5)
hardness = self.hardness if self.hardness is not None else base_hardness
hit_points = self.hit_points if self.hit_points is not None else base_hp
return (
hardness + 2 * self.enhancement,
hit_points + 10 * self.enhancement,
)
class DamageReduction(_FrozenModel):
+159 -18
View File
@@ -1,8 +1,11 @@
"""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.
A successful sunder damages the defender's WEAPON as an OBJECT (SRD Sunder /
CRB "Smashing an Object"): the attacker's weapon damage, less the object's
Hardness, is subtracted from its hit points. At half hp or less the weapon
gains the broken condition (still usable at -2 attack/damage, crits only on
a natural 20 at x2 - CRB "Broken"); at 0 hp or less it is destroyed and
unusable.
"""
from __future__ import annotations
@@ -53,6 +56,24 @@ def make_combatant(
)
def make_object_combatant(
cid: str, *, hardness: int, hit_points: int, attack_bonus: int = 2
) -> Combatant:
"""Combatant whose sword carries authored object stats (Table 7-12
overrides) so sunder outcomes are scripted exactly."""
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
hardness=hardness,
hit_points=hit_points,
)
base = make_combatant(cid, attack_bonus=attack_bonus)
return base.model_copy(update={"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)
@@ -72,32 +93,152 @@ def make_engine(
return CombatEngine(ScriptedRng(queue), make_grid(), states)
def test_object_stats_default_from_table_7_12() -> None:
# CRB Table 7-12 defaults by weapon class (Medium, unenhanced): steel
# blade 10/5 (one-handed) and 10/10 (two-handed); wooden hafted 5/5 and
# 5/10; projectile weapon 5/5. Enhancement: +2 hardness and +10 hp per
# +1 (footnotes 1 and 3).
def _spec(**kw: object) -> AttackSpec:
base: dict[str, object] = {
"id": "w", "name": "sword", "kind": "melee", "attack_bonus": 0,
"damage": [DamageComponent(formula="1d8", types=["slashing"])],
}
base.update(kw)
return AttackSpec(**base)
assert _spec().object_stats() == (10, 5)
assert _spec(two_handed=True).object_stats() == (10, 10)
assert _spec(name="waraxe").object_stats() == (5, 5)
assert _spec(name="waraxe", two_handed=True).object_stats() == (5, 10)
assert _spec(name="short bow", kind="ranged").object_stats() == (5, 5)
assert _spec(enhancement=2).object_stats() == (14, 25)
# Authored overrides replace the class default.
assert _spec(hardness=0, hit_points=4).object_stats() == (0, 4)
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))
# SRD Sunder / CRB "Smashing an Object": the sunder deals the attacker's
# weapon damage (1d4 scripted to 4), reduced by the object's Hardness
# (authored 2) -> 2 damage to the weapon's hit points, never to the
# defender's HP.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(
make_object_combatant("b", hardness=2, hit_points=10), "monsters", (1, 2)
)
hp_before = b.hp
engine = make_engine([1, 12], [a, b])
engine = make_engine([1, 12, 4], [a, b]) # AoO miss, CMB 12, 1d4 -> 4
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
assert "b-w" not in b.weapon_destroyed
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))
# Only the first sunder provokes: b's per-round AoO allowance is spent.
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b])
for _ in range(5):
def test_sunder_hardness_absorbs_small_damage() -> None:
# A plain steel blade is hardness 10 (Table 7-12): a 1d4 (max 4) sunder
# cannot scratch it — 0 damage past hardness.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12, 4], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.weapon_damage["b-w"] == 0
assert "b-w" not in b.weapon_broken
def test_sunder_breaks_weapon_at_half_hit_points() -> None:
# SRD Sunder: "If an object has equal to or less than half its total hit
# points remaining, it gains the broken condition." hardness 0, 10 hp;
# 1d4+3 scripted to 4+3=7 -> 3 hp left <= 5 -> broken. The broken weapon
# STAYS usable (CRB "Broken").
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"attacks": [
AttackSpec(
id="a-w", name="sword", kind="melee", attack_bonus=2,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
damage_bonus=3,
)
]}
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(
make_object_combatant("b", hardness=0, hit_points=10), "monsters", (1, 2)
)
engine = make_engine([1, 12, 4], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
assert "b-w" in b.weapon_broken
assert "b-w" not in b.weapon_destroyed
assert [w.id for w in b.usable_attacks()] == ["b-w"]
assert engine.weapon_for(b, a) is not None
assert "broken" in engine._transcript[-1]
def test_sunder_destroys_weapon_at_zero_hit_points() -> None:
# CRB "Smashing an Object": "When an object's hit points reach 0, it's
# ruined." Two sunders of 7 (hardness 0, 10 hp): broken after the first,
# destroyed after the second — then unusable.
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"attacks": [
AttackSpec(
id="a-w", name="sword", kind="melee", attack_bonus=2,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
damage_bonus=3,
)
]}
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(
make_object_combatant("b", hardness=0, hit_points=10), "monsters", (1, 2)
)
engine = make_engine([1, 12, 4, 12, 4], [a, b])
engine._execute_maneuver(a, b, "sunder") # first: AoO provokes (nat 1)
assert "b-w" in b.weapon_broken
engine._execute_maneuver(a, b, "sunder") # b's AoO allowance is spent
assert "b-w" in b.weapon_destroyed
assert "b-w" not in b.weapon_broken
assert [w.id for w in b.usable_attacks()] == []
assert engine.weapon_for(b, a) is None
assert any(c.condition.name == "disarmed" for c in b.active_conditions)
assert "weapon destroyed" in engine._transcript[-1]
def test_broken_weapon_still_attacks_at_minus_two() -> None:
# CRB "Broken": "any attacks made with the item suffer a -2 penalty on
# attack and damage rolls."
b = make_state(make_combatant("b"), "monsters", (1, 2))
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
engine = make_engine([10], [a, b])
result = engine.resolve_attack(a, b, a.combatant.attacks[0])
assert result.total == 10 + 2 # intact baseline
b.weapon_broken.add("b-w")
engine2 = make_engine([10], [a, b])
result2 = engine2.resolve_attack(b, a, b.combatant.attacks[0])
assert result2.total == 10 + 2 - 2
assert result2.hit is False # 10 vs AC 13: the -2 turns the hit into a miss
def test_broken_weapon_crits_only_on_natural_twenty_at_x2() -> None:
# CRB "Broken": "Such weapons only score a critical hit on a natural 20
# and only deal x2 damage on a confirmed critical hit." The sword
# threatens 19-20 at x3 when intact.
sharp = AttackSpec(
id="c-w", name="sword", kind="melee", attack_bonus=2,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
crit_range=19, crit_mult=3,
)
c = make_state(
make_combatant("c").model_copy(update={"attacks": [sharp]}), "players", (1, 1)
)
d = make_state(make_combatant("d"), "monsters", (1, 2))
c.weapon_broken.add("c-w")
# Natural 19: no threat at all with a broken weapon (no confirm roll).
engine = make_engine([19, 4], [c, d])
result = engine.resolve_attack(c, d, sharp)
assert result.crit is False
# Natural 20: threatens, auto-confirms (natural 20), x2 (not x3):
# (1d4=4 - 2 broken) * 2 = 4.
engine = make_engine([20, 20, 4], [c, d])
result = engine.resolve_attack(c, d, sharp)
assert result.crit is True
assert result.damage == 4
def test_escape_from_grapple_consumes_single_d20() -> None:
+59 -36
View File
@@ -3,9 +3,9 @@
L8 of the ``llm-policy-contract`` plan: ``Action`` gains a ``repair_weapon``
kind (reusing the existing ``weapon_id`` field) so a policy can restore a
weapon that a sunder maneuver broke. The weapon is removed from
``state.weapon_broken``, its accumulated damage is reset, and the transient
``disarmed`` condition originating from the sunder is removed so the repaired
weapon is fully usable again via ``usable_attacks()``.
``state.weapon_broken``, its accumulated damage is reset, and the weapon is
fully usable again via ``usable_attacks()``. A weapon DESTROYED by a sunder
(hp <= 0) cannot be repaired.
No default policy emits ``repair_weapon``; it is only accepted by ``_execute``
(usage via custom policy / LLM).
@@ -14,8 +14,6 @@ No default policy emits ``repair_weapon``; it is only accepted by ``_execute``
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import Condition
from pf1e_simulator.effects import ActiveCondition, StatModifier
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
@@ -56,6 +54,37 @@ def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
)
def _soft_sword_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
"""Combatant with a authored soft sword (hardness 0, 10 hp) so one
scripted 1d4+3 sunder breaks it."""
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
hardness=0,
hit_points=10,
)
return make_combatant(cid, attack_bonus=attack_bonus).model_copy(
update={"attacks": [attack]}
)
def _heavy_striker(cid: str) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
damage_bonus=3,
)
return make_combatant(cid, attack_bonus=2).model_copy(
update={"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)
@@ -74,17 +103,6 @@ def _weapon_ids(state: CombatantState) -> list[str]:
return [w.id for w in state.usable_attacks()]
def _sunder_disarmed_condition() -> ActiveCondition:
return ActiveCondition(
condition=Condition(
name="disarmed",
modifiers=(StatModifier(target="attack", value=-4),),
),
rounds_left=1,
source="maneuver: sunder",
)
# --- Test 1: repair restores a broken weapon ---------------------------------
def test_repair_restores_broken_weapon() -> None:
@@ -94,16 +112,11 @@ def test_repair_restores_broken_weapon() -> None:
b = make_state(b_spec, "monsters", (1, 2))
b.weapon_damage = {"b-w": 10}
b.weapon_broken = {"b-w"}
b.active_conditions.append(_sunder_disarmed_condition())
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken
assert not b.weapon_damage
assert "b-w" in _weapon_ids(b)
assert not any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
assert "repair b-w (restored)" in engine._transcript[-1]
@@ -140,30 +153,40 @@ def test_repair_unknown_id_logs_nothing_to_repair() -> None:
# --- Test 4: real sunder then repair restores the weapon ---------------------
def test_sunder_then_repair_restores_weapon() -> 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([1] + [12] * 5, [a, b])
for _ in range(5):
# a's 1d4+3 (scripted 4+3=7) vs b's soft sword (hardness 0, 10 hp):
# 3 hp left <= half -> broken (still usable at -2, CRB "Broken"); the
# repair action resets the damage and clears the condition.
a = make_state(_heavy_striker("a"), "players", (1, 1))
b = make_state(_soft_sword_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12, 4], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken
assert "b-w" not in _weapon_ids(b)
assert any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
assert "b-w" in _weapon_ids(b)
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken
assert not b.weapon_damage
assert "b-w" in _weapon_ids(b)
assert not any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
assert "repair b-w (restored)" in engine._transcript[-1]
# --- Test 4b: a destroyed weapon cannot be repaired --------------------------
def test_repair_destroyed_weapon_is_refused() -> None:
# CRB "Smashing an Object": hp <= 0 is "ruined" — repair_weapon is a
# no-op on destroyed weapons.
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))
b.weapon_damage = {"b-w": 10}
b.weapon_destroyed.add("b-w")
engine = make_engine([], [a, b])
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert "b-w" in b.weapon_destroyed
assert b.weapon_damage == {"b-w": 10}
assert "weapon destroyed" in engine._transcript[-1]
# --- Test 5: determinism -----------------------------------------------------
def _run_with_repair() -> tuple[str, ...]:
+27 -9
View File
@@ -2,10 +2,12 @@
``attack`` and ``full_attack`` accept ``Action.weapon_id`` — the id of one of
the attacker's weapons. With it the engine strikes with THAT weapon instead of
the ``weapon_for`` auto-selection; a choice that is unknown, broken, stolen,
or unable to strike the target (out of reach / no line of effect) is rejected
with ``weapon_unavailable`` and no attack is made. Without it, the
auto-selection and the full-attack mechanics are byte-identical to before.
the ``weapon_for`` auto-selection; a choice that is unknown, destroyed,
stolen, dropped, or unable to strike the target (out of reach / no line of
effect) is rejected with ``weapon_unavailable`` and no attack is made (a
BROKEN melee weapon stays selectable — CRB "Broken" makes it usable at -2).
Without it, the auto-selection and the full-attack mechanics are
byte-identical to before.
"""
from __future__ import annotations
@@ -133,15 +135,31 @@ def test_full_attack_without_choice_still_swings_all_melee_weapons() -> None:
assert "shortsword vs foe d20=10+2=12" in joined
# ── (b) broken weapon → weapon_unavailable ───────────────────────────────────
# ── (b) broken / destroyed weapon selection ──────────────────────────────────
def test_attack_with_broken_weapon_is_rejected() -> None:
eng, hero, foe = _duel([]) # empty script: any roll would raise
def test_attack_with_broken_weapon_is_usable() -> None:
# CRB "Broken": a broken weapon is still usable — at -2 on attack and
# damage. The explicit choice is accepted: d20 10 + 2 - 2 = 10 vs AC 10
# -> HIT for 3 - 2 = 1 damage.
eng, hero, foe = _duel([10, 3])
hero.weapon_broken.add("hero-short")
result = eng._execute(
hero, Action(kind="attack", target_id="foe", weapon_id="hero-short")
)
assert result == ActionResult(kind="attack", ok=True, reason="ok")
assert "shortsword vs foe d20=10+2=10 AC 10 -> HIT 1 damage" in eng._transcript[-1]
assert foe.hp == 99
def test_attack_with_destroyed_weapon_is_rejected() -> None:
# A destroyed weapon (hp <= 0, CRB "Smashing an Object": "ruined") is
# unusable: the explicit choice is rejected, no roll consumed.
eng, hero, foe = _duel([]) # empty script: any roll would raise
hero.weapon_destroyed.add("hero-short")
result = eng._execute(
hero, Action(kind="attack", target_id="foe", weapon_id="hero-short")
)
assert result == ActionResult(
kind="attack", ok=False, reason="weapon_unavailable"
)
@@ -149,9 +167,9 @@ def test_attack_with_broken_weapon_is_rejected() -> None:
assert foe.hp == 100
def test_full_attack_with_broken_weapon_is_rejected() -> None:
def test_full_attack_with_destroyed_weapon_is_rejected() -> None:
eng, hero, foe = _duel([])
hero.weapon_broken.add("hero-short")
hero.weapon_destroyed.add("hero-short")
result = eng._execute(
hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short")
)