Files
pf1e-simulator/tests/test_conditional_modifiers.py
T

511 lines
18 KiB
Python

"""Tests for conditional StatModifier evaluation with action context (B1).
Given: a StatModifier whose ``condition`` refers to a runtime property
(target range, wielded weapon, save descriptor)
When: the engine resolves an attack or a saving throw
Then: the modifier applies only when the runtime condition holds;
unsupported or unknown condition names stay excluded (security).
Supported condition families (plan B1):
- ``target_within_30ft_ranged`` — ranged-style weapon (or touch with
increment) fired at a target within 30 ft (Point-Blank Shot).
- ``axe`` — wielded weapon whose name contains "axe" (engine capability; the
real Axe to Grind trait is a documented no-op, see test_feats.py).
- save descriptors (fear, cold, electricity, fey, divination, poison,
disease, mind_affecting) — the saving throw's context, threaded from the
fear aura and from spell effects/schools (real feats: Spell Vulnerability,
Half-Forgotten Secrets, Lonely's -2 vs mind_affecting standing in for
charm; the remaining descriptors are exercised through the local probe
specs below).
- Unsupported (mounted, adjacent_ally_stealth_synergy, weapon_finesse,
alcohol) and unknown names always evaluate to False.
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
import pytest
from pf1e_simulator.abilities import (
FORTIFIED_DRINKER,
HALF_FORGOTTEN_SECRETS,
LONELY,
MOUNTED_COMBAT,
POINT_BLANK_SHOT,
SPELL_VULNERABILITY_DIVINATION,
AbilitySpec,
)
from pf1e_simulator.combat import CombatantState, CombatEngine, SaveResult
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import StatModifier
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
from pf1e_simulator.spells import load_spell_registry, spell_save_descriptors
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def make_combatant(
cid: str,
*,
hp: int = 20,
ac_total: int = 18,
attack_bonus: int = 5,
damage: str = "1d6",
kind: Literal["melee", "ranged", "touch"] = "melee",
weapon_name: str = "test weapon",
range_increment_ft: int | None = None,
features: tuple[AbilitySpec, ...] = (),
will: int = 0,
fort: int = 0,
ref: int = 0,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind=kind,
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice(damage), types=["slashing"])],
range_increment_ft=range_increment_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=12,
dex_score=12,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac_total, touch=max(12, ac_total - 6), flat_footed=ac_total - 2),
bab=attack_bonus,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=fort, ref=ref, will=will),
attacks=[attack],
features=list(features),
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid(width: int = 8) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * width] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(
queue: list[int], states: list[CombatantState], *, width: int = 8
) -> CombatEngine:
return CombatEngine(ScriptedRng(queue), make_grid(width), states)
def _resolve_save(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
*,
roll: int = 18,
save_descriptors: frozenset[str] = frozenset(),
) -> SaveResult:
hero = make_combatant("hero", features=(feat,), will=0, fort=0, ref=0)
h = make_state(hero, "players", (0, 0))
engine = make_engine([roll], [h])
return engine.resolve_save(h, save_type, dc, save_descriptors=save_descriptors)
# ── Weapon/range conditions ───────────────────────────────────────────────────
def test_point_blank_shot_applies_within_30_ft() -> None:
"""PBS: ranged attack at 5 ft gains +1 attack and +1 damage."""
archer = make_combatant(
"archer",
kind="ranged",
range_increment_ft=60,
features=(POINT_BLANK_SHOT,),
)
victim = make_combatant("victim", hp=20)
a = make_state(archer, "players", (1, 1))
t = make_state(victim, "monsters", (1, 2)) # 5 ft
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, archer.attacks[0])
# 12 + 5 + 1 (PBS) = 18 >= AC 18; damage 3 + 1 (PBS) = 4
assert result.total == 18
assert result.hit is True
assert t.hp == 16
def test_point_blank_shot_inactive_beyond_30_ft() -> None:
"""PBS: the +1/+1 does not apply at 35 ft even though the weapon is in range."""
archer = make_combatant(
"archer",
kind="ranged",
range_increment_ft=60,
features=(POINT_BLANK_SHOT,),
)
victim = make_combatant("victim", ac_total=16, hp=20)
a = make_state(archer, "players", (0, 0))
t = make_state(victim, "monsters", (0, 7)) # 35 ft
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, archer.attacks[0])
# 12 + 5 = 17 >= AC 16; damage 3 (no PBS)
assert result.total == 17
assert result.hit is True
assert t.hp == 17
def test_point_blank_shot_inactive_for_melee_weapons() -> None:
"""PBS is ranged-only: a melee attack at 5 ft gets no bonus."""
hero = make_combatant("hero", features=(POINT_BLANK_SHOT,))
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2)) # 5 ft, melee
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # no +1
assert t.hp == 17 # damage 3, no +1
_AXE_DAMAGE_PROBE = AbilitySpec(
name="Axe Damage Probe",
category="feat",
effects=(StatModifier(target="damage", value=1, condition="axe"),),
)
def test_axe_condition_applies_to_axe_weapon() -> None:
"""Engine 'axe' condition: +1 damage with a weapon whose name contains 'axe'."""
hero = make_combatant("hero", weapon_name="Battleaxe", features=(_AXE_DAMAGE_PROBE,))
foe = make_combatant("foe", ac_total=16, hp=20)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.hit is True
assert t.hp == 16 # 3 + 1 (axe)
def test_axe_condition_inactive_without_axe() -> None:
"""Engine 'axe' condition: no bonus when the weapon is not an axe."""
hero = make_combatant("hero", weapon_name="Shortsword", features=(_AXE_DAMAGE_PROBE,))
foe = make_combatant("foe", ac_total=16, hp=20)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.hit is True
assert t.hp == 17 # 3, no bonus
# ── Save descriptor conditions ────────────────────────────────────────────────
# Probe specs exercising the descriptor gating (the loader's real feats for
# these descriptors are documented no-ops, see test_feats.py).
_COLD_FORT_PROBE = AbilitySpec(
name="Cold Fort Probe",
category="feat",
effects=(StatModifier(target="fort", value=2, condition="cold"),),
)
_ELEC_REF_PROBE = AbilitySpec(
name="Electricity Ref Probe",
category="feat",
effects=(StatModifier(target="ref", value=2, condition="electricity"),),
)
_FEY_WILL_PROBE = AbilitySpec(
name="Fey Will Probe",
category="feat",
effects=(StatModifier(target="will", value=1, condition="fey"),),
)
_POISON_FORT_PROBE = AbilitySpec(
name="Poison Fort Probe",
category="feat",
effects=(StatModifier(target="fort", value=1, condition="poison"),),
)
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(_COLD_FORT_PROBE, "fort", 20, frozenset({"cold"})),
(_ELEC_REF_PROBE, "ref", 20, frozenset({"electricity"})),
(_FEY_WILL_PROBE, "will", 19, frozenset({"fey"})),
(HALF_FORGOTTEN_SECRETS, "fort", 20, frozenset({"disease"})),
(HALF_FORGOTTEN_SECRETS, "will", 20, frozenset({"mind_affecting"})),
(_POISON_FORT_PROBE, "fort", 19, frozenset({"poison"})),
],
)
def test_conditioned_save_bonus_applies_when_descriptor_present(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
descriptors: frozenset[str],
) -> None:
"""A save carrying the matching descriptor activates the conditional bonus."""
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
assert result.total == dc # 18 + bonus reaches exactly dc
assert result.success is True
def test_lonely_is_a_negative_mind_affecting_penalty() -> None:
"""Lonely (drawback): -2 Will vs mind_affecting (charm stands in), never a bonus."""
penalized = _resolve_save(
LONELY, "will", 17, save_descriptors=frozenset({"mind_affecting"}), roll=18,
)
assert penalized.total == 16 # 18 - 2
assert penalized.success is False
unaffected = _resolve_save(
LONELY, "will", 17, save_descriptors=frozenset({"fear"}), roll=18,
)
assert unaffected.total == 18
assert unaffected.success is True
@pytest.mark.parametrize(
("feat", "save_type", "dc", "descriptors"),
[
(_COLD_FORT_PROBE, "fort", 19, frozenset({"fear"})),
(HALF_FORGOTTEN_SECRETS, "will", 19, frozenset({"fear"})),
(_POISON_FORT_PROBE, "fort", 19, frozenset({"cold"})),
],
)
def test_conditioned_save_bonus_excluded_without_matching_descriptor(
feat: AbilitySpec,
save_type: Literal["fort", "ref", "will"],
dc: int,
descriptors: frozenset[str],
) -> None:
"""A save with an unrelated descriptor keeps the conditional bonus excluded."""
result = _resolve_save(feat, save_type, dc, save_descriptors=descriptors, roll=18)
assert result.total == 18 # no bonus
assert result.success is False
def test_spell_vulnerability_divination_penalty() -> None:
"""Spell Vulnerability: -2 Will/Ref penalty applies on divination saves only."""
penalized = _resolve_save(
SPELL_VULNERABILITY_DIVINATION, "will", 17,
save_descriptors=frozenset({"divination"}), roll=18,
)
assert penalized.total == 16 # 18 - 2
assert penalized.success is False
unaffected = _resolve_save(
SPELL_VULNERABILITY_DIVINATION, "ref", 17,
save_descriptors=frozenset({"fear"}), roll=18,
)
assert unaffected.total == 18
assert unaffected.success is True
# ── Unsupported / unknown conditions stay excluded ────────────────────────────
def test_mounted_condition_unsupported_stays_excluded() -> None:
"""Mounted Combat: 'mounted' is not evaluable (no mount model) -> excluded."""
hero = make_combatant("hero", attack_bonus=5, features=(MOUNTED_COMBAT,))
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # no +1 mounted bonus
assert t.hp == 17 # damage 3, no +1
def test_alcohol_condition_unsupported_stays_excluded() -> None:
"""Fortified Drinker is an inert no-op: no bonus on any save context."""
result = _resolve_save(
FORTIFIED_DRINKER, "fort", 19, save_descriptors=frozenset({"poison"}), roll=18,
)
assert result.total == 18 # no +2 alcohol bonus
assert result.success is False
def test_unknown_condition_stays_excluded() -> None:
"""A condition name with no evaluation rule is excluded (security)."""
hero = make_combatant("hero", attack_bonus=5)
foe = make_combatant("foe", ac_total=16)
a = make_state(hero, "players", (1, 1))
t = make_state(foe, "monsters", (1, 2))
a.effects.append(StatModifier(target="attack", value=5, condition="bogus_condition"))
engine = make_engine([12, 3], [a, t])
result = engine.resolve_attack(a, t, hero.attacks[0])
assert result.total == 17 # the +5 modifier is excluded
assert result.hit is True
# ── End-to-end: fear aura threading ───────────────────────────────────────────
def _make_fear_aura_lich() -> Combatant:
return make_combatant(
"lich",
hp=100,
attack_bonus=10,
features=(AbilitySpec(name="Fear Aura", category="class"),),
)
_FEAR_SAVE_PROBE = AbilitySpec(
name="Fear Save Probe",
category="feat",
effects=(StatModifier(target="will", value=1, condition="fear"),),
)
def test_fear_aura_save_carries_fear_descriptor() -> None:
"""The lich's fear aura save carries 'fear': a +1 fear bonus flips the save."""
lich = _make_fear_aura_lich()
foe = make_combatant("foe", will=0, features=(_FEAR_SAVE_PROBE,))
lich_state = make_state(lich, "monsters", (1, 1))
foe_state = make_state(foe, "players", (1, 2)) # 5 ft, inside aura range
engine = make_engine([21], [lich_state, foe_state]) # 21 + 0 + 1 = 22 >= DC 22
engine._apply_fear_aura(lich_state)
assert not any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
def test_fear_aura_without_fear_bonus_fails_save() -> None:
"""Without the +1, the same d20 fails the aura save and applies shaken."""
lich = _make_fear_aura_lich()
foe = make_combatant("foe", will=0)
lich_state = make_state(lich, "monsters", (1, 1))
foe_state = make_state(foe, "players", (1, 2))
engine = make_engine([21], [lich_state, foe_state]) # 21 < DC 22
engine._apply_fear_aura(lich_state)
assert any(ac.condition.name == "shaken" for ac in foe_state.active_conditions)
# ── End-to-end: spell save descriptor threading ───────────────────────────────
def _make_caster(cid: str = "caster") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=5,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
casting_ability="int",
spell_slots={0: 99, 1: 99, 2: 99, 3: 99},
)
def _make_target(
cid: str = "target", *, features: tuple[AbilitySpec, ...] = ()
) -> 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=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=30,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
features=list(features),
)
def _make_spell_engine(
caster: Combatant, target: Combatant, queue: list[int]
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=(3, 0), hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
def test_hold_person_save_carries_mind_affecting_descriptor() -> None:
"""Hold Person (enchantment) save carries 'mind_affecting': trait +2 flips it.
Caster level 5, INT 18 -> DC = 10 + 3 (level) + 4 (mod) = 17. A d20 of 13
vs will 2 is 15 (< 17, fails) without the trait and 17 with it (saves).
"""
caster = _make_caster()
target = _make_target(features=(HALF_FORGOTTEN_SECRETS,))
engine, c, t = _make_spell_engine(caster, target, [13])
engine._cast_spell(c, t, "Hold Person")
assert not any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
def test_hold_person_without_bonus_fails_save() -> None:
"""Without the trait the same d20 fails the Hold Person save."""
caster = _make_caster()
target = _make_target()
engine, c, t = _make_spell_engine(caster, target, [13])
engine._cast_spell(c, t, "Hold Person")
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
# ── Spell save descriptor derivation ──────────────────────────────────────────
@pytest.mark.parametrize(
("spell_name", "expected"),
[
("Lightning Bolt", frozenset({"electricity"})),
("Fear", frozenset({"fear"})),
("Fireball", frozenset({"fire"})),
("Hold Person", frozenset({"mind_affecting"})),
("Bull's Strength", frozenset()),
],
)
def test_spell_save_descriptors_derivation(spell_name: str, expected: frozenset[str]) -> None:
"""Damage types, fear conditions and school tags map to save descriptors."""
reg = load_spell_registry(_SPELLS_DIR)
assert spell_save_descriptors(reg[spell_name]) == expected