feat(effects): add effect system with PF1e bonus stacking rules

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 94521394ef
commit 8ac6f9eabe
3 changed files with 308 additions and 7 deletions
+16 -7
View File
@@ -331,10 +331,12 @@ Règles modélisées :
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet 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. (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 Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
appliquées dans la résolution) : 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. - Manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft - 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 - `combat.py``CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet, pénalités de portée), états de vie, des attaques (couvert, ligne d'effet, pénalités de portée), états de vie,
transcripts, politique par défaut. 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`, - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]). `win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py``EncounterSpec`/`Side`, `build_states` (placement en zone + - `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) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 219 tests uv run pytest -q # 241 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
@@ -386,9 +394,10 @@ uv run basedpyright src # mode strict
## Feuille de route ## Feuille de route
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à effets paramétrés, conditions, manœuvres de combat, dons et capacités de
outrance, attaques d'opportunité, charge, retraite et pas de placement sont classe. Flanquement, attaques à outrance, attaques d'opportunité, charge,
déjà modélisés. 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 - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et - **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+88
View File
@@ -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())
+204
View File
@@ -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)