feat(combat): add activate_power_attack action and consume Up Close and Deadly deed

- New Action kind activate_power_attack: free action that sets
  state.power_attack = True when the combatant has the Power Attack feat;
  logged no-op otherwise. Routed via _execute_movement (free-action branch)
  to keep _execute under the C901 complexity ceiling.
- Consume deed_up_close_and_deadly in resolve_attack: the first attack
  resolved while the flag is set gains +1d6 if it is a ranged attack within
  30 ft; the flag is reset in all cases (benefit lost if out of range or
  not ranged). Extracted into _consume_up_close_and_deadly helper.
- Document activate_power_attack, corrected deed semantics, and the
  power_attack snapshot key in README.
- Add tests/test_engine_fixes.py (5 tests): PA activation, no-feat no-op,
  deed+ranged within 30ft, deed out-of-range, deed melee.
This commit is contained in:
2026-09-06 18:37:58 +02:00
parent e29c472a67
commit a9ccdd3a56
3 changed files with 215 additions and 3 deletions
+11 -3
View File
@@ -446,7 +446,10 @@ Règles modélisées :
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty) (tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
`inspire_courage` + `spell_combat` (misty)
— couvertes par `tests/test_class_abilities.py`. — couvertes par `tests/test_class_abilities.py`.
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie - Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
@@ -501,6 +504,10 @@ Règles modélisées :
`terrain_semantics` + positions). Les API de décision exposées (items L1-L8) `terrain_semantics` + positions). Les API de décision exposées (items L1-L8)
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
Le kind `activate_power_attack` (action libre) active le don Power Attack
(`state.power_attack = True`) si le combattant possède le don ; sans le don,
c'est un no-op journalisé. Le snapshot expose l'état courant via la clé
`power_attack` (bool) par combattant.
Non modélisé : la couche `markers` (points nommés de la carte) est validée à Non modélisé : la couche `markers` (points nommés de la carte) est validée à
la lecture mais non appliquée dans la résolution. la lecture mais non appliquée dans la résolution.
@@ -551,8 +558,9 @@ la lecture mais non appliquée dans la résolution.
- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec - `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec
catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé
par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X
dégâts, X = BAB//4 + 1, mêlée uniquement, declared as free action at turn dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le
start via `state.power_attack`). Les modificateurs sont collectés par kind `activate_power_attack` qui pose `state.power_attack`). Les
modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur `features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des (`loaders/combatant.py`) résout les noms de dons des fiches vers des
+45
View File
@@ -489,6 +489,7 @@ class Action:
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
"maneuver", "channel_energy", "hex", "deed", "spell_combat", "maneuver", "channel_energy", "hex", "deed", "spell_combat",
"inspire_courage", "retrieve_weapon", "repair_weapon", "inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack",
] ]
target_id: str | None = None target_id: str | None = None
spell_name: str | None = None spell_name: str | None = None
@@ -1373,12 +1374,40 @@ class CombatEngine:
damage *= weapon.crit_mult damage *= weapon.crit_mult
damage = self._apply_dr(damage, weapon, target) damage = self._apply_dr(damage, weapon, target)
target.hp -= damage target.hp -= damage
damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit
)
return AttackResult( return AttackResult(
hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac, hit=hit, crit=crit, damage=damage, roll=roll, total=total, ac=ac,
penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground, penalty=penalty, flank_bonus=flank, high_ground_bonus=high_ground,
base_bonus=base_bonus, base_bonus=base_bonus,
) )
def _consume_up_close_and_deadly(
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
dist_ft: int,
damage: int,
*,
hit: bool,
) -> int:
"""Consume the Up Close and Deadly deed on the first attack resolved.
The deed applies +1d6 to a ranged attack within 30 ft; the flag is
reset after the first attack in all cases (the benefit is lost if the
attack is out of range or not ranged).
"""
if not attacker.deed_up_close_and_deadly:
return damage
if hit and self._is_ranged_attack(weapon) and dist_ft <= _UP_CLOSE_RANGE_FT:
bonus = self._rng.roll_die(6)
damage += bonus
target.hp -= bonus
attacker.deed_up_close_and_deadly = False
return damage
def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int: def _apply_dr(self, damage: int, weapon: AttackSpec, target: CombatantState) -> int:
dr: DamageReduction | None = target.combatant.dr dr: DamageReduction | None = target.combatant.dr
if dr is None: if dr is None:
@@ -1990,6 +2019,8 @@ class CombatEngine:
self._move(state, target) self._move(state, target)
elif kind == "wait": elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait") self._log(f"round {self._current_round} {state.combatant.id}: wait")
elif kind == "activate_power_attack":
self._execute_activate_power_attack(state)
def _execute_immediate( def _execute_immediate(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
@@ -2011,6 +2042,20 @@ class CombatEngine:
elif target is not None: elif target is not None:
self._execute_attack(state, target, "attack") self._execute_attack(state, target, "attack")
def _execute_activate_power_attack(self, state: CombatantState) -> None:
"""Activate Power Attack as a free action (requires the feat)."""
if not self._has_feat(state, "Power Attack"):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"activate_power_attack: no Power Attack feat"
)
return
state.power_attack = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"Power Attack activated"
)
def _check_reactions(self, event: ReactionEvent) -> None: def _check_reactions(self, event: ReactionEvent) -> None:
"""Offer an event to the reaction policy; execute the first immediate. """Offer an event to the reaction policy; execute the first immediate.
+159
View File
@@ -0,0 +1,159 @@
"""Engine-level tests for the two audit fixes.
Covers:
- ``activate_power_attack`` action kind: sets ``state.power_attack`` as a free
action when the combatant has the Power Attack feat, and is a logged no-op
otherwise.
- ``deed_up_close_and_deadly`` consumption: the +1d6 bonus applies to the
first ranged attack within 30 ft and the flag is reset; the flag is reset
with no bonus when the first attack is out of range or not ranged.
"""
from __future__ import annotations
from pf1e_simulator.abilities import POWER_ATTACK
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def _combatant(
cid: str,
*,
kind: str = "melee",
range_increment_ft: int | None = None,
features: list | None = None,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="weapon",
kind=kind,
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
range_increment_ft=range_increment_ft,
)
return Combatant(
id=cid,
name=cid,
level=4,
size="Medium",
abilities=AbilityScores(
str_score=16, dex_score=12, con_score=14,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=18, touch=12, flat_footed=16),
bab=4,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=6, ref=2, will=2),
attacks=[attack],
features=features or [],
)
def _foe(cid: str = "foe") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=10, flat_footed=13),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=1, will=0),
attacks=[attack],
)
def _engine(
attacker: Combatant, target: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
a = CombatantState(combatant=attacker, side="players", pos=(0, 0), hp=attacker.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=(0, 0), hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(ScriptedRng(queue), grid, [a, t])
return engine, a, t
class TestActivatePowerAttack:
"""Given: a combatant with the Power Attack feat
When: an activate_power_attack action is executed
Then: state.power_attack is set True and the activation is logged."""
def test_sets_flag_and_logs(self) -> None:
attacker = _combatant("fighter", features=[POWER_ATTACK])
engine, a, _ = _engine(attacker, _foe(), [])
engine._execute(a, Action(kind="activate_power_attack"))
assert a.power_attack is True
assert any("Power Attack activated" in line for line in engine._transcript)
def test_without_feat_is_noop_with_warning(self) -> None:
attacker = _combatant("fighter")
engine, a, _ = _engine(attacker, _foe(), [])
engine._execute(a, Action(kind="activate_power_attack"))
assert a.power_attack is False
assert any("no Power Attack feat" in line for line in engine._transcript)
class TestDeedUpCloseAndDeadly:
"""Given: an attacker with deed_up_close_and_deadly set
When: an attack is resolved
Then: the first ranged attack within 30 ft gains +1d6 and the flag resets;
otherwise the flag resets with no bonus."""
def test_ranged_within_30ft_adds_d6_and_resets(self) -> None:
attacker = _combatant("gunner", kind="ranged", range_increment_ft=60)
target = _foe()
engine, a, t = _engine(attacker, target, [15, 4, 3])
a.deed_up_close_and_deadly = True
result = engine.resolve_attack(a, t, attacker.attacks[0])
assert result.hit is True
assert result.damage == 4 + 3 # 1d8(4) + 1d6(3)
assert a.deed_up_close_and_deadly is False
def test_out_of_range_resets_without_bonus(self) -> None:
attacker = _combatant("gunner", kind="ranged", range_increment_ft=60)
target = _foe()
engine, a, t = _engine(attacker, target, [15, 4])
a.deed_up_close_and_deadly = True
t.pos = (10, 0) # 50 ft away, beyond the 30 ft deed range
result = engine.resolve_attack(a, t, attacker.attacks[0])
assert result.hit is True
assert result.damage == 4 # no +1d6
assert a.deed_up_close_and_deadly is False
def test_melee_attack_resets_without_bonus(self) -> None:
attacker = _combatant("fighter")
target = _foe()
engine, a, t = _engine(attacker, target, [15, 4])
a.deed_up_close_and_deadly = True
result = engine.resolve_attack(a, t, attacker.attacks[0])
assert result.hit is True
assert result.damage == 4 # no +1d6 (not ranged)
assert a.deed_up_close_and_deadly is False