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
(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
+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)