feat(effects): add effect system with PF1e bonus stacking rules
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user