From a7729eebf1be2af5a315de84d02a5e361fae246e Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:52 +0200 Subject: [PATCH] fix(combat): correct concentration DCs by situation --- src/pf1e_simulator/combat.py | 28 +++++++-- tests/test_concentration.py | 115 +++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/test_concentration.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 9675404..45c6a55 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -748,21 +748,41 @@ class CombatEngine: # Concentration check if damaged dmg = hp_before - caster.hp if dmg > 0 and not self._check_concentration(caster, spell_level, dmg): + dc = self._concentration_dc(caster, spell_level, dmg) self._log( f"round {self._current_round} {caster.combatant.id}: " - f"concentration check failed (DC {15 + 2 * spell_level}), spell fizzles" + f"concentration check failed (DC {dc}), spell fizzles" ) return True return False + def _concentration_dc( + self, caster: CombatantState, spell_level: int, damage_taken: int + ) -> int: + """Concentration DC for the caster's current situation. + + - injured while casting: => 10 + damage_taken + spell_level + - grappled or pinned: => 10 + grappler's CMB + spell_level + - casting defensively: => 15 + 2 * spell_level + """ + if caster.grappled_with is not None: + grappler = next( + (s for s in self._states if s.combatant.id == caster.grappled_with), None + ) + cmb = self._maneuver_cmb(grappler) if grappler is not None else 0 + return 10 + cmb + spell_level + if damage_taken > 0: + return 10 + damage_taken + spell_level + return 15 + 2 * spell_level + def _check_concentration( self, caster: CombatantState, spell_level: int, damage_taken: int ) -> bool: - """Concentration check: DC = 15 + 2 * spell_level + damage_taken. + """Concentration check: DC depends on situation. - Combat Casting feat gives +4 bonus. + Roll = d20 + caster_level + casting ability mod (+4 Combat Casting). """ - dc = 15 + 2 * spell_level + damage_taken + dc = self._concentration_dc(caster, spell_level, damage_taken) has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features) bonus = 4 if has_combat_casting else 0 roll = self._rng.d20() diff --git a/tests/test_concentration.py b/tests/test_concentration.py new file mode 100644 index 0000000..5138dbc --- /dev/null +++ b/tests/test_concentration.py @@ -0,0 +1,115 @@ +"""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 + casting ability mod (+4 Combat Casting), +unchanged from the current logic. +""" + +from __future__ import annotations + +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, +) -> Combatant: + attack = AttackSpec( + id=f"{cid}-w", + name="dagger", + kind="melee", + attack_bonus=2, + damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], + ) + return Combatant( + id=cid, + name=cid, + level=level, + size="Medium", + abilities=AbilityScores( + str_score=10, + dex_score=10, + con_score=con, + int_score=10, + wis_score=10, + cha_score=10, + ), + 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, + ) + + +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