Files

317 lines
11 KiB
Python

"""Tests for PF1e conditions: stat modifiers applied to attack/AC/saves."""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import (
BLINDED,
DAZZLED,
ENTANGLED,
FATIGUED,
FLAT_FOOTED,
FRIGHTENED,
SHAKEN,
SICKENED,
STUNNED,
)
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
def _make_hero(cid: str = "hero") -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
damage_bonus=2,
)
return Combatant(
id=cid,
name=cid,
level=1,
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],
)
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
# ── Condition data: modifiers and flags ──────────────────────────────────────
class TestConditionData:
"""Given: the predefined condition constants
When: inspecting their modifiers and flags
Then: they match the CRB rules."""
def test_shaken_modifiers(self) -> None:
targets = {m.target for m in SHAKEN.modifiers}
assert targets == {"attack", "fort", "ref", "will"}
assert all(m.value == -2 for m in SHAKEN.modifiers)
def test_sickened_modifiers(self) -> None:
targets = {m.target for m in SICKENED.modifiers}
assert targets == {"attack", "damage", "fort", "ref", "will"}
assert all(m.value == -2 for m in SICKENED.modifiers)
def test_dazzled_only_attack(self) -> None:
assert len(DAZZLED.modifiers) == 1
assert DAZZLED.modifiers[0].target == "attack"
assert DAZZLED.modifiers[0].value == -1
def test_entangled_flags(self) -> None:
assert ENTANGLED.cant_charge is True
assert ENTANGLED.speed_mult == 0.5
def test_fatigued_flags(self) -> None:
assert FATIGUED.cant_charge is True
def test_flat_footed_flags(self) -> None:
assert FLAT_FOOTED.denies_dex_to_ac is True
assert FLAT_FOOTED.cant_aoo is True
def test_stunned_flags(self) -> None:
assert STUNNED.cant_act is True
assert STUNNED.denies_dex_to_ac is True
def test_frightened_flags(self) -> None:
assert FRIGHTENED.must_flee is True
def test_blinded_flags(self) -> None:
assert BLINDED.denies_dex_to_ac is True
# ── Condition applied to attack rolls ────────────────────────────────────────
class TestConditionOnAttack:
"""Given: an attacker with a condition that penalizes attack
When: resolve_attack is called
Then: the penalty is applied to the attack total."""
def test_shaken_attacker_has_minus2_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SHAKEN)
result = engine.resolve_attack(h, f, hero.attacks[0])
# base_bonus=5, roll=10, flank=0, penalty=0, attack_mod=-2
assert result.total == 10 + 5 - 2 # 13
def test_dazzled_attacker_has_minus1_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(DAZZLED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.total == 10 + 5 - 1 # 14
def test_sickened_attacker_has_minus2_attack(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SICKENED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.total == 10 + 5 - 2 # 13
def test_no_condition_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
# ── Condition applied to damage rolls ────────────────────────────────────────
class TestConditionOnDamage:
"""Given: an attacker with a condition that penalizes damage
When: resolve_attack hits
Then: the penalty is applied to damage."""
def test_sickened_attacker_damage_reduced_by_2(self) -> None:
hero = _make_hero()
foe = _make_foe()
# roll=15 (hit), damage die=4
engine, h, f = _make_engine(hero, foe, [15, 4])
h.conditions.append(SICKENED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.hit is True
# damage = 4 (die) + 2 (bonus) - 2 (sickened) = 4
assert result.damage == 4
def test_no_condition_normal_damage(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [15, 4])
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.hit is True
# damage = 4 (die) + 2 (bonus) = 6
assert result.damage == 6
# ── Condition applied to AC ──────────────────────────────────────────────────
class TestConditionOnAC:
"""Given: a defender with a condition that modifies AC
When: resolve_attack is called
Then: the AC is adjusted accordingly."""
def test_stunned_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(STUNNED)
result = engine.resolve_attack(h, f, hero.attacks[0])
# foe AC 15 - 2 (stunned) = 13
assert result.ac == 13
def test_blinded_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(BLINDED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
def test_entangled_defender_minus2_ac(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
f.conditions.append(ENTANGLED)
result = engine.resolve_attack(h, f, hero.attacks[0])
assert result.ac == 15 - 2 # 13
# ── Condition applied to saving throws ───────────────────────────────────────
class TestConditionOnSaves:
"""Given: a combatant with a condition that penalizes saves
When: resolve_save is called
Then: the penalty is applied to the save total."""
def test_shaken_minus2_to_all_saves(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10, 10, 10])
h.conditions.append(SHAKEN)
fort = engine.resolve_save(h, "fort", dc=12)
assert fort.total == 10 + 5 - 2 # 13
ref = engine.resolve_save(h, "ref", dc=12)
assert ref.total == 10 + 4 - 2 # 12
will = engine.resolve_save(h, "will", dc=12)
assert will.total == 10 + 3 - 2 # 11
def test_sickened_minus2_to_all_saves(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10])
h.conditions.append(SICKENED)
result = engine.resolve_save(h, "will", dc=12)
assert result.total == 10 + 3 - 2 # 11
def test_entangled_minus2_ref_only(self) -> None:
hero = _make_hero()
engine, h, _ = _make_engine(hero, _make_foe(), [10, 10])
h.conditions.append(ENTANGLED)
ref = engine.resolve_save(h, "ref", dc=12)
assert ref.total == 10 + 4 - 2 # 12
fort = engine.resolve_save(h, "fort", dc=12)
assert fort.total == 10 + 5 # 15, no penalty to fort
# ── Effects + conditions together ────────────────────────────────────────────
class TestEffectsWithConditions:
"""Given: a combatant with both a transient effect and a condition
When: resolve_attack or resolve_save is called
Then: both modifiers are collected and stacking rules apply."""
def test_effect_and_condition_stack_untyped(self) -> None:
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10, 4])
h.conditions.append(SHAKEN) # -2 untyped to attack
h.effects.append(StatModifier(target="attack", value=1)) # +1 untyped
result = engine.resolve_attack(h, f, hero.attacks[0])
# 10 + 5 + (-2 + 1) = 14
assert result.total == 14
def test_two_shaken_conditions_do_not_double(self) -> None:
"""Two identical conditions — their untyped modifiers stack (PF1e:
same condition doesn't worsen, but our model sums untyped modifiers).
This test documents current behavior: untyped modifiers from
multiple conditions sum. A future 'condition stacking' rule could
deduplicate by condition name."""
hero = _make_hero()
foe = _make_foe()
engine, h, f = _make_engine(hero, foe, [10])
h.conditions.append(SHAKEN)
h.conditions.append(SHAKEN)
result = engine.resolve_attack(h, f, hero.attacks[0])
# Both -2 untyped penalties stack: 10 + 5 - 4 = 11
assert result.total == 11