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
|
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
|
||||||
|
|||||||
@@ -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