From 74f5d64c436a85c0dec14483b4457819ab436158 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 15:43:45 +0200 Subject: [PATCH] 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). --- README.md | 46 +++++-- src/pf1e_simulator/combat.py | 245 ++++++++++++++++++++++----------- src/pf1e_simulator/models.py | 40 ++++++ tests/test_maneuvers.py | 177 +++++++++++++++++++++--- tests/test_repair_weapon.py | 97 ++++++++----- tests/test_weapon_selection.py | 36 +++-- 6 files changed, 482 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index cf028d5..19db01e 100644 --- a/README.md +++ b/README.md @@ -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, @@ -1640,12 +1652,17 @@ Règles modélisées : RAW « as part of a move action to clear all firearms » n'est pas modélisée (un seul firearm par deed), et Quick Clear ne répare que le broken issu d'un misfire — le broken d'un sunder se répare via `repair_weapon`. - Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When - a firearm misfires, it gains the broken condition ») : une attaque d'arme à - 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)), + Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When + a firearm misfires, it gains the broken condition ») : une attaque d'arme à + 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. 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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 5f609b1..3390f85 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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),), - ), - 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( + # 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 + ), + None, + ) + 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 ({dmg} damage to weapon)" + 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"{prefix} -> SUCCESS ({dealt} damage to weapon)") def _nearest_anchor_adjacent_to( self, grappler: CombatantState, target: CombatantState diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 03ab21f..0a44667 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -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): diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 5343e3b..be3f6a3 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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") +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(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): - engine._execute_maneuver(a, b, "sunder") - assert b.hp == 6 + 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-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: diff --git a/tests/test_repair_weapon.py b/tests/test_repair_weapon.py index 0d1c6fe..199f831 100644 --- a/tests/test_repair_weapon.py +++ b/tests/test_repair_weapon.py @@ -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): - engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder")) + # 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, ...]: diff --git a/tests/test_weapon_selection.py b/tests/test_weapon_selection.py index bcb92e4..01b9f4c 100644 --- a/tests/test_weapon_selection.py +++ b/tests/test_weapon_selection.py @@ -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") )