fix(rules): entangled casting needs DC 15+level concentration; add -4 dex_skills
This commit is contained in:
@@ -3635,17 +3635,31 @@ class CombatEngine:
|
|||||||
return 10 + damage_taken + spell_level
|
return 10 + damage_taken + spell_level
|
||||||
return 15 + 2 * 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(
|
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:
|
) -> bool:
|
||||||
"""Concentration check: DC depends on situation.
|
"""Concentration check: DC depends on situation.
|
||||||
|
|
||||||
PF1e (CRB): the check is d20 + caster level + the ability modifier
|
PF1e (CRB): the check is d20 + caster level + the ability modifier
|
||||||
used to determine bonus spells (Int/Wis/Cha) — NOT Constitution.
|
used to determine bonus spells (Int/Wis/Cha) — NOT Constitution.
|
||||||
Combat Casting's +4 applies only when casting defensively or while
|
Combat Casting's +4 applies only when casting defensively, while
|
||||||
grappled/pinned, never for damage taken while casting.
|
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
|
injured_while_casting = damage_taken > 0 and caster.grappled_with is None
|
||||||
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
|
has_combat_casting = self._has_feat(caster, _COMBAT_CASTING_FEAT)
|
||||||
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
bonus = 0 if injured_while_casting else (4 if has_combat_casting else 0)
|
||||||
@@ -5836,6 +5850,18 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
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)
|
return self._check_casting_aoo(caster, spell_level)
|
||||||
|
|
||||||
def _spell_delivery_blocked(
|
def _spell_delivery_blocked(
|
||||||
|
|||||||
@@ -122,13 +122,17 @@ EXHAUSTED = Condition(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Entangled: -2 attack, -4 DEX; half speed; can't run or charge (CRB)
|
# 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(
|
ENTANGLED = Condition(
|
||||||
name="entangled",
|
name="entangled",
|
||||||
modifiers=(
|
modifiers=(
|
||||||
StatModifier(target="attack", value=-2),
|
StatModifier(target="attack", value=-2),
|
||||||
StatModifier(target="ac", value=-2),
|
StatModifier(target="ac", value=-2),
|
||||||
StatModifier(target="ref", value=-2),
|
StatModifier(target="ref", value=-2),
|
||||||
|
StatModifier(target="dex_skills", value=-4), # -4 to Dex skills
|
||||||
),
|
),
|
||||||
cant_charge=True,
|
cant_charge=True,
|
||||||
speed_mult=0.5,
|
speed_mult=0.5,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pf1e_simulator.abilities import AbilitySpec
|
from pf1e_simulator.abilities import AbilitySpec
|
||||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.conditions import ENTANGLED
|
||||||
from pf1e_simulator.dice import parse_dice
|
from pf1e_simulator.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import MapSpec, TerrainType
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
@@ -175,3 +176,46 @@ class TestCombatCastingPlacement:
|
|||||||
grappler = _combatant("grappler", cmb=3)
|
grappler = _combatant("grappler", cmb=3)
|
||||||
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
|
engine, c, _ = _make_engine(caster, grappler, [5], grappled=True)
|
||||||
assert engine._check_concentration(c, 1, damage_taken=0) is 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
|
||||||
|
|||||||
Reference in New Issue
Block a user