fix(combat): correct concentration DCs by situation

This commit is contained in:
ctan
2026-08-31 13:35:34 +02:00
parent a88187db20
commit f1cf353a4d
2 changed files with 139 additions and 4 deletions
+24 -4
View File
@@ -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()
+115
View File
@@ -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