diff --git a/README.md b/README.md index 9ea1801..ca8dbed 100644 --- a/README.md +++ b/README.md @@ -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 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` - (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`. - Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie 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) s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, `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 à 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 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 - dégâts, X = BAB//4 + 1, mêlée uniquement, declared as free action at turn - start via `state.power_attack`). Les modificateurs sont collectés par + dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le + 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 `features` de `Combatant` contient les aptitudes permanentes ; le chargeur (`loaders/combatant.py`) résout les noms de dons des fiches vers des diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 56df17a..1b7b4ff 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -489,6 +489,7 @@ class Action: "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon", + "activate_power_attack", ] target_id: str | None = None spell_name: str | None = None @@ -1373,12 +1374,40 @@ class CombatEngine: damage *= weapon.crit_mult damage = self._apply_dr(damage, weapon, target) target.hp -= damage + damage = self._consume_up_close_and_deadly( + attacker, target, weapon, dist_ft, damage, hit=hit + ) 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, ) + 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: dr: DamageReduction | None = target.combatant.dr if dr is None: @@ -1990,6 +2019,8 @@ class CombatEngine: self._move(state, target) elif kind == "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( self, state: CombatantState, target: CombatantState | None, action: Action @@ -2011,6 +2042,20 @@ class CombatEngine: elif target is not None: 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: """Offer an event to the reaction policy; execute the first immediate. diff --git a/tests/test_engine_fixes.py b/tests/test_engine_fixes.py new file mode 100644 index 0000000..5653553 --- /dev/null +++ b/tests/test_engine_fixes.py @@ -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