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 naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural
20 while attempting a combat maneuver is always a success (except when 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 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` naturel échoue toujours. Un sunder endommage l'**arme** comme un OBJET, pas
de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) → les PV (SRD « Sunder » / CRB « Smashing an Object ») : les dégâts réels de
`disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via l'arme de l'attaquant (composantes physiques + bonus plat ; une arme brisée
`Action(kind="repair_weapon")`. overrun → 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 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 **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, « 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`, feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`,
journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la
progression de la valeur de misfire et l'explosion des early firearms 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 `inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
complet, incante un sort standard et porte toutes ses attaques de mêlée à 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 **−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) : state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), 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 name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, 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 fraîche), casting_ability ou None pour non-jeteur
(E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills (E3), abilities {str/dex/con/int/wis/cha: score brut} (Medium), skills
{nom: {rank, class_skill, armor_penalty}} uniquement rank > 0 (Medium), {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 summon_duration (int ou None), immediate_used, deed_up_close_and_deadly
(flags actuels), active_buffs {source, rounds_left, mods: tuple de (flags actuels), active_buffs {source, rounds_left, mods: tuple de
{target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft, {target, value, bonus_type}} (E5), speed_land_ft, speed_fly_ft,
@@ -1710,8 +1727,9 @@ Règles modélisées :
en pieds), ac {total, en pieds), ac {total,
touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks touch, flat_footed}, saves {fort, ref, will}, cmb, cmd, attacks
(référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus, (référentiel complet : id, name, kind, attack_bonus, damage, damage_bonus,
crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm — crit_range, crit_mult, reach_ft, range_increment_ft, count, firearm,
y compris les armes broken/stolen), features (noms des capacités), 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: feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}), tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft, 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 _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 _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. _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_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)
@@ -840,10 +841,16 @@ class CombatantState:
swift_used_this_turn: bool = False swift_used_this_turn: 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 # Runtime weapon durability (not in the frozen data model): damage dealt
# sunder damage per weapon id, and the set of weapon ids rendered unusable. # 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_damage: dict[str, int] = field(default_factory=dict)
weapon_broken: set[str] = field(default_factory=set) 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 # Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers. # steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set) weapon_stolen: set[str] = field(default_factory=set)
@@ -924,12 +931,23 @@ class CombatantState:
held_charge: HeldCharge | None = None held_charge: HeldCharge | None = None
def usable_attacks(self) -> list[AttackSpec]: 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 [ return [
w w
for w in self.combatant.attacks 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_stolen
and w.id not in self.weapon_dropped 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]: def occupied_cells(self) -> frozenset[Pos]:
@@ -1634,6 +1652,7 @@ class CombatEngine:
"conditions": conditions, "conditions": conditions,
"resources_left": resources, "resources_left": resources,
"weapon_broken": tuple(sorted(state.weapon_broken)), "weapon_broken": tuple(sorted(state.weapon_broken)),
"weapon_destroyed": tuple(sorted(state.weapon_destroyed)),
"weapon_stolen": tuple(sorted(state.weapon_stolen)), "weapon_stolen": tuple(sorted(state.weapon_stolen)),
"weapon_dropped": tuple(sorted(state.weapon_dropped)), "weapon_dropped": tuple(sorted(state.weapon_dropped)),
"grappled_with": state.grappled_with, "grappled_with": state.grappled_with,
@@ -1761,6 +1780,8 @@ class CombatEngine:
"range_increment_ft": attack.range_increment_ft, "range_increment_ft": attack.range_increment_ft,
"count": attack.count, "count": attack.count,
"firearm": attack.firearm, "firearm": attack.firearm,
"hardness": attack.object_stats()[0],
"hit_points": attack.object_stats()[1],
} }
for attack in state.combatant.attacks for attack in state.combatant.attacks
), ),
@@ -3112,6 +3133,11 @@ class CombatEngine:
else 0 else 0
) )
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus 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 = ( fim_penalty = (
self._firing_into_melee_penalty(attacker, target) self._firing_into_melee_penalty(attacker, target)
if self._is_ranged_attack(weapon) if self._is_ranged_attack(weapon)
@@ -3149,6 +3175,7 @@ class CombatEngine:
total = ( total = (
roll + base_bonus + penalty + flank + high_ground + attack_roll_mod - pa_amt roll + base_bonus + penalty + flank + high_ground + attack_roll_mod - pa_amt
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus + 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 # 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 # 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._running_denies_dex(target)
or self._has_condition_named(attacker, "invisible") 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 = ( full_ac_base = (
target.combatant.ac.flat_footed target.combatant.ac.flat_footed
if denies_dex if denies_dex
@@ -3193,43 +3269,7 @@ class CombatEngine:
ac += self._attack_cover_bonus( ac += self._attack_cover_bonus(
attacker, target, ranged=self._is_ranged_attack(weapon) attacker, target, ranged=self._is_ranged_attack(weapon)
) )
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac) return 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,
)
def _resolve_hit_damage( def _resolve_hit_damage(
self, self,
@@ -3248,6 +3288,7 @@ class CombatEngine:
fim_penalty: int, fim_penalty: int,
dist_ft: int, dist_ft: int,
denies_dex: bool, denies_dex: bool,
broken: bool = False,
) -> tuple[bool, bool, int]: ) -> tuple[bool, bool, int]:
"""Resolve a successful attack roll: crit confirm, concealment miss """Resolve a successful attack roll: crit confirm, concealment miss
chance, sneak attack dice, then damage (PF1e order). chance, sneak attack dice, then damage (PF1e order).
@@ -3259,8 +3300,15 @@ class CombatEngine:
the confirmation roll. the confirmation roll.
""" """
crit = False crit = False
threat_range = weapon.crit_range # CRB "Broken": a broken weapon "only score[s] a critical hit on a
if self._has_feat(attacker, _IMPROVED_CRITICAL_FEATURE): # 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) threat_range = _CRIT_RANGE_BASE - 2 * (_CRIT_RANGE_BASE - weapon.crit_range)
if roll != _NATURAL_ONE and roll >= threat_range: if roll != _NATURAL_ONE and roll >= threat_range:
confirm = self._rng.d20() confirm = self._rng.d20()
@@ -3268,6 +3316,7 @@ class CombatEngine:
confirm + base_bonus + penalty + flank + high_ground confirm + base_bonus + penalty + flank + high_ground
+ attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus + attack_mod - pa_amt + fim_penalty + attacker.aid_attack_bonus
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0) + (_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 # PF1e (CRB): the confirmation roll is an attack roll — a natural
# 20 on it is an automatic hit (auto-confirm), a natural 1 miss. # 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 energy_dice = sum(rolls) - physical_dice
damage = physical_dice + weapon.damage_bonus damage = physical_dice + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods) 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 # PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
# bonus increased by half) with a two-handed weapon, a one-handed # 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 # weapon used in two hands, or a primary natural with 1.5x Strength
@@ -3334,7 +3387,9 @@ class CombatEngine:
if weapon.kind == "melee": if weapon.kind == "melee":
damage += pa_amt * (3 if weapon.two_handed else 2) damage += pa_amt * (3 if weapon.two_handed else 2)
if crit: 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 damage += sneak_total + energy_dice
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ... # PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
# energy damage dealt along with an attack" — DR reduces only the # 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. """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 No-op (with a log line) when ``weapon_id`` is None or the weapon was never
damaged. Also removes the transient ``disarmed`` condition originating damaged. A DESTROYED weapon (hp <= 0, "ruined" per CRB "Smashing an
from the sunder that broke the weapon, so the repaired weapon is fully Object") cannot be repaired by this action.
usable again.
""" """
if weapon_id is None: if weapon_id is None:
self._log( self._log(
@@ -5427,6 +5481,12 @@ class CombatEngine:
f"repair: nothing to repair" f"repair: nothing to repair"
) )
return 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: if weapon_id not in state.weapon_damage:
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -5435,10 +5495,6 @@ class CombatEngine:
return return
state.weapon_damage.pop(weapon_id, None) state.weapon_damage.pop(weapon_id, None)
state.weapon_broken.discard(weapon_id) 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( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"repair {weapon_id} (restored)" f"repair {weapon_id} (restored)"
@@ -5623,39 +5679,66 @@ 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) # PF1e (SRD Sunder): "you can attempt to sunder an item held or worn
if weapon is None: # by your opponent" — a natural weapon is not an item, so only
self._log( # manufactured melee weapons can be targeted.
f"round {self._current_round} {state.combatant.id}: sunder vs " weapon = next(
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " (
f"CMD {m.target_cmd} -> SUCCESS (no weapon to damage)" w
) for w in target.usable_attacks()
return if w.kind == "melee" and not w.natural
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, None,
source="maneuver: sunder",
) )
) prefix = (
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} "
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 return
self._log( self._log(f"{prefix} -> SUCCESS ({dealt} damage to weapon)")
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)"
)
def _nearest_anchor_adjacent_to( def _nearest_anchor_adjacent_to(
self, grappler: CombatantState, target: CombatantState self, grappler: CombatantState, target: CombatantState
+40
View File
@@ -232,6 +232,11 @@ class DamageComponent(_FrozenModel):
return value return value
_HAFTED_WEAPON_TOKENS: tuple[str, ...] = (
"axe", "spear", "hammer", "mace", "flail", "staff", "pick", "javelin",
)
class AttackSpec(_FrozenModel): class AttackSpec(_FrozenModel):
"""One attack menu entry (weapon or natural attack). """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.): weapons. `secondary` marks a secondary natural attack (tail, wing, etc.):
the engine applies -5 to the attack bonus (reduced to -2 with the the engine applies -5 to the attack bonus (reduced to -2 with the
Multiattack feat). 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 id: str
@@ -272,6 +284,34 @@ class AttackSpec(_FrozenModel):
two_handed: bool = False two_handed: bool = False
natural: bool = False natural: bool = False
secondary: 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): class DamageReduction(_FrozenModel):
+159 -18
View File
@@ -1,8 +1,11 @@
"""Tests for combat maneuvers, focused on the sunder result. """Tests for combat maneuvers, focused on the sunder result.
A successful sunder damages the defender's WEAPON, never its HP. Repeated A successful sunder damages the defender's WEAPON as an OBJECT (SRD Sunder /
sunders can destroy the weapon, after which the defender can no longer use CRB "Smashing an Object"): the attacker's weapon damage, less the object's
it (effectively disarmed). See the plan task-12. 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 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: def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) 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) 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: def test_sunder_damages_weapon_not_hp() -> None:
a_spec = make_combatant("a", attack_bonus=2) # SRD Sunder / CRB "Smashing an Object": the sunder deals the attacker's
b_spec = make_combatant("b") # weapon damage (1d4 scripted to 4), reduced by the object's Hardness
a = make_state(a_spec, "players", (1, 1)) # (authored 2) -> 2 damage to the weapon's hit points, never to the
b = make_state(b_spec, "monsters", (1, 2)) # 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 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") engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2 assert b.weapon_damage["b-w"] == 2
assert "b-w" not in b.weapon_broken assert "b-w" not in b.weapon_broken
assert "b-w" not in b.weapon_destroyed
def test_sunder_destroys_weapon_and_disarms() -> None: def test_sunder_hardness_absorbs_small_damage() -> None:
a_spec = make_combatant("a", attack_bonus=2) # A plain steel blade is hardness 10 (Table 7-12): a 1d4 (max 4) sunder
b_spec = make_combatant("b") # cannot scratch it — 0 damage past hardness.
a = make_state(a_spec, "players", (1, 1)) a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2)) b = make_state(make_combatant("b"), "monsters", (1, 2))
# Only the first sunder provokes: b's per-round AoO allowance is spent. engine = make_engine([1, 12, 4], [a, b])
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b]) engine._execute_maneuver(a, b, "sunder")
for _ in range(5): 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") engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
assert "b-w" in b.weapon_broken 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 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: 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`` 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 kind (reusing the existing ``weapon_id`` field) so a policy can restore a
weapon that a sunder maneuver broke. The weapon is removed from weapon that a sunder maneuver broke. The weapon is removed from
``state.weapon_broken``, its accumulated damage is reset, and the transient ``state.weapon_broken``, its accumulated damage is reset, and the weapon is
``disarmed`` condition originating from the sunder is removed so the repaired fully usable again via ``usable_attacks()``. A weapon DESTROYED by a sunder
weapon is fully usable again via ``usable_attacks()``. (hp <= 0) cannot be repaired.
No default policy emits ``repair_weapon``; it is only accepted by ``_execute`` No default policy emits ``repair_weapon``; it is only accepted by ``_execute``
(usage via custom policy / LLM). (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 __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine 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.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import ( 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: def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) 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()] 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 --------------------------------- # --- Test 1: repair restores a broken weapon ---------------------------------
def test_repair_restores_broken_weapon() -> None: 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 = make_state(b_spec, "monsters", (1, 2))
b.weapon_damage = {"b-w": 10} b.weapon_damage = {"b-w": 10}
b.weapon_broken = {"b-w"} b.weapon_broken = {"b-w"}
b.active_conditions.append(_sunder_disarmed_condition())
engine = make_engine([], [a, b]) engine = make_engine([], [a, b])
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w")) engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken assert not b.weapon_broken
assert not b.weapon_damage assert not b.weapon_damage
assert "b-w" in _weapon_ids(b) 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] 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 --------------------- # --- Test 4: real sunder then repair restores the weapon ---------------------
def test_sunder_then_repair_restores_weapon() -> None: def test_sunder_then_repair_restores_weapon() -> None:
a_spec = make_combatant("a", attack_bonus=2) # a's 1d4+3 (scripted 4+3=7) vs b's soft sword (hardness 0, 10 hp):
b_spec = make_combatant("b") # 3 hp left <= half -> broken (still usable at -2, CRB "Broken"); the
a = make_state(a_spec, "players", (1, 1)) # repair action resets the damage and clears the condition.
b = make_state(b_spec, "monsters", (1, 2)) a = make_state(_heavy_striker("a"), "players", (1, 1))
engine = make_engine([1] + [12] * 5, [a, b]) b = make_state(_soft_sword_combatant("b"), "monsters", (1, 2))
for _ in range(5): engine = make_engine([1, 12, 4], [a, b])
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder")) engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="sunder"))
assert "b-w" in b.weapon_broken assert "b-w" in b.weapon_broken
assert "b-w" not in _weapon_ids(b) assert "b-w" in _weapon_ids(b)
assert any(
c.condition.name == "disarmed" and c.source == "maneuver: sunder"
for c in b.active_conditions
)
engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w")) engine._execute(b, Action(kind="repair_weapon", weapon_id="b-w"))
assert not b.weapon_broken assert not b.weapon_broken
assert not b.weapon_damage assert not b.weapon_damage
assert "b-w" in _weapon_ids(b) 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] 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 ----------------------------------------------------- # --- Test 5: determinism -----------------------------------------------------
def _run_with_repair() -> tuple[str, ...]: 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 ``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 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, the ``weapon_for`` auto-selection; a choice that is unknown, destroyed,
or unable to strike the target (out of reach / no line of effect) is rejected stolen, dropped, or unable to strike the target (out of reach / no line of
with ``weapon_unavailable`` and no attack is made. Without it, the effect) is rejected with ``weapon_unavailable`` and no attack is made (a
auto-selection and the full-attack mechanics are byte-identical to before. 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 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 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: def test_attack_with_broken_weapon_is_usable() -> None:
eng, hero, foe = _duel([]) # empty script: any roll would raise # 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") hero.weapon_broken.add("hero-short")
result = eng._execute( result = eng._execute(
hero, Action(kind="attack", target_id="foe", weapon_id="hero-short") 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( assert result == ActionResult(
kind="attack", ok=False, reason="weapon_unavailable" kind="attack", ok=False, reason="weapon_unavailable"
) )
@@ -149,9 +167,9 @@ def test_attack_with_broken_weapon_is_rejected() -> None:
assert foe.hp == 100 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([]) eng, hero, foe = _duel([])
hero.weapon_broken.add("hero-short") hero.weapon_destroyed.add("hero-short")
result = eng._execute( result = eng._execute(
hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short") hero, Action(kind="full_attack", target_id="foe", weapon_id="hero-short")
) )