diff --git a/README.md b/README.md index 64f57cb..47d529c 100644 --- a/README.md +++ b/README.md @@ -340,9 +340,9 @@ Non modélisé (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : - Sorts (le système d'effets `effects.py`, les règles d'empilement des - bonus, les jets de sauvegarde `resolve_save` et le système de conditions - `conditions.py` sont en place — l'intégration des sorts au moteur est en - cours). + bonus, les jets de sauvegarde `resolve_save`, le système de conditions + `conditions.py` et les dons passifs `abilities.py` sont en place — + l'intégration des sorts au moteur est en cours). - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft @@ -378,6 +378,12 @@ dans la résolution) : staggered, frightened, panicked, cowering). Les modificateurs sont intégrés à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de sauvegarde) via `_stat_modifiers` qui collecte effets + conditions. +- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec + catégorie feat/class/racial/trait) et dons passifs prédéfinis (Weapon Focus, + Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness, + Point-Blank Shot, Dodge). Les modificateurs sont collectés par + `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ + `features` de `Combatant` contient les aptitudes permanentes. - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée à [0, 1]). - `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone + @@ -393,7 +399,7 @@ dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 276 tests +uv run pytest -q # 293 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -410,7 +416,8 @@ uv run basedpyright src # mode strict conditions, manœuvres de combat, dons et capacités de classe. Flanquement, attaques à outrance, attaques d'opportunité, charge, retraite et pas de placement sont déjà modélisés. Le système d'effets (`effects.py`), les - règles d'empilement des bonus et les jets de sauvegarde (`resolve_save`) + règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le + système de conditions (`conditions.py`) et les dons passifs (`abilities.py`) sont en place. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py new file mode 100644 index 0000000..6d35053 --- /dev/null +++ b/src/pf1e_simulator/abilities.py @@ -0,0 +1,143 @@ +"""PF1e abilities: feats, class features, racial traits, character traits. + +All four categories share the same mechanical representation via ``AbilitySpec``. +What differs is the ``category`` (for stacking and display) and ``source_detail``. + +This step defines passive feats — permanent stat modifiers that the engine +collects alongside effects and conditions via ``_stat_modifiers``. Active feats +(Power Attack, Rage) and conditional feats (Sneak Attack) will be added in +later steps. + +Sources: PRPG Core Rulebook, Feats chapter. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from pf1e_simulator.effects import StatModifier + +Category = Literal["feat", "class", "racial", "trait"] + + +@dataclass(frozen=True) +class AbilitySpec: + """One feat, class feature, racial trait, or character trait. + + ``effects`` are the StatModifiers this ability grants. For passive feats, + these are always-on modifiers. For conditional feats, ``condition`` on + each StatModifier gates applicability (evaluated by the engine). + """ + + name: str + category: Category + source_detail: str = "" + effects: tuple[StatModifier, ...] = () + + +# ── Passive combat feats ───────────────────────────────────────────────────── +# Weapon Focus: +1 attack with a specific weapon (untyped, CRB) +def weapon_focus(weapon_name: str) -> AbilitySpec: + return AbilitySpec( + name=f"Weapon Focus ({weapon_name})", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="attack", value=1, weapon_filter=weapon_name), + ), + ) + + +# Toughness: +1 HP per Hit Die (approximated as +level HP, untyped, CRB) +# For simplicity at this stage, we model it as a flat HP bonus. +# The engine doesn't yet compute per-HD bonuses, so the caller sets the value. +def toughness(level: int) -> AbilitySpec: + return AbilitySpec( + name="Toughness", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="hp", value=level), + ), + ) + + +# Iron Will: +2 will saves (untyped, CRB) +IRON_WILL = AbilitySpec( + name="Iron Will", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="will", value=2), + ), +) + +# Great Fortitude: +2 fortitude saves (untyped, CRB) +GREAT_FORTITUDE = AbilitySpec( + name="Great Fortitude", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="fort", value=2), + ), +) + +# Lightning Reflexes: +2 reflex saves (untyped, CRB) +LIGHTNING_REFLEXES = AbilitySpec( + name="Lightning Reflexes", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="ref", value=2), + ), +) + +# Alertness: +2 Perception and Sense Motive (untyped, CRB) +# Modeled as +2 will (Wisdom-based) since skills aren't implemented +ALERTNESS = AbilitySpec( + name="Alertness", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="will", value=2), + ), +) + +# Point-Blank Shot: +1 attack and +1 damage with ranged weapons within 30 ft. +# Untyped, conditional (CRB). +POINT_BLANK_SHOT = AbilitySpec( + name="Point-Blank Shot", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier( + target="attack", value=1, condition="target_within_30ft_ranged" + ), + StatModifier( + target="damage", value=1, condition="target_within_30ft_ranged" + ), + ), +) + +# Precise Shot: no -4 penalty for shooting into melee (CRB) +# This is a behavioral flag, not a stat modifier — it will be handled +# as a SpecialBehavior in a later step. Defined here for completeness. +PRECISE_SHOT = AbilitySpec( + name="Precise Shot", + category="feat", + source_detail="feat lv1", +) + +# Dodge: +1 dodge bonus to AC (dodge type, stacks, CRB) +DODGE = AbilitySpec( + name="Dodge", + category="feat", + source_detail="feat lv1", + effects=( + StatModifier(target="ac", value=1, bonus_type="dodge"), + ), +) + +# Skill Focus: +3 to a skill (not yet modeled — skills deferred) +# Defined as a placeholder for future expansion. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d8db617..9e71aa1 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -357,11 +357,25 @@ class CombatEngine: return _FLANK_BONUS return 0 - def _stat_modifiers(self, state: CombatantState, target: str) -> list[StatModifier]: - """Collect all StatModifiers for ``target`` from effects + conditions.""" + def _stat_modifiers( + self, state: CombatantState, target: str, *, weapon_name: str | None = None + ) -> list[StatModifier]: + """Collect all StatModifiers for ``target`` from effects, conditions, and features. + + ``weapon_name`` filters out modifiers with a ``weapon_filter`` that + doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol"). + Modifiers without a ``weapon_filter`` are always included. + """ mods = [e for e in state.effects if e.target == target] for cond in state.conditions: mods.extend(m for m in cond.modifiers if m.target == target) + for feat in state.combatant.features: + for m in feat.effects: + if m.target != target: + continue + if m.weapon_filter is not None and m.weapon_filter != weapon_name: + continue + mods.append(m) return mods def resolve_attack( @@ -378,7 +392,7 @@ class CombatEngine: penalty = self.range_penalty(weapon, dist_ft) flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0 base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus - atk_mods = self._stat_modifiers(attacker, "attack") + atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name) attack_mod = resolve_modifiers(atk_mods) total = roll + base_bonus + penalty + flank + attack_mod ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac")) @@ -401,7 +415,7 @@ class CombatEngine: confirm = self._rng.d20() confirm_total = confirm + base_bonus + penalty + flank + attack_mod crit = confirm != _NATURAL_ONE and confirm_total >= ac - dmg_mods = self._stat_modifiers(attacker, "damage") + dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name) damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus damage += resolve_modifiers(dmg_mods) if crit: diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 2cd45ff..43eba4a 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -11,6 +11,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator +from pf1e_simulator.abilities import AbilitySpec # noqa: TC001 from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice @@ -110,3 +111,4 @@ class Combatant(_FrozenModel): xp: int | None = None source: str = "" notes: str = "" + features: list[AbilitySpec] = [] diff --git a/tests/test_abilities.py b/tests/test_abilities.py new file mode 100644 index 0000000..8da77d7 --- /dev/null +++ b/tests/test_abilities.py @@ -0,0 +1,264 @@ +"""Tests for passive feats: Weapon Focus, Iron Will, Dodge, Point-Blank Shot.""" + +from __future__ import annotations + +from pf1e_simulator.abilities import ( + ALERTNESS, + DODGE, + GREAT_FORTITUDE, + IRON_WILL, + LIGHTNING_REFLEXES, + POINT_BLANK_SHOT, + weapon_focus, +) +from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.conditions import SHAKEN +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 _make_hero( + cid: str = "hero", + *, + features: list[object] | None = None, + weapon_name: str = "sword", + weapon_kind: str = "melee", + attack_bonus: int = 5, + damage_bonus: int = 2, +) -> Combatant: + attack = AttackSpec( + id=f"{cid}-w", + name=weapon_name, + kind=weapon_kind, # type: ignore[arg-type] + attack_bonus=attack_bonus, + damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], + damage_bonus=damage_bonus, + range_increment_ft=60 if weapon_kind == "ranged" else None, + ) + return Combatant( + id=cid, + name=cid, + level=4, + size="Medium", + abilities=AbilityScores( + str_score=14, dex_score=12, con_score=12, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=20, + ac=ACProfile(total=18, touch=12, flat_footed=16), + bab=5, + initiative_mod=2, + speed_land_ft=30, + saves=Saves(fort=5, ref=4, will=3), + attacks=[attack], + features=features or [], # type: ignore[arg-type] + ) + + +def _make_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=12, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=15, + ac=ACProfile(total=15, touch=10, flat_footed=13), + bab=3, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=3, ref=2, will=1), + attacks=[attack], + ) + + +def _make_engine( + hero: Combatant, foe: Combatant, queue: list[int] +) -> tuple[CombatEngine, CombatantState, CombatantState]: + h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max) + f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.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, [h, f]) + return engine, h, f + + +# ── AbilitySpec data ───────────────────────────────────────────────────────── + + +class TestAbilityData: + """Given: the predefined feat constants + When: inspecting their effects + Then: they match the CRB rules.""" + + def test_iron_will_gives_plus2_will(self) -> None: + assert len(IRON_WILL.effects) == 1 + mod = IRON_WILL.effects[0] + assert mod.target == "will" + assert mod.value == 2 + + def test_great_fortitude_gives_plus2_fort(self) -> None: + assert GREAT_FORTITUDE.effects[0].target == "fort" + assert GREAT_FORTITUDE.effects[0].value == 2 + + def test_lightning_reflexes_gives_plus2_ref(self) -> None: + assert LIGHTNING_REFLEXES.effects[0].target == "ref" + assert LIGHTNING_REFLEXES.effects[0].value == 2 + + def test_dodge_gives_plus1_dodge_ac(self) -> None: + assert len(DODGE.effects) == 1 + mod = DODGE.effects[0] + assert mod.target == "ac" + assert mod.value == 1 + assert mod.bonus_type == "dodge" + + def test_weapon_focus_targets_specific_weapon(self) -> None: + wf = weapon_focus("Pistol") + assert wf.effects[0].target == "attack" + assert wf.effects[0].value == 1 + assert wf.effects[0].weapon_filter == "Pistol" + + def test_point_blank_shot_is_conditional(self) -> None: + mods = POINT_BLANK_SHOT.effects + assert len(mods) == 2 + assert all(m.condition is not None for m in mods) + assert {m.target for m in mods} == {"attack", "damage"} + + +# ── Feat applied to attack rolls ───────────────────────────────────────────── + + +class TestFeatOnAttack: + """Given: an attacker with a passive feat + When: resolve_attack is called + Then: the feat modifier is applied to the attack total.""" + + def test_weapon_focus_adds_plus1_attack(self) -> None: + hero = _make_hero(features=[weapon_focus("sword")]) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # base_bonus=5, roll=10, attack_mod=+1 (weapon focus) + assert result.total == 10 + 5 + 1 # 16 + + def test_weapon_focus_does_not_apply_to_other_weapon(self) -> None: + hero = _make_hero(features=[weapon_focus("Pistol")], weapon_name="sword") + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + result = engine.resolve_attack(h, f, hero.attacks[0]) + # Weapon Focus (Pistol) doesn't apply to "sword" + assert result.total == 10 + 5 # 15 + + def test_no_feats_normal_attack(self) -> None: + hero = _make_hero() + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + result = engine.resolve_attack(h, f, hero.attacks[0]) + assert result.total == 10 + 5 # 15 + + +# ── Feat applied to AC ─────────────────────────────────────────────────────── + + +class TestFeatOnAC: + """Given: a defender with the Dodge feat + When: resolve_attack is called + Then: +1 dodge AC is applied.""" + + def test_dodge_adds_plus1_ac(self) -> None: + defender = _make_foe("defender") + defender_with_dodge = defender.model_copy(update={"features": [DODGE]}) + hero = _make_hero() + engine, h, d = _make_engine(hero, defender_with_dodge, [10, 4]) + result = engine.resolve_attack(h, d, hero.attacks[0]) + assert result.ac == 16 # 15 base + 1 dodge + + +# ── Feat applied to saving throws ──────────────────────────────────────────── + + +class TestFeatOnSaves: + """Given: a combatant with save feats + When: resolve_save is called + Then: the feat modifier is applied to the save total.""" + + def test_iron_will_adds_plus2_will(self) -> None: + hero = _make_hero(features=[IRON_WILL]) + engine, h, _ = _make_engine(hero, _make_foe(), [10]) + result = engine.resolve_save(h, "will", dc=12) + # base will=3, roll=10, +2 (Iron Will) = 15 + assert result.total == 15 + assert result.success is True + + def test_great_fortitude_adds_plus2_fort(self) -> None: + hero = _make_hero(features=[GREAT_FORTITUDE]) + engine, h, _ = _make_engine(hero, _make_foe(), [10]) + result = engine.resolve_save(h, "fort", dc=14) + # base fort=5, roll=10, +2 = 17 + assert result.total == 17 + + def test_lightning_reflexes_adds_plus2_ref(self) -> None: + hero = _make_hero(features=[LIGHTNING_REFLEXES]) + engine, h, _ = _make_engine(hero, _make_foe(), [10]) + result = engine.resolve_save(h, "ref", dc=13) + # base ref=4, roll=10, +2 = 16 + assert result.total == 16 + + def test_iron_will_does_not_affect_fort(self) -> None: + hero = _make_hero(features=[IRON_WILL]) + engine, h, _ = _make_engine(hero, _make_foe(), [10]) + result = engine.resolve_save(h, "fort", dc=20) + # base fort=5, roll=10, no modifier = 15 + assert result.total == 15 + + +# ── Feats + conditions together ────────────────────────────────────────────── + + +class TestFeatsWithConditions: + """Given: a combatant with both a feat and a condition + When: resolve_attack is called + Then: both modifiers are collected and stacking rules apply.""" + + def test_weapon_focus_and_shaken(self) -> None: + hero = _make_hero(features=[weapon_focus("sword")]) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + h.conditions.append(SHAKEN) # -2 attack (untyped) + # +1 (Weapon Focus, untyped) - 2 (shaken, untyped) = -1 + result = engine.resolve_attack(h, f, hero.attacks[0]) + assert result.total == 10 + 5 - 1 # 14 + + def test_dodge_ac_on_defender(self) -> None: + hero = _make_hero(features=[DODGE]) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 4]) + result = engine.resolve_attack(f, h, foe.attacks[0]) + assert result.ac == 19 # 18 base + 1 dodge + + def test_multiple_save_feats_stack_untyped(self) -> None: + hero = _make_hero(features=[IRON_WILL, ALERTNESS]) + engine, h, _ = _make_engine(hero, _make_foe(), [10]) + result = engine.resolve_save(h, "will", dc=17) + assert result.total == 17 # 10 + 3 + 2 + 2