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:
@@ -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,
|
||||||
@@ -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
|
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
|
(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`.
|
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
|
Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When
|
||||||
a firearm misfires, it gains the broken condition ») : une attaque d'arme à
|
a firearm misfires, it gains the broken condition ») : une attaque d'arme à
|
||||||
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,
|
||||||
|
|||||||
+164
-81
@@ -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
|
None,
|
||||||
if target.weapon_damage[weapon.id] >= _WEAPON_DURABILITY:
|
)
|
||||||
target.weapon_broken.add(weapon.id)
|
prefix = (
|
||||||
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(
|
|
||||||
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 ({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(
|
def _nearest_anchor_adjacent_to(
|
||||||
self, grappler: CombatantState, target: CombatantState
|
self, grappler: CombatantState, target: CombatantState
|
||||||
|
|||||||
@@ -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
@@ -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(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))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(
|
||||||
# Only the first sunder provokes: b's per-round AoO allowance is spent.
|
make_object_combatant("b", hardness=0, hit_points=10), "monsters", (1, 2)
|
||||||
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b])
|
)
|
||||||
for _ in range(5):
|
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:
|
||||||
|
|||||||
+60
-37
@@ -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, ...]:
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user