From 317f91a07f388469be77618c6f7020608f0ea8c9 Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 19:17:10 +0200 Subject: [PATCH] fix(rules): entangled casting needs DC 15+level concentration; add -4 dex_skills --- src/pf1e_simulator/combat.py | 34 +++++++++++++++++++++--- src/pf1e_simulator/conditions.py | 6 ++++- tests/test_concentration.py | 44 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 25176e7..ab758df 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3635,17 +3635,31 @@ class CombatEngine: return 10 + damage_taken + spell_level return 15 + 2 * spell_level + def _entangled_concentration_dc(self, spell_level: int) -> int: + """SRD Table 9-1: "Entangled while casting" => DC 15 + spell level.""" + return 15 + spell_level + def _check_concentration( - self, caster: CombatantState, spell_level: int, damage_taken: int + self, + caster: CombatantState, + spell_level: int, + damage_taken: int, + *, + dc_override: int | None = None, ) -> bool: """Concentration check: DC depends on situation. PF1e (CRB): the check is d20 + caster level + the ability modifier used to determine bonus spells (Int/Wis/Cha) — NOT Constitution. - Combat Casting's +4 applies only when casting defensively or while - grappled/pinned, never for damage taken while casting. + Combat Casting's +4 applies only when casting defensively, while + grappled/pinned, or while entangled, never for damage taken while + casting. ``dc_override`` pins the DC (used by the entangled gate). """ - dc = self._concentration_dc(caster, spell_level, damage_taken) + dc = ( + self._concentration_dc(caster, spell_level, damage_taken) + if dc_override is None + else dc_override + ) injured_while_casting = damage_taken > 0 and caster.grappled_with is None has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT) bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0) @@ -5836,6 +5850,18 @@ class CombatEngine: ) return True return False + if self._has_condition_named(caster, "entangled"): + # PF1e (SRD Table 9-1 Concentration Check DCs): "Entangled while + # casting" requires a DC 15 + spell level check or the spell is + # lost. Combat Casting's +4 applies (not an injury check). + dc = self._entangled_concentration_dc(spell_level) + if not self._check_concentration(caster, spell_level, 0, dc_override=dc): + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"casting while entangled: concentration failed (DC {dc}), " + f"spell lost" + ) + return True return self._check_casting_aoo(caster, spell_level) def _spell_delivery_blocked( diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index e5a4594..938201d 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -122,13 +122,17 @@ EXHAUSTED = Condition( ) # Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB) -# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX) +# Approximation: -2 attack, -2 AC (from -4 DEX), -2 ref (from -4 DEX), +# -4 to Dexterity skills (mirrors grappled/pinned). Casting while entangled +# requires a DC 15 + spell level concentration check — enforced in +# ``CombatEngine._spell_cast_interrupted`` (SRD Table 9-1). ENTANGLED = Condition( name="entangled", modifiers=( StatModifier(target="attack", value=-2), StatModifier(target="ac", value=-2), StatModifier(target="ref", value=-2), + StatModifier(target="dex_skills", value=-4), # -4 to Dex skills ), cant_charge=True, speed_mult=0.5, diff --git a/tests/test_concentration.py b/tests/test_concentration.py index 37c0dcb..07eec70 100644 --- a/tests/test_concentration.py +++ b/tests/test_concentration.py @@ -15,6 +15,7 @@ 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 @@ -175,3 +176,46 @@ class TestCombatCastingPlacement: 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