Files

265 lines
9.6 KiB
Python

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