Files
pf1e-simulator/tests/test_concentration.py
T

222 lines
9.3 KiB
Python

"""Tests for situation-dependent concentration check DCs (Todo 4).
The concentration DC depends on the casting situation (CRB Table 9-1):
- injured while casting: DC = 10 + damage_taken + spell_level
- grappled or pinned: DC = 10 + grappler's CMB + spell_level
- casting defensively: DC = 15 + 2 * spell_level
The check roll = d20 + caster level + the casting ability modifier used to
determine bonus spells (Int/Wis/Cha) — NOT Constitution (PF1e conformance
fix B2). Combat Casting's +4 applies only when casting defensively or while
grappled — never for damage taken while casting.
"""
from __future__ import annotations
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.conditions import ENTANGLED
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 _combatant(
cid: str,
*,
level: int = 5,
con: int = 12,
cmb: int | None = None,
casting_ability: str = "wis",
int_score: int = 10,
wis_score: int = 10,
cha_score: int = 10,
combat_casting: bool = False,
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
features = (
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=con,
int_score=int_score,
wis_score=wis_score,
cha_score=cha_score,
),
hp_max=30,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
cmb=cmb,
casting_ability=casting_ability,
features=features,
)
def _make_engine(
caster: Combatant,
grappler: Combatant,
queue: list[int],
*,
grappled: bool = False,
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
g = CombatantState(combatant=grappler, side="monsters", pos=(1, 0), hp=grappler.hp_max)
if grappled:
c.grappled_with = grappler.id
g.grappled_with = caster.id
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 4] * 4), legend=legend)
grid = Grid.from_spec(spec)
engine = CombatEngine(ScriptedRng(queue), grid, [c, g])
return engine, c, g
class TestConcentrationDc:
"""Given: a caster making a concentration check in a given situation
When: the situation DC is computed
Then: it matches the CRB rule for that situation."""
def test_dc_when_injured_is_ten_plus_damage_plus_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler")
engine, c, g = _make_engine(caster, grappler, [])
assert engine._concentration_dc(c, 1, damage_taken=4) == 10 + 4 + 1
def test_dc_when_grappled_uses_grappler_cmb_plus_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler", cmb=7)
engine, c, g = _make_engine(caster, grappler, [], grappled=True)
assert engine._concentration_dc(c, 1, damage_taken=0) == 10 + 7 + 1
def test_dc_when_casting_defensively_is_15_plus_twice_level(self) -> None:
caster = _combatant("caster", level=5)
grappler = _combatant("grappler")
engine, c, g = _make_engine(caster, grappler, [])
assert engine._concentration_dc(c, 2, damage_taken=0) == 15 + 2 * 2
def test_check_passes_at_threshold_and_fails_one_below(self) -> None:
caster = _combatant("caster", level=5, con=10)
grappler = _combatant("grappler")
engine_pass, c, _ = _make_engine(caster, grappler, [10])
assert engine_pass._check_concentration(c, 1, damage_taken=4) is True
engine_fail = _make_engine(caster, grappler, [9])[0]
assert engine_fail._check_concentration(c, 1, damage_taken=4) is False
class TestConcentrationUsesCastingAbilityNotCon:
"""B2 (PF1e conformance): the check is d20 + caster level + the ability
modifier used for bonus spells (Int/Wis/Cha) — NOT Constitution."""
def test_high_con_low_casting_ability_fails_where_con_would_pass(self) -> None:
# CON 18 (+4) but casting ability INT 10 (+0): injured DC = 10+4+1 = 15.
# Correct roll: d20 6 + level 5 + int 0 = 11 < 15 -> FAIL.
# The old CON-based check would have given 6 + 5 + 4 = 15 -> pass.
caster = _combatant("caster", level=5, con=18, casting_ability="int")
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is False
def test_charisma_modifier_is_used_for_a_sorcerer(self) -> None:
# CHA 18 (+4), CON 6 (-2), casting ability cha: injured DC = 10+4+1 = 15.
# d20 6 + level 5 + cha 4 = 15 >= 15 -> PASS (CON would give 13 -> fail).
caster = _combatant("caster", level=5, con=6, cha_score=18, casting_ability="cha")
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is True
class TestCombatCastingPlacement:
"""B2 (PF1e conformance): Combat Casting's +4 applies only when casting
defensively or while grappled — never for damage taken while casting."""
def test_combat_casting_does_not_help_when_injured(self) -> None:
# Injured DC = 10+4+1 = 15. d20 6 + level 5 + wis 0 = 11 < 15 -> FAIL.
# The old blanket +4 would have made 15 -> pass.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [6])
assert engine._check_concentration(c, 1, damage_taken=4) is False
def test_combat_casting_helps_when_casting_defensively(self) -> None:
# Defensive DC = 15 + 2*1 = 17. d20 8 + level 5 + wis 0 + 4 = 17 -> PASS.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler")
engine, c, _ = _make_engine(caster, grappler, [8])
assert engine._check_concentration(c, 1, damage_taken=0) is True
def test_combat_casting_helps_while_grappled(self) -> None:
# Grappled DC = 10 + CMB 3 + level 1 = 14. d20 5 + 5 + 0 + 4 = 14 -> PASS.
caster = _combatant("caster", level=5, con=10, combat_casting=True)
grappler = _combatant("grappler", cmb=3)
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
assert engine._check_concentration(c, 1, damage_taken=0) is True
class TestEntangledCasting:
"""SRD Table 9-1: "Entangled while casting" => DC 15 + spell level. The
casting gate must force the check and lose the spell on a failure."""
def _entangled_engine(
self, roll: int, *, combat_casting: bool = False
) -> tuple[CombatEngine, CombatantState]:
caster = _combatant("caster", level=5, combat_casting=combat_casting)
foe = _combatant("foe")
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(3, 3), hp=foe.hp_max)
c.conditions.append(ENTANGLED)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 4] * 4), legend=legend)
engine = CombatEngine(ScriptedRng([roll]), Grid.from_spec(spec), [c, f])
return engine, c
def test_entangled_concentration_dc_is_15_plus_spell_level(self) -> None:
caster = _combatant("caster", level=5)
engine = _make_engine(caster, _combatant("grappler"), [])[0]
assert engine._entangled_concentration_dc(2) == 15 + 2
def test_failed_check_loses_the_spell(self) -> None:
# DC 16 (15 + level 1). d20 10 + level 5 + wis 0 = 15 < 16 -> spell lost.
engine, c = self._entangled_engine(10)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is True
def test_successful_check_keeps_the_spell(self) -> None:
# d20 11 + level 5 + wis 0 = 16 >= 16 -> pass (foe at (3,3) does not
# threaten, so no AoO gate either).
engine, c = self._entangled_engine(11)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
def test_combat_casting_helps_while_entangled(self) -> None:
# d20 7 + level 5 + wis 0 + 4 = 16 >= 16 -> PASS with the feat.
engine, c = self._entangled_engine(7, combat_casting=True)
assert engine._spell_cast_interrupted(c, 1, cast_defensively=False) is False
def test_entangled_carries_minus4_dex_skills(self) -> None:
mods = {m.target: m.value for m in ENTANGLED.modifiers}
assert mods["dex_skills"] == -4