feat(conditions): add condition system with 18 PF1e conditions
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user