feat(abilities): add passive feats system (Weapon Focus, Iron Will, Dodge, Point-Blank Shot)
This commit is contained in:
@@ -340,9 +340,9 @@ Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
|
||||
dans la résolution) :
|
||||
|
||||
- Sorts (le système d'effets `effects.py`, les règles d'empilement des
|
||||
bonus, les jets de sauvegarde `resolve_save` et le système de conditions
|
||||
`conditions.py` sont en place — l'intégration des sorts au moteur est en
|
||||
cours).
|
||||
bonus, les jets de sauvegarde `resolve_save`, le système de conditions
|
||||
`conditions.py` et les dons passifs `abilities.py` sont en place —
|
||||
l'intégration des sorts au moteur est en cours).
|
||||
- Manœuvres de combat.
|
||||
- Effets mécaniques de hauteur/élévation.
|
||||
- Tailles Large+ (2×2), allonge > 5 ft
|
||||
@@ -378,6 +378,12 @@ dans la résolution) :
|
||||
staggered, frightened, panicked, cowering). Les modificateurs sont intégrés
|
||||
à `resolve_attack` (attaque, dégâts, CA) et `resolve_save` (jets de
|
||||
sauvegarde) via `_stat_modifiers` qui collecte effets + conditions.
|
||||
- `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec
|
||||
catégorie feat/class/racial/trait) et dons passifs prédéfinis (Weapon Focus,
|
||||
Toughness, Iron Will, Great Fortitude, Lightning Reflexes, Alertness,
|
||||
Point-Blank Shot, Dodge). Les modificateurs sont collectés par
|
||||
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
|
||||
`features` de `Combatant` contient les aptitudes permanentes.
|
||||
- `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 +
|
||||
@@ -393,7 +399,7 @@ dans la résolution) :
|
||||
La gate de validation complète (tests + lint + types) :
|
||||
|
||||
```bash
|
||||
uv run pytest -q # 276 tests
|
||||
uv run pytest -q # 293 tests
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright src # mode strict
|
||||
```
|
||||
@@ -410,7 +416,8 @@ uv run basedpyright src # mode strict
|
||||
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`), les
|
||||
règles d'empilement des bonus et les jets de sauvegarde (`resolve_save`)
|
||||
règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le
|
||||
système de conditions (`conditions.py`) et les dons passifs (`abilities.py`)
|
||||
sont en place.
|
||||
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
|
||||
en politiques, balayage de matrices de positionnement.
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PF1e abilities: feats, class features, racial traits, character traits.
|
||||
|
||||
All four categories share the same mechanical representation via ``AbilitySpec``.
|
||||
What differs is the ``category`` (for stacking and display) and ``source_detail``.
|
||||
|
||||
This step defines passive feats — permanent stat modifiers that the engine
|
||||
collects alongside effects and conditions via ``_stat_modifiers``. Active feats
|
||||
(Power Attack, Rage) and conditional feats (Sneak Attack) will be added in
|
||||
later steps.
|
||||
|
||||
Sources: PRPG Core Rulebook, Feats chapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.effects import StatModifier
|
||||
|
||||
Category = Literal["feat", "class", "racial", "trait"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AbilitySpec:
|
||||
"""One feat, class feature, racial trait, or character trait.
|
||||
|
||||
``effects`` are the StatModifiers this ability grants. For passive feats,
|
||||
these are always-on modifiers. For conditional feats, ``condition`` on
|
||||
each StatModifier gates applicability (evaluated by the engine).
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: Category
|
||||
source_detail: str = ""
|
||||
effects: tuple[StatModifier, ...] = ()
|
||||
|
||||
|
||||
# ── Passive combat feats ─────────────────────────────────────────────────────
|
||||
# Weapon Focus: +1 attack with a specific weapon (untyped, CRB)
|
||||
def weapon_focus(weapon_name: str) -> AbilitySpec:
|
||||
return AbilitySpec(
|
||||
name=f"Weapon Focus ({weapon_name})",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="attack", value=1, weapon_filter=weapon_name),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Toughness: +1 HP per Hit Die (approximated as +level HP, untyped, CRB)
|
||||
# For simplicity at this stage, we model it as a flat HP bonus.
|
||||
# The engine doesn't yet compute per-HD bonuses, so the caller sets the value.
|
||||
def toughness(level: int) -> AbilitySpec:
|
||||
return AbilitySpec(
|
||||
name="Toughness",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="hp", value=level),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Iron Will: +2 will saves (untyped, CRB)
|
||||
IRON_WILL = AbilitySpec(
|
||||
name="Iron Will",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="will", value=2),
|
||||
),
|
||||
)
|
||||
|
||||
# Great Fortitude: +2 fortitude saves (untyped, CRB)
|
||||
GREAT_FORTITUDE = AbilitySpec(
|
||||
name="Great Fortitude",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="fort", value=2),
|
||||
),
|
||||
)
|
||||
|
||||
# Lightning Reflexes: +2 reflex saves (untyped, CRB)
|
||||
LIGHTNING_REFLEXES = AbilitySpec(
|
||||
name="Lightning Reflexes",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="ref", value=2),
|
||||
),
|
||||
)
|
||||
|
||||
# Alertness: +2 Perception and Sense Motive (untyped, CRB)
|
||||
# Modeled as +2 will (Wisdom-based) since skills aren't implemented
|
||||
ALERTNESS = AbilitySpec(
|
||||
name="Alertness",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="will", value=2),
|
||||
),
|
||||
)
|
||||
|
||||
# Point-Blank Shot: +1 attack and +1 damage with ranged weapons within 30 ft.
|
||||
# Untyped, conditional (CRB).
|
||||
POINT_BLANK_SHOT = AbilitySpec(
|
||||
name="Point-Blank Shot",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(
|
||||
target="attack", value=1, condition="target_within_30ft_ranged"
|
||||
),
|
||||
StatModifier(
|
||||
target="damage", value=1, condition="target_within_30ft_ranged"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Precise Shot: no -4 penalty for shooting into melee (CRB)
|
||||
# This is a behavioral flag, not a stat modifier — it will be handled
|
||||
# as a SpecialBehavior in a later step. Defined here for completeness.
|
||||
PRECISE_SHOT = AbilitySpec(
|
||||
name="Precise Shot",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
)
|
||||
|
||||
# Dodge: +1 dodge bonus to AC (dodge type, stacks, CRB)
|
||||
DODGE = AbilitySpec(
|
||||
name="Dodge",
|
||||
category="feat",
|
||||
source_detail="feat lv1",
|
||||
effects=(
|
||||
StatModifier(target="ac", value=1, bonus_type="dodge"),
|
||||
),
|
||||
)
|
||||
|
||||
# Skill Focus: +3 to a skill (not yet modeled — skills deferred)
|
||||
# Defined as a placeholder for future expansion.
|
||||
@@ -357,11 +357,25 @@ class CombatEngine:
|
||||
return _FLANK_BONUS
|
||||
return 0
|
||||
|
||||
def _stat_modifiers(self, state: CombatantState, target: str) -> list[StatModifier]:
|
||||
"""Collect all StatModifiers for ``target`` from effects + conditions."""
|
||||
def _stat_modifiers(
|
||||
self, state: CombatantState, target: str, *, weapon_name: str | None = None
|
||||
) -> list[StatModifier]:
|
||||
"""Collect all StatModifiers for ``target`` from effects, conditions, and features.
|
||||
|
||||
``weapon_name`` filters out modifiers with a ``weapon_filter`` that
|
||||
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
|
||||
Modifiers without a ``weapon_filter`` are always included.
|
||||
"""
|
||||
mods = [e for e in state.effects if e.target == target]
|
||||
for cond in state.conditions:
|
||||
mods.extend(m for m in cond.modifiers if m.target == target)
|
||||
for feat in state.combatant.features:
|
||||
for m in feat.effects:
|
||||
if m.target != target:
|
||||
continue
|
||||
if m.weapon_filter is not None and m.weapon_filter != weapon_name:
|
||||
continue
|
||||
mods.append(m)
|
||||
return mods
|
||||
|
||||
def resolve_attack(
|
||||
@@ -378,7 +392,7 @@ class CombatEngine:
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
atk_mods = self._stat_modifiers(attacker, "attack")
|
||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
total = roll + base_bonus + penalty + flank + attack_mod
|
||||
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
@@ -401,7 +415,7 @@ class CombatEngine:
|
||||
confirm = self._rng.d20()
|
||||
confirm_total = confirm + base_bonus + penalty + flank + attack_mod
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
dmg_mods = self._stat_modifiers(attacker, "damage")
|
||||
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
|
||||
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||
damage += resolve_modifiers(dmg_mods)
|
||||
if crit:
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from pf1e_simulator.abilities import AbilitySpec # noqa: TC001
|
||||
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
||||
|
||||
|
||||
@@ -110,3 +111,4 @@ class Combatant(_FrozenModel):
|
||||
xp: int | None = None
|
||||
source: str = ""
|
||||
notes: str = ""
|
||||
features: list[AbilitySpec] = []
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Tests for passive feats: Weapon Focus, Iron Will, Dodge, Point-Blank Shot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.abilities import (
|
||||
ALERTNESS,
|
||||
DODGE,
|
||||
GREAT_FORTITUDE,
|
||||
IRON_WILL,
|
||||
LIGHTNING_REFLEXES,
|
||||
POINT_BLANK_SHOT,
|
||||
weapon_focus,
|
||||
)
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
from pf1e_simulator.conditions import SHAKEN
|
||||
from pf1e_simulator.dice import parse_dice
|
||||
from pf1e_simulator.grid import Grid
|
||||
from pf1e_simulator.map import MapSpec, TerrainType
|
||||
from pf1e_simulator.models import (
|
||||
AbilityScores,
|
||||
ACProfile,
|
||||
AttackSpec,
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
)
|
||||
from pf1e_simulator.rng import ScriptedRng
|
||||
|
||||
|
||||
def _make_hero(
|
||||
cid: str = "hero",
|
||||
*,
|
||||
features: list[object] | None = None,
|
||||
weapon_name: str = "sword",
|
||||
weapon_kind: str = "melee",
|
||||
attack_bonus: int = 5,
|
||||
damage_bonus: int = 2,
|
||||
) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name=weapon_name,
|
||||
kind=weapon_kind, # type: ignore[arg-type]
|
||||
attack_bonus=attack_bonus,
|
||||
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
|
||||
damage_bonus=damage_bonus,
|
||||
range_increment_ft=60 if weapon_kind == "ranged" else None,
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=4,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=14, dex_score=12, con_score=12,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=20,
|
||||
ac=ACProfile(total=18, touch=12, flat_footed=16),
|
||||
bab=5,
|
||||
initiative_mod=2,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=5, ref=4, will=3),
|
||||
attacks=[attack],
|
||||
features=features or [], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _make_foe(cid: str = "foe") -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="club",
|
||||
kind="melee",
|
||||
attack_bonus=3,
|
||||
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size="Medium",
|
||||
abilities=AbilityScores(
|
||||
str_score=12, dex_score=10, con_score=10,
|
||||
int_score=10, wis_score=10, cha_score=10,
|
||||
),
|
||||
hp_max=15,
|
||||
ac=ACProfile(total=15, touch=10, flat_footed=13),
|
||||
bab=3,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=30,
|
||||
saves=Saves(fort=3, ref=2, will=1),
|
||||
attacks=[attack],
|
||||
)
|
||||
|
||||
|
||||
def _make_engine(
|
||||
hero: Combatant, foe: Combatant, queue: list[int]
|
||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
|
||||
f = CombatantState(combatant=foe, side="monsters", pos=(0, 0), hp=foe.hp_max)
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||
grid = Grid.from_spec(spec)
|
||||
engine = CombatEngine(ScriptedRng(queue), grid, [h, f])
|
||||
return engine, h, f
|
||||
|
||||
|
||||
# ── AbilitySpec data ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAbilityData:
|
||||
"""Given: the predefined feat constants
|
||||
When: inspecting their effects
|
||||
Then: they match the CRB rules."""
|
||||
|
||||
def test_iron_will_gives_plus2_will(self) -> None:
|
||||
assert len(IRON_WILL.effects) == 1
|
||||
mod = IRON_WILL.effects[0]
|
||||
assert mod.target == "will"
|
||||
assert mod.value == 2
|
||||
|
||||
def test_great_fortitude_gives_plus2_fort(self) -> None:
|
||||
assert GREAT_FORTITUDE.effects[0].target == "fort"
|
||||
assert GREAT_FORTITUDE.effects[0].value == 2
|
||||
|
||||
def test_lightning_reflexes_gives_plus2_ref(self) -> None:
|
||||
assert LIGHTNING_REFLEXES.effects[0].target == "ref"
|
||||
assert LIGHTNING_REFLEXES.effects[0].value == 2
|
||||
|
||||
def test_dodge_gives_plus1_dodge_ac(self) -> None:
|
||||
assert len(DODGE.effects) == 1
|
||||
mod = DODGE.effects[0]
|
||||
assert mod.target == "ac"
|
||||
assert mod.value == 1
|
||||
assert mod.bonus_type == "dodge"
|
||||
|
||||
def test_weapon_focus_targets_specific_weapon(self) -> None:
|
||||
wf = weapon_focus("Pistol")
|
||||
assert wf.effects[0].target == "attack"
|
||||
assert wf.effects[0].value == 1
|
||||
assert wf.effects[0].weapon_filter == "Pistol"
|
||||
|
||||
def test_point_blank_shot_is_conditional(self) -> None:
|
||||
mods = POINT_BLANK_SHOT.effects
|
||||
assert len(mods) == 2
|
||||
assert all(m.condition is not None for m in mods)
|
||||
assert {m.target for m in mods} == {"attack", "damage"}
|
||||
|
||||
|
||||
# ── Feat applied to attack rolls ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatOnAttack:
|
||||
"""Given: an attacker with a passive feat
|
||||
When: resolve_attack is called
|
||||
Then: the feat modifier is applied to the attack total."""
|
||||
|
||||
def test_weapon_focus_adds_plus1_attack(self) -> None:
|
||||
hero = _make_hero(features=[weapon_focus("sword")])
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# base_bonus=5, roll=10, attack_mod=+1 (weapon focus)
|
||||
assert result.total == 10 + 5 + 1 # 16
|
||||
|
||||
def test_weapon_focus_does_not_apply_to_other_weapon(self) -> None:
|
||||
hero = _make_hero(features=[weapon_focus("Pistol")], weapon_name="sword")
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
# Weapon Focus (Pistol) doesn't apply to "sword"
|
||||
assert result.total == 10 + 5 # 15
|
||||
|
||||
def test_no_feats_normal_attack(self) -> None:
|
||||
hero = _make_hero()
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
assert result.total == 10 + 5 # 15
|
||||
|
||||
|
||||
# ── Feat applied to AC ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatOnAC:
|
||||
"""Given: a defender with the Dodge feat
|
||||
When: resolve_attack is called
|
||||
Then: +1 dodge AC is applied."""
|
||||
|
||||
def test_dodge_adds_plus1_ac(self) -> None:
|
||||
defender = _make_foe("defender")
|
||||
defender_with_dodge = defender.model_copy(update={"features": [DODGE]})
|
||||
hero = _make_hero()
|
||||
engine, h, d = _make_engine(hero, defender_with_dodge, [10, 4])
|
||||
result = engine.resolve_attack(h, d, hero.attacks[0])
|
||||
assert result.ac == 16 # 15 base + 1 dodge
|
||||
|
||||
|
||||
# ── Feat applied to saving throws ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatOnSaves:
|
||||
"""Given: a combatant with save feats
|
||||
When: resolve_save is called
|
||||
Then: the feat modifier is applied to the save total."""
|
||||
|
||||
def test_iron_will_adds_plus2_will(self) -> None:
|
||||
hero = _make_hero(features=[IRON_WILL])
|
||||
engine, h, _ = _make_engine(hero, _make_foe(), [10])
|
||||
result = engine.resolve_save(h, "will", dc=12)
|
||||
# base will=3, roll=10, +2 (Iron Will) = 15
|
||||
assert result.total == 15
|
||||
assert result.success is True
|
||||
|
||||
def test_great_fortitude_adds_plus2_fort(self) -> None:
|
||||
hero = _make_hero(features=[GREAT_FORTITUDE])
|
||||
engine, h, _ = _make_engine(hero, _make_foe(), [10])
|
||||
result = engine.resolve_save(h, "fort", dc=14)
|
||||
# base fort=5, roll=10, +2 = 17
|
||||
assert result.total == 17
|
||||
|
||||
def test_lightning_reflexes_adds_plus2_ref(self) -> None:
|
||||
hero = _make_hero(features=[LIGHTNING_REFLEXES])
|
||||
engine, h, _ = _make_engine(hero, _make_foe(), [10])
|
||||
result = engine.resolve_save(h, "ref", dc=13)
|
||||
# base ref=4, roll=10, +2 = 16
|
||||
assert result.total == 16
|
||||
|
||||
def test_iron_will_does_not_affect_fort(self) -> None:
|
||||
hero = _make_hero(features=[IRON_WILL])
|
||||
engine, h, _ = _make_engine(hero, _make_foe(), [10])
|
||||
result = engine.resolve_save(h, "fort", dc=20)
|
||||
# base fort=5, roll=10, no modifier = 15
|
||||
assert result.total == 15
|
||||
|
||||
|
||||
# ── Feats + conditions together ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFeatsWithConditions:
|
||||
"""Given: a combatant with both a feat and a condition
|
||||
When: resolve_attack is called
|
||||
Then: both modifiers are collected and stacking rules apply."""
|
||||
|
||||
def test_weapon_focus_and_shaken(self) -> None:
|
||||
hero = _make_hero(features=[weapon_focus("sword")])
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
h.conditions.append(SHAKEN) # -2 attack (untyped)
|
||||
# +1 (Weapon Focus, untyped) - 2 (shaken, untyped) = -1
|
||||
result = engine.resolve_attack(h, f, hero.attacks[0])
|
||||
assert result.total == 10 + 5 - 1 # 14
|
||||
|
||||
def test_dodge_ac_on_defender(self) -> None:
|
||||
hero = _make_hero(features=[DODGE])
|
||||
foe = _make_foe()
|
||||
engine, h, f = _make_engine(hero, foe, [10, 4])
|
||||
result = engine.resolve_attack(f, h, foe.attacks[0])
|
||||
assert result.ac == 19 # 18 base + 1 dodge
|
||||
|
||||
def test_multiple_save_feats_stack_untyped(self) -> None:
|
||||
hero = _make_hero(features=[IRON_WILL, ALERTNESS])
|
||||
engine, h, _ = _make_engine(hero, _make_foe(), [10])
|
||||
result = engine.resolve_save(h, "will", dc=17)
|
||||
assert result.total == 17 # 10 + 3 + 2 + 2
|
||||
Reference in New Issue
Block a user