317 lines
11 KiB
Python
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
|