feat(abilities): add passive feats system (Weapon Focus, Iron Will, Dodge, Point-Blank Shot)
This commit is contained in:
@@ -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