265 lines
9.6 KiB
Python
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
|