"""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.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