fix(rules): require a ranged touch attack for ray spells
PF1e CRB (Magic - Ranged Touch Attacks): spells delivered by ray require a ranged touch attack (BAB + Dex vs touch AC, no range increment) to hit a hostile target; a miss wastes the slot. SpellSpec gains requires_ranged_touch, set on the 8 SRD-confirmed rays (Ray of Frost, Ray of Enfeeblement, Scorching Ray, Acid Arrow, Acid Splash, Disrupt Undead, Shard of Chaos, Spear of Purity). Magic Missile and area spells are unaffected. Simplification (documented): no size modifier / Weapon Finesse on the roll. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -745,7 +745,20 @@ Règles modélisées :
|
|||||||
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
|
`resolve_attack`) ; en cas d'échec le sort n'a aucun effet (l'emplacement
|
||||||
est brûlé). Simplifications documentées : Weapon Finesse et les autres
|
est brûlé). Simplifications documentées : Weapon Finesse et les autres
|
||||||
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
|
bonus situationnels de jet de contact ne sont pas modélisés. Une cible
|
||||||
volontaire (soi ou un allié du même camp) ne demande aucun jet. Un sort ciblé avec sauvegarde fait pleins dégâts
|
volontaire (soi ou un allié du même camp) ne demande aucun jet. Sorts à
|
||||||
|
contact à distance (CRB « Magic — Ranged Touch Attacks ») : un sort dont le
|
||||||
|
JSON déclare `requires_ranged_touch: true` (les rayons du SRD — Ray of
|
||||||
|
Frost, Ray of Enfeeblement, Scorching Ray, Acid Arrow, Acid Splash, Disrupt
|
||||||
|
Undead, Shard of Chaos, Spear of Purity) impose une attaque de contact à
|
||||||
|
distance contre une cible **hostile** (hors effet de zone) — d20 + BAB + mod
|
||||||
|
de **Dextérité** du lanceur contre la CA de contact de la cible (amputée du
|
||||||
|
modificateur de Dextérité si la cible perd son bonus de Dextérité, même
|
||||||
|
chemin que `resolve_attack`) ; la portée du sort est une valeur absolue, il
|
||||||
|
n'y a donc **pas** de pénalité par incrément de portée ; en cas d'échec le
|
||||||
|
sort n'a aucun effet (l'emplacement est brûlé). Simplifications documentées
|
||||||
|
(miroir du contact de mêlée) : le modificateur de taille et Weapon Finesse
|
||||||
|
ne sont pas modélisés. Magic Missile (touche automatiquement), les sorts de
|
||||||
|
zone et les sorts à sauvegarde simple ne sont pas concernés. Un sort ciblé avec sauvegarde fait pleins dégâts
|
||||||
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
||||||
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
||||||
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
||||||
@@ -1127,7 +1140,8 @@ la lecture mais non appliquée dans la résolution.
|
|||||||
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
|
||||||
`AbilitySpec` réels ou des « no-op » tracés.
|
`AbilitySpec` réels ou des « no-op » tracés.
|
||||||
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
|
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
|
||||||
sauvegarde, DC de base, temps d'incantation `cast_time`) et types d'effets
|
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
|
||||||
|
(rayons), temps d'incantation `cast_time`) et types d'effets
|
||||||
(`DamageEffect`, `ConditionEffect`,
|
(`DamageEffect`, `ConditionEffect`,
|
||||||
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium,
|
||||||
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "medium",
|
"range": "medium",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "fort",
|
"save": "fort",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "none",
|
"save": "none",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
"description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
"description": "A dart-shaped projectile of chaotic energy (ranged touch attack): lawful creatures take 1d8 per two caster levels (max 5d8); lawful outsiders take 1d6 per caster level (max 10d6) and are slowed 1 round. A successful Will save halves the damage and negates the slow. Half damage to neutral creatures, no effect on chaotic ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
|
"requires_ranged_touch": true,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
"description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
"description": "A spear of good energy (ranged touch attack): evil creatures take 1d8 per two caster levels (max 5d8); evil outsiders take 1d6 per caster level (max 10d6) and are blinded 1 round. A successful Will save halves the damage and negates the blind. Half damage to neutral creatures, no effect on good ones. No combat effect encoded: the engine has no alignment, so the alignment-gated damage cannot be represented."
|
||||||
|
|||||||
@@ -4698,8 +4698,9 @@ class CombatEngine:
|
|||||||
has_area: bool,
|
has_area: bool,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Delivery gates: a hostile touch spell must land its melee touch
|
"""Delivery gates: a hostile touch spell must land its melee touch
|
||||||
attack (B14), then spell resistance must be overcome. True = no
|
attack (B14) and a hostile ranged touch spell (ray) must land its
|
||||||
effect (the slot is already spent)."""
|
ranged touch attack, then spell resistance must be overcome. True =
|
||||||
|
no effect (the slot is already spent)."""
|
||||||
if (
|
if (
|
||||||
target is not None
|
target is not None
|
||||||
and spell.range == "touch"
|
and spell.range == "touch"
|
||||||
@@ -4707,6 +4708,14 @@ class CombatEngine:
|
|||||||
and not self._spell_touch_attack_hits(caster, target)
|
and not self._spell_touch_attack_hits(caster, target)
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
if (
|
||||||
|
target is not None
|
||||||
|
and spell.requires_ranged_touch
|
||||||
|
and not has_area
|
||||||
|
and target.side != caster.side
|
||||||
|
and not self._spell_ranged_touch_attack_hits(caster, target)
|
||||||
|
):
|
||||||
|
return True
|
||||||
return (
|
return (
|
||||||
target is not None
|
target is not None
|
||||||
and not has_area
|
and not has_area
|
||||||
@@ -4744,6 +4753,39 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return hit
|
return hit
|
||||||
|
|
||||||
|
def _spell_ranged_touch_attack_hits(
|
||||||
|
self, caster: CombatantState, target: CombatantState
|
||||||
|
) -> bool:
|
||||||
|
"""Ranged touch attack to deliver a ray spell.
|
||||||
|
|
||||||
|
PF1e (CRB, Magic — Ranged Touch Attacks): d20 + caster's ranged attack
|
||||||
|
bonus vs the target's touch AC; the spell's range is a single absolute
|
||||||
|
value so there is no range-increment penalty. Engine simplification
|
||||||
|
(documented): the bonus is BAB + Dexterity modifier — size modifier,
|
||||||
|
Weapon Finesse and other situational bonuses are not modeled; natural
|
||||||
|
20 hits and natural 1 misses per the engine's attack-roll convention.
|
||||||
|
The touch AC reuses the resolve_attack denies-Dex path (flat-footed/
|
||||||
|
running target, invisible attacker).
|
||||||
|
"""
|
||||||
|
roll = self._rng.d20()
|
||||||
|
bonus = caster.combatant.bab + caster.combatant.abilities.dex_mod
|
||||||
|
denies_dex = (
|
||||||
|
self.has_condition_flag(target, "denies_dex_to_ac")
|
||||||
|
or target.running
|
||||||
|
or self._has_condition_named(caster, "invisible")
|
||||||
|
)
|
||||||
|
if denies_dex:
|
||||||
|
ac = target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod)
|
||||||
|
else:
|
||||||
|
ac = target.combatant.ac.touch
|
||||||
|
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and roll + bonus >= ac)
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {caster.combatant.id}: ranged touch attack vs "
|
||||||
|
f"{target.combatant.id} d20={roll}+{bonus}={roll + bonus} AC {ac} "
|
||||||
|
f"-> {'HIT' if hit else 'MISS'}"
|
||||||
|
)
|
||||||
|
return hit
|
||||||
|
|
||||||
def _spell_cast_rejected(
|
def _spell_cast_rejected(
|
||||||
self,
|
self,
|
||||||
caster: CombatantState,
|
caster: CombatantState,
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ class SpellSpec:
|
|||||||
range: SpellRange = "medium"
|
range: SpellRange = "medium"
|
||||||
save: SaveType = "none"
|
save: SaveType = "none"
|
||||||
base_dc: int = 10
|
base_dc: int = 10
|
||||||
|
# PF1e (CRB, Magic — Ranged Touch Attacks): the spell requires a ranged
|
||||||
|
# touch attack to deliver (rays). Auto-hit spells (Magic Missile) and
|
||||||
|
# area/save-only spells keep this False.
|
||||||
|
requires_ranged_touch: bool = False
|
||||||
cast_time: CastTime = "standard"
|
cast_time: CastTime = "standard"
|
||||||
effects: tuple[SpellEffect, ...] = ()
|
effects: tuple[SpellEffect, ...] = ()
|
||||||
description: str = ""
|
description: str = ""
|
||||||
@@ -304,6 +308,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
|||||||
range=range_raw,
|
range=range_raw,
|
||||||
save=save_raw,
|
save=save_raw,
|
||||||
base_dc=_as_int(data.get("base_dc", 10)),
|
base_dc=_as_int(data.get("base_dc", 10)),
|
||||||
|
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
|
||||||
cast_time=cast_time,
|
cast_time=cast_time,
|
||||||
effects=effects,
|
effects=effects,
|
||||||
description=_as_str(data.get("description", "")),
|
description=_as_str(data.get("description", "")),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
|
|||||||
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
|
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
|
||||||
creature requires a melee touch attack (miss = spell wasted); a willing
|
creature requires a melee touch attack (miss = spell wasted); a willing
|
||||||
target (self/ally) needs no roll.
|
target (self/ally) needs no roll.
|
||||||
|
Ranged touch (CRB, Magic, Ranged Touch Attacks): a spell with
|
||||||
|
requires_ranged_touch (a ray) must land a ranged touch attack (BAB + Dex
|
||||||
|
vs touch AC, no range increment) on a hostile target; auto-hit spells
|
||||||
|
like Magic Missile need no roll.
|
||||||
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
|
B24 (CRB, Concentration): casting defensively forgoes the AoO but requires
|
||||||
a DC 15 + 2 x spell level check; casting while grappled requires
|
a DC 15 + 2 x spell level check; casting while grappled requires
|
||||||
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
|
DC 10 + grappler's CMB + spell level; Combat Casting's +4 applies to
|
||||||
@@ -31,9 +35,11 @@ from pf1e_simulator.models import (
|
|||||||
)
|
)
|
||||||
from pf1e_simulator.rng import ScriptedRng
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
from pf1e_simulator.spells import (
|
from pf1e_simulator.spells import (
|
||||||
|
SPELLS_DIR,
|
||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
SpellSpec,
|
SpellSpec,
|
||||||
|
load_spell,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
|
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
|
||||||
@@ -66,7 +72,16 @@ _GRACE = SpellSpec(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE}
|
_RAY = SpellSpec(
|
||||||
|
name="Ray",
|
||||||
|
level=1,
|
||||||
|
school="evocation",
|
||||||
|
range="close",
|
||||||
|
save="none",
|
||||||
|
requires_ranged_touch=True,
|
||||||
|
effects=(DamageEffect(formula=parse_dice("1d6"), types=("cold",)),),
|
||||||
|
)
|
||||||
|
_REGISTRY = {"Bolt": _BOLT, "Zap": _ZAP, "Grace": _GRACE, "Ray": _RAY}
|
||||||
|
|
||||||
|
|
||||||
def _caster(
|
def _caster(
|
||||||
@@ -150,11 +165,15 @@ def _foe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
|
def _grid() -> Grid:
|
||||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
return Grid.from_spec(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(states: list[CombatantState], queue: list[int]) -> CombatEngine:
|
||||||
return CombatEngine(
|
return CombatEngine(
|
||||||
ScriptedRng(queue), Grid.from_spec(spec), states, spell_registry=_REGISTRY
|
ScriptedRng(queue), _grid(), states, spell_registry=_REGISTRY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -330,6 +349,79 @@ class TestHostileTouchDelivery:
|
|||||||
assert c.active_conditions[0].condition.name == "dazzled"
|
assert c.active_conditions[0].condition.name == "dazzled"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRangedTouchDelivery:
|
||||||
|
"""Ranged touch attacks (CRB, Magic): a ray spell requires a ranged touch
|
||||||
|
attack (BAB + Dex vs touch AC); auto-hit spells like Magic Missile do not."""
|
||||||
|
|
||||||
|
def test_missed_ranged_touch_attack_wastes_the_spell(self) -> None:
|
||||||
|
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Dex 10)
|
||||||
|
# When: Ray is cast with an attack roll of 5 (total 6)
|
||||||
|
# Then: the spell misses, deals no damage, and the slot is spent.
|
||||||
|
c = _state(_caster(), "players", (0, 0))
|
||||||
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
|
||||||
|
engine._cast_spell(c, f, "Ray")
|
||||||
|
assert f.hp == 100
|
||||||
|
assert c.spell_slots_left[1] == 0
|
||||||
|
assert any(
|
||||||
|
"ranged touch attack vs foe" in line and "MISS" in line
|
||||||
|
for line in engine._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_successful_ranged_touch_attack_delivers_the_spell(self) -> None:
|
||||||
|
# Given: the same enemy
|
||||||
|
# When: the ranged touch attack roll is 10 (total 11 >= 10)
|
||||||
|
# Then: the ray lands for its 1d6.
|
||||||
|
c = _state(_caster(), "players", (0, 0))
|
||||||
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
|
engine = _engine([c, f], [10, 4])
|
||||||
|
engine._cast_spell(c, f, "Ray")
|
||||||
|
assert f.hp == 100 - 4
|
||||||
|
assert any(
|
||||||
|
"ranged touch attack vs foe" in line and "HIT" in line
|
||||||
|
for line in engine._transcript
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ranged_touch_on_ally_needs_no_attack_roll(self) -> None:
|
||||||
|
# Given: an ally on the caster's side
|
||||||
|
# When: a ray is cast on them with an EMPTY die queue
|
||||||
|
# Then: no attack roll is consumed and the effect lands (willing).
|
||||||
|
c = _state(_caster(), "players", (0, 0))
|
||||||
|
ally = _state(_foe(), "players", (0, 1))
|
||||||
|
engine = _engine([c, ally], [4])
|
||||||
|
engine._cast_spell(c, ally, "Ray")
|
||||||
|
assert ally.hp == 100 - 4
|
||||||
|
assert not any("ranged touch attack" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_magic_missile_still_auto_hits(self) -> None:
|
||||||
|
# Given: the real Magic Missile spell (no ranged touch requirement)
|
||||||
|
# When: cast with only a damage roll in the queue
|
||||||
|
# Then: no attack roll is consumed — the missile always hits.
|
||||||
|
missile = load_spell(SPELLS_DIR / "magic_missile.json")
|
||||||
|
assert missile.requires_ranged_touch is False
|
||||||
|
c = _state(_caster(), "players", (0, 0))
|
||||||
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
||||||
|
engine = CombatEngine(
|
||||||
|
ScriptedRng([3]), # 1d4+1 -> 4, no attack roll before it
|
||||||
|
_grid(),
|
||||||
|
[c, f],
|
||||||
|
spell_registry={"Magic Missile": missile},
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, f, "Magic Missile")
|
||||||
|
assert f.hp == 100 - 4
|
||||||
|
assert not any("ranged touch attack" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
def test_requires_ranged_touch_parses_from_spell_json(self) -> None:
|
||||||
|
# Given: the shipped spell data
|
||||||
|
# When: ray spells and non-rays are loaded
|
||||||
|
# Then: the flag reflects the SRD delivery (rays True, others False).
|
||||||
|
assert load_spell(SPELLS_DIR / "ray_of_frost.json").requires_ranged_touch
|
||||||
|
assert load_spell(SPELLS_DIR / "scorching_ray.json").requires_ranged_touch
|
||||||
|
assert load_spell(SPELLS_DIR / "acid_arrow.json").requires_ranged_touch
|
||||||
|
assert load_spell(SPELLS_DIR / "fireball.json").requires_ranged_touch is False
|
||||||
|
assert SpellSpec(name="X", level=0, school="evocation").requires_ranged_touch is False
|
||||||
|
|
||||||
|
|
||||||
class TestDefensiveAndGrappleCasting:
|
class TestDefensiveAndGrappleCasting:
|
||||||
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
|
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
|
||||||
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
|
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
|
||||||
|
|||||||
Reference in New Issue
Block a user