diff --git a/README.md b/README.md index 7aed2b1..e497263 100644 --- a/README.md +++ b/README.md @@ -331,10 +331,12 @@ Règles modélisées : l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet (5 incréments max) ne sont pas distinguées des armes à projectiles. -Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non -appliquées dans la résolution) : +Non modélisé (couches `elevation`/`markers` présentes mais non appliquées +dans la résolution) : -- Sorts, jets de sauvegarde, conditions et états. +- Sorts, jets de sauvegarde, conditions et états (le système d'effets et les + règles d'empilement sont en place via `effects.py` — l'intégration au + moteur est en cours). - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft @@ -357,6 +359,12 @@ appliquées dans la résolution) : - `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution des attaques (couvert, ligne d'effet, pénalités de portée), états de vie, transcripts, politique par défaut. +- `effects.py` — système d'effets : `StatModifier` (modificateur de + caractéristique avec type de bonus) et `resolve_modifiers` (application des + règles d'empilement PF1e). Types de bonus cumulables (dodge, racial, trait, + sans type) s'additionnent ; types non-cumulables (morale, sacré, profane, + enhancement…) gardent la valeur la plus élevée. Les pénalités suivent les + mêmes règles. Fondation pour sorts, conditions, dons et capacités de classe. - `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 + @@ -372,7 +380,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 219 tests +uv run pytest -q # 241 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -386,9 +394,10 @@ uv run basedpyright src # mode strict ## Feuille de route - **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à - outrance, attaques d'opportunité, charge, retraite et pas de placement sont - déjà modélisés. + effets paramétrés, 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`) et les règles d'empilement des bonus sont en place. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. - **Phase 3** — rapporteur LLM local : agrégation des statistiques et diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py new file mode 100644 index 0000000..8fa4f52 --- /dev/null +++ b/src/pf1e_simulator/effects.py @@ -0,0 +1,88 @@ +"""Effect system: bonus types, stat modifiers, and PF1e stacking rules. + +PF1e bonus types determine whether multiple bonuses to the same stat stack: +- Stacking types (dodge, racial, trait, untyped/"") all sum together. +- Non-stacking types (morale, sacred, profane, competence, insight, luck, + enhancement, deflection, natural) keep only the highest value per type. +- Penalties follow the same rules: same-type penalties don't stack (the + least severe — i.e. the max value — is kept). + +This module is the foundation for spells, conditions, feats, class abilities, +and racial traits — all of which produce StatModifier instances that the +engine collects and sums via resolve_modifiers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Sequence + +BonusType = Literal[ + "", # untyped — stacks with everything + "dodge", # stacks with itself + "morale", # no stack + "sacred", # no stack + "profane", # no stack + "competence", # no stack + "insight", # no stack + "luck", # no stack + "racial", # stacks with itself + "trait", # stacks with itself + "enhancement", # no stack + "deflection", # no stack + "natural", # no stack + "circumstance", # no stack (simulator can't distinguish circumstances) +] + +StatTarget = Literal[ + "attack", "damage", "ac", "touch_ac", "flat_footed_ac", + "fort", "ref", "will", "initiative", + "cmb", "cmd", "speed_ft", "hp", "concentration", "cl", +] + +_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"}) + + +@dataclass(frozen=True) +class StatModifier: + """+X to a stat, optionally with a bonus type for stacking and a condition. + + - ``bonus_type`` defaults to "" (untyped) which stacks with everything. + - ``condition`` is None for always-active modifiers; otherwise it names a + runtime condition (e.g. "target_within_30ft", "flanking") that the engine + evaluates before including this modifier. + - ``weapon_filter`` restricts the modifier to a specific weapon by name + (e.g. "Pistol" for Weapon Focus (Pistol)). + """ + + target: StatTarget + value: int + bonus_type: BonusType = "" + condition: str | None = None + weapon_filter: str | None = None + + +def resolve_modifiers(modifiers: Sequence[StatModifier]) -> int: + """Sum stat modifiers applying PF1e stacking rules. + + Stacking bonus types (dodge, racial, trait, untyped) all sum together. + Non-stacking types keep only the highest value per type (max is used, + which correctly handles both bonuses and penalties: for bonuses the + highest is most beneficial, for penalties the highest is least severe). + + The caller is responsible for filtering by condition and weapon before + calling — this function performs pure stacking math. + """ + stacking_total = 0 + best_by_type: dict[str, int] = {} + for mod in modifiers: + if mod.bonus_type in _STACKING_TYPES: + stacking_total += mod.value + else: + prev = best_by_type.get(mod.bonus_type) + if prev is None or mod.value > prev: + best_by_type[mod.bonus_type] = mod.value + return stacking_total + sum(best_by_type.values()) diff --git a/tests/test_effects.py b/tests/test_effects.py new file mode 100644 index 0000000..0cac811 --- /dev/null +++ b/tests/test_effects.py @@ -0,0 +1,204 @@ +"""Tests for the effect system: StatModifier and PF1e bonus stacking rules.""" + +from __future__ import annotations + +from pf1e_simulator.effects import StatModifier, resolve_modifiers + +# ── Stacking types: sum together ────────────────────────────────────────────── + + +class TestStackingTypes: + """Given: modifiers with stacking bonus types + When: resolve_modifiers is called + Then: values sum together.""" + + def test_two_dodge_bonus_stack(self) -> None: + mods = [ + StatModifier("ac", 1, "dodge"), + StatModifier("ac", 2, "dodge"), + ] + assert resolve_modifiers(mods) == 3 + + def test_two_racial_bonus_stack(self) -> None: + mods = [ + StatModifier("save", 1, "racial"), + StatModifier("save", 1, "racial"), + ] + assert resolve_modifiers(mods) == 2 + + def test_two_trait_bonus_stack(self) -> None: + mods = [ + StatModifier("attack", 1, "trait"), + StatModifier("attack", 1, "trait"), + ] + assert resolve_modifiers(mods) == 2 + + def test_two_untyped_bonus_stack(self) -> None: + mods = [ + StatModifier("attack", 1), + StatModifier("attack", 2), + ] + assert resolve_modifiers(mods) == 3 + + def test_mixed_stacking_types_all_sum(self) -> None: + mods = [ + StatModifier("ac", 1, "dodge"), + StatModifier("ac", 2, "racial"), + StatModifier("ac", 1, "trait"), + StatModifier("ac", 1), + ] + assert resolve_modifiers(mods) == 5 + + def test_negative_untyped_stack(self) -> None: + mods = [ + StatModifier("attack", -1), + StatModifier("attack", -2), + ] + assert resolve_modifiers(mods) == -3 + + +# ── Non-stacking types: keep highest ────────────────────────────────────────── + + +class TestNonStackingTypes: + """Given: modifiers with non-stacking bonus types + When: resolve_modifiers is called + Then: only the highest value per type is kept.""" + + def test_two_morale_keep_highest(self) -> None: + mods = [ + StatModifier("attack", 2, "morale"), + StatModifier("attack", 1, "morale"), + ] + assert resolve_modifiers(mods) == 2 + + def test_two_sacred_keep_highest(self) -> None: + mods = [ + StatModifier("ac", 2, "sacred"), + StatModifier("ac", 3, "sacred"), + ] + assert resolve_modifiers(mods) == 3 + + def test_two_enhancement_keep_highest(self) -> None: + mods = [ + StatModifier("ac", 2, "enhancement"), + StatModifier("ac", 4, "enhancement"), + ] + assert resolve_modifiers(mods) == 4 + + def test_different_non_stacking_types_each_apply(self) -> None: + mods = [ + StatModifier("ac", 2, "morale"), + StatModifier("ac", 1, "sacred"), + StatModifier("ac", 3, "enhancement"), + ] + assert resolve_modifiers(mods) == 6 + + def test_circumstance_does_not_stack(self) -> None: + mods = [ + StatModifier("attack", 2, "circumstance"), + StatModifier("attack", 1, "circumstance"), + ] + assert resolve_modifiers(mods) == 2 + + +# ── Penalties ───────────────────────────────────────────────────────────────── + + +class TestPenalties: + """Given: penalties (negative values) with non-stacking types + When: resolve_modifiers is called + Then: least severe penalty is kept (max value).""" + + def test_same_type_penalties_keep_least_severe(self) -> None: + mods = [ + StatModifier("attack", -2, "morale"), + StatModifier("attack", -1, "morale"), + ] + assert resolve_modifiers(mods) == -1 + + def test_same_type_bonus_and_penalty_keep_most_beneficial(self) -> None: + mods = [ + StatModifier("attack", 2, "morale"), + StatModifier("attack", -1, "morale"), + ] + assert resolve_modifiers(mods) == 2 + + def test_untyped_penalties_stack(self) -> None: + mods = [ + StatModifier("attack", -1), + StatModifier("attack", -2), + ] + assert resolve_modifiers(mods) == -3 + + +# ── Mixed: stacking + non-stacking ──────────────────────────────────────────── + + +class TestMixedStacking: + """Given: a mix of stacking and non-stacking modifiers + When: resolve_modifiers is called + Then: stacking types sum, non-stacking types keep highest, totals combine.""" + + def test_dodge_plus_morale_plus_untyped(self) -> None: + mods = [ + StatModifier("ac", 1, "dodge"), + StatModifier("ac", 2, "morale"), + StatModifier("ac", 1), + ] + assert resolve_modifiers(mods) == 4 + + def test_two_morale_plus_dodge_plus_racial(self) -> None: + mods = [ + StatModifier("ac", 2, "morale"), + StatModifier("ac", 3, "morale"), + StatModifier("ac", 1, "dodge"), + StatModifier("ac", 1, "racial"), + ] + assert resolve_modifiers(mods) == 5 # 3 (max morale) + 1 (dodge) + 1 (racial) + + def test_penalties_and_bonus_mixed(self) -> None: + mods = [ + StatModifier("attack", 2, "morale"), + StatModifier("attack", -1, "morale"), + StatModifier("attack", -1), + StatModifier("attack", 1, "dodge"), + ] + assert resolve_modifiers(mods) == 2 # 2 (max morale) + -1 (untyped) + 1 (dodge) + + +# ── Edge cases ──────────────────────────────────────────────────────────────── + + +class TestEdgeCases: + """Given: edge-case modifier lists + When: resolve_modifiers is called + Then: returns the correct total.""" + + def test_empty_list_returns_zero(self) -> None: + assert resolve_modifiers([]) == 0 + + def test_single_modifier(self) -> None: + assert resolve_modifiers([StatModifier("ac", 4, "dodge")]) == 4 + + def test_single_negative_modifier(self) -> None: + assert resolve_modifiers([StatModifier("attack", -3, "morale")]) == -3 + + def test_all_zero_values(self) -> None: + mods = [ + StatModifier("ac", 0, "dodge"), + StatModifier("ac", 0, "morale"), + ] + assert resolve_modifiers(mods) == 0 + + def test_resolve_does_not_filter_by_target(self) -> None: + """resolve_modifiers treats all modifiers together regardless of target. + + The caller must filter by target before calling — the function only + applies bonus-type stacking rules, not target grouping. + """ + mods = [ + StatModifier("attack", 2, "morale"), + StatModifier("ac", 3, "morale"), + ] + assert resolve_modifiers(mods) == 3 # same non-stacking type → max(2, 3)