816 lines
35 KiB
Python
816 lines
35 KiB
Python
"""PF1e conformance: spell-casting flow fixes B7 / B14 / B24.
|
|
|
|
B7 (CRB, Metamagic Feats): metamagic is chosen PER CAST, costs slot levels
|
|
(Empower +2, Maximize +3, Extend +1, Heighten +1 modeled minimum), and
|
|
only feats the caster knows can apply. The save DC uses the spell's
|
|
BASE level - metamagic does not raise it (SRD empower: "Saving throws
|
|
and opposed rolls are not affected"); only Heighten Spell raises the DC
|
|
to the heightened level (SRD heighten-spell).
|
|
B14 (CRB, Magic, Range: Touch): delivering a touch spell to an unwilling
|
|
creature requires a melee touch attack (a miss HOLDS the charge per SRD
|
|
"Holding the Charge" - the spell survives to be re-attempted; the slot
|
|
is spent at the cast); a willing 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; a miss expends the
|
|
spell (SRD: "ranged touch attacks cannot be held until a later turn");
|
|
auto-hit spells like Magic Missile need no roll.
|
|
B24 (CRB, Concentration): casting defensively forgoes the AoO but 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
|
|
both, never to the injured-while-casting check.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.abilities import AbilitySpec
|
|
from pf1e_simulator.combat import Action, ActionResult, CombatantState, CombatEngine
|
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.effects import ActiveCondition
|
|
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
|
|
from pf1e_simulator.spells import (
|
|
SPELLS_DIR,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
SpellSpec,
|
|
load_spell,
|
|
)
|
|
|
|
# Test spells: one medium damage bolt (no save), one hostile touch zap, one
|
|
# touch condition grace — enough to exercise slot levels, DCs and delivery.
|
|
_BOLT = SpellSpec(
|
|
name="Bolt",
|
|
level=1,
|
|
school="evocation",
|
|
range="medium",
|
|
save="none",
|
|
effects=(DamageEffect(formula=parse_dice("2d4"), types=("force",)),),
|
|
)
|
|
_ZAP = SpellSpec(
|
|
name="Zap",
|
|
level=1,
|
|
school="evocation",
|
|
range="touch",
|
|
save="none",
|
|
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
|
|
)
|
|
_GRACE = SpellSpec(
|
|
name="Grace",
|
|
level=1,
|
|
school="abjuration",
|
|
range="touch",
|
|
save="none",
|
|
effects=(
|
|
ConditionEffect(
|
|
condition_name="dazzled", duration_rounds=2, save_negates=False
|
|
),
|
|
),
|
|
)
|
|
_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(
|
|
cid: str = "caster",
|
|
*,
|
|
level: int = 5,
|
|
bab: int = 1,
|
|
casting_ability: str = "int",
|
|
int_score: int = 18,
|
|
wis_score: int = 10,
|
|
metamagic: list[str] | None = None,
|
|
spell_slots: dict[int, int] | None = None,
|
|
combat_casting: bool = False,
|
|
) -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="dagger",
|
|
kind="melee",
|
|
attack_bonus=2,
|
|
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
|
|
)
|
|
features = (
|
|
[AbilitySpec(name="Combat Casting", category="feat")] if combat_casting else []
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=level,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=12,
|
|
int_score=int_score, wis_score=wis_score, cha_score=10,
|
|
),
|
|
hp_max=30,
|
|
ac=ACProfile(total=12, touch=12, flat_footed=10),
|
|
bab=bab,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=2, ref=3, will=3),
|
|
attacks=[attack],
|
|
casting_ability=casting_ability,
|
|
spell_slots=spell_slots if spell_slots is not None else {1: 1, 2: 1, 3: 1, 4: 1},
|
|
metamagic_feats=metamagic or [],
|
|
features=features,
|
|
)
|
|
|
|
|
|
def _foe(
|
|
cid: str = "foe",
|
|
*,
|
|
touch_ac: int = 10,
|
|
dex_score: int = 10,
|
|
cmb: int | None = None,
|
|
melee: bool = True,
|
|
) -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="club" if melee else "short bow",
|
|
kind="melee" if melee else "ranged",
|
|
attack_bonus=0,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
|
range_increment_ft=None if melee else 60,
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=dex_score, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=100,
|
|
ac=ACProfile(total=12, touch=touch_ac, flat_footed=10),
|
|
bab=1,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
saves=Saves(fort=2, ref=2, will=2),
|
|
attacks=[attack],
|
|
cmb=cmb,
|
|
)
|
|
|
|
|
|
def _grid() -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
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(
|
|
ScriptedRng(queue), _grid(), states, spell_registry=_REGISTRY
|
|
)
|
|
|
|
|
|
def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
|
|
|
|
|
class TestMetamagicPerCast:
|
|
"""B7: metamagic is chosen per cast and costs slot levels, but the save
|
|
DC stays at the spell's base level - only Heighten Spell raises it
|
|
(SRD, Metamagic Feats)."""
|
|
|
|
def test_empower_costs_slot_two_levels_higher_but_dc_stays_base(self) -> None:
|
|
# Given: a caster who knows Empower Spell with a level-3 slot
|
|
# When: casting Bolt (lvl 1) with Empower Spell
|
|
# Then: the level-3 slot burns but the DC is 10 + 1 + int 4 = 15
|
|
# (SRD empower-spell: "Saving throws and opposed rolls are not
|
|
# affected" - the higher slot does not raise the DC).
|
|
caster = _caster(metamagic=["Empower Spell"])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [3, 3]) # 2d4 -> 6, empowered -> 9
|
|
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
|
assert c.spell_slots_left[3] == 0
|
|
assert c.spell_slots_left[1] == 1
|
|
assert f.hp == 100 - 9
|
|
assert any("DC15" in line for line in engine._transcript)
|
|
assert not any("DC17" in line for line in engine._transcript)
|
|
|
|
def test_maximize_costs_slot_three_levels_higher_dc_stays_base(self) -> None:
|
|
# Given: a caster who knows Maximize Spell
|
|
# When: casting Bolt maximized (2d4 max = 8)
|
|
# Then: the level-4 slot burns but the DC is 10 + 1 + 4 = 15.
|
|
caster = _caster(metamagic=["Maximize Spell"])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [1, 1]) # dice overwritten by maximize
|
|
engine._cast_spell(c, f, "Bolt", metamagic=("Maximize Spell",))
|
|
assert c.spell_slots_left[4] == 0
|
|
assert f.hp == 100 - 8
|
|
assert any("DC15" in line for line in engine._transcript)
|
|
assert not any("DC18" in line for line in engine._transcript)
|
|
|
|
def test_extend_costs_one_slot_level_doubles_duration_and_dc_stays_base(self) -> None:
|
|
# Given: a caster who knows Extend Spell, self-targeting a touch buff
|
|
# When: casting Grace (2r) extended
|
|
# Then: the level-2 slot burns and the condition lasts 4 rounds.
|
|
caster = _caster(metamagic=["Extend Spell"])
|
|
c = _state(caster, "players", (0, 0))
|
|
engine = _engine([c], []) # self touch: no attack roll, no save
|
|
engine._cast_spell(c, c, "Grace", metamagic=("Extend Spell",))
|
|
assert c.spell_slots_left[2] == 0
|
|
assert c.spell_slots_left[1] == 1
|
|
assert c.active_conditions[0].rounds_left == 4
|
|
# Extend raises the slot cost but never the DC: a hostile extended
|
|
# Bolt still DCs at its base level.
|
|
c2 = _state(caster, "players", (0, 0))
|
|
f2 = _state(_foe(), "monsters", (3, 0))
|
|
engine2 = _engine([c2, f2], [3, 3])
|
|
engine2._cast_spell(c2, f2, "Bolt", metamagic=("Extend Spell",))
|
|
assert any("DC15" in line for line in engine2._transcript)
|
|
assert not any("DC16" in line for line in engine2._transcript)
|
|
|
|
def test_heighten_spell_raises_the_dc_to_the_heightened_level(self) -> None:
|
|
# Given: a caster who knows Heighten Spell (modeled at its minimum
|
|
# +1 adjustment - the engine's per-cast API carries feat names only)
|
|
# When: casting Bolt (lvl 1) heightened
|
|
# Then: the level-2 slot burns AND the DC is 10 + 2 + int 4 = 16
|
|
# (SRD heighten-spell: "All effects dependent on spell level (such
|
|
# as saving throw DCs [...]) are calculated according to the
|
|
# heightened level").
|
|
caster = _caster(metamagic=["Heighten Spell"])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [3, 3])
|
|
engine._cast_spell(c, f, "Bolt", metamagic=("Heighten Spell",))
|
|
assert c.spell_slots_left[2] == 0
|
|
assert c.spell_slots_left[1] == 1
|
|
assert any("DC16" in line for line in engine._transcript)
|
|
|
|
def test_unknown_metamagic_feat_is_never_applied(self) -> None:
|
|
# Given: a caster who knows NO metamagic
|
|
# When: the action requests Empower Spell anyway
|
|
# Then: the feat is ignored — base slot, base DC, base damage.
|
|
caster = _caster(metamagic=[])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [3, 3])
|
|
engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
|
assert c.spell_slots_left[1] == 0
|
|
assert c.spell_slots_left[3] == 1
|
|
assert f.hp == 100 - 6
|
|
assert any("DC15" in line for line in engine._transcript)
|
|
|
|
def test_known_but_unrequested_feat_is_not_applied(self) -> None:
|
|
# Given: a caster who knows Empower AND Maximize
|
|
# When: casting Bolt with no metamagic requested
|
|
# Then: the base slot burns, base DC, dice are rolled (not maxed).
|
|
caster = _caster(metamagic=["Empower Spell", "Maximize Spell"])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [3, 3])
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert c.spell_slots_left[1] == 0
|
|
assert c.spell_slots_left[3] == 1
|
|
assert c.spell_slots_left[4] == 1
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_cast_rejected_without_a_slot_at_the_used_level(self) -> None:
|
|
# Given: a caster with only a level-1 slot who knows Empower
|
|
# When: an empowered cast needs a level-3 slot
|
|
# Then: the cast is rejected (no_slot), nothing is spent or rolled.
|
|
caster = _caster(metamagic=["Empower Spell"], spell_slots={1: 1})
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [])
|
|
result = engine._cast_spell(c, f, "Bolt", metamagic=("Empower Spell",))
|
|
assert isinstance(result, ActionResult)
|
|
assert result.ok is False
|
|
assert result.reason == "no_slot"
|
|
assert c.spell_slots_left[1] == 1
|
|
assert f.hp == 100
|
|
assert any("NO SPELL SLOT (level 3)" in line for line in engine._transcript)
|
|
|
|
def test_action_metamagic_field_threads_through_execution(self) -> None:
|
|
# Given: a caster with slots who knows Empower
|
|
# When: the cast is issued as an Action carrying metamagic
|
|
# Then: _execute dispatches the empowered cast (level-3 slot burns).
|
|
caster = _caster(metamagic=["Empower Spell"])
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(), "monsters", (3, 0))
|
|
engine = _engine([c, f], [3, 3])
|
|
result = engine._execute(
|
|
c,
|
|
Action(
|
|
kind="cast_spell", target_id="foe", spell_name="Bolt",
|
|
metamagic=("Empower Spell",),
|
|
),
|
|
)
|
|
assert result.ok is True
|
|
assert c.spell_slots_left[3] == 0
|
|
assert f.hp == 100 - 9
|
|
|
|
|
|
class TestHostileTouchDelivery:
|
|
"""B14: a touch spell delivered to an unwilling creature requires a
|
|
melee touch attack (CRB, Magic — Range: Touch)."""
|
|
|
|
def test_missed_touch_attack_holds_the_charge(self) -> None:
|
|
# Given: an enemy with touch AC 10, caster bonus +1 (BAB 1, Str 10)
|
|
# When: Zap is cast with a touch attack roll of 5 (total 6)
|
|
# Then: the spell misses and deals no damage; the slot stays spent
|
|
# but the SPELL survives as a held charge (SRD "Holding the
|
|
# Charge": "If the attack misses, you are still holding the
|
|
# charge" - "you can continue to make touch attacks round after
|
|
# round").
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5]) # only the attack roll — no damage roll
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100
|
|
assert c.spell_slots_left[1] == 0
|
|
assert any("touch attack vs foe" in line and "MISS" in line for line in engine._transcript)
|
|
assert c.held_charge is not None
|
|
assert c.held_charge.spell_name == "Zap"
|
|
assert c.held_charge.target_id == "foe"
|
|
assert any("holds the charge of Zap" in line for line in engine._transcript)
|
|
|
|
def test_held_charge_re_attempt_hits_and_delivers(self) -> None:
|
|
# Given: a caster holding Zap on an adjacent enemy (missed first roll)
|
|
# When: the next-turn re-attempt roll 9 + 1 = 10 meets touch AC 10
|
|
# Then: the spell delivers for its 1d6 and the charge is cleared.
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5, 9, 4]) # miss, hit, 1d6 damage
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert c.held_charge is not None
|
|
engine._resolve_held_charge(c)
|
|
assert f.hp == 100 - 4
|
|
assert c.held_charge is None
|
|
assert any("re-attempts held charge Zap" in line for line in engine._transcript)
|
|
|
|
def test_held_charge_re_attempt_miss_still_holds(self) -> None:
|
|
# Given: the same held charge
|
|
# When: the re-attempt misses too (roll 2 + 1 = 3 < 10)
|
|
# Then: the charge persists for a later turn (no damage rolled).
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5, 2])
|
|
engine._cast_spell(c, f, "Zap")
|
|
engine._resolve_held_charge(c)
|
|
assert f.hp == 100
|
|
assert c.held_charge is not None
|
|
assert c.held_charge.spell_name == "Zap"
|
|
|
|
def test_held_charge_out_of_reach_waits_without_rolling(self) -> None:
|
|
# Given: the held target has left touch range (5 ft)
|
|
# When: the re-attempt runs
|
|
# Then: no attack roll is consumed and the charge is kept.
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5])
|
|
engine._cast_spell(c, f, "Zap")
|
|
f.pos = (5, 5)
|
|
engine._resolve_held_charge(c) # empty rng queue: would raise on a roll
|
|
assert c.held_charge is not None
|
|
|
|
def test_held_charge_dissipates_when_target_dies(self) -> None:
|
|
# Given: the held charge's target died in the meantime
|
|
# When: the re-attempt runs
|
|
# Then: the charge is gone, no roll consumed.
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5])
|
|
engine._cast_spell(c, f, "Zap")
|
|
f.hp = -50
|
|
engine._resolve_held_charge(c)
|
|
assert c.held_charge is None
|
|
assert any("dissipates (target gone)" in line for line in engine._transcript)
|
|
|
|
def test_casting_another_spell_dissipates_the_charge(self) -> None:
|
|
# Given: a caster holding Zap
|
|
# When: the caster casts another spell (SRD: "If you cast another
|
|
# spell, the touch spell dissipates")
|
|
# Then: the charge is gone before the new cast resolves.
|
|
caster = _caster(spell_slots={1: 2})
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [5, 3, 3]) # touch miss, then Bolt 2d4
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert c.held_charge is not None
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert c.held_charge is None
|
|
assert any("dissipates (casting Bolt)" in line for line in engine._transcript)
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_successful_touch_attack_delivers_the_spell(self) -> None:
|
|
# Given: the same enemy
|
|
# When: the touch attack roll is 10 (total 11 >= 10)
|
|
# Then: the zap lands for its 1d6.
|
|
caster = _caster()
|
|
c, f = _state(caster, "players", (0, 0)), _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [10, 4])
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100 - 4
|
|
assert any("HIT" in line for line in engine._transcript)
|
|
|
|
def test_touch_attack_strips_dex_when_target_denies_dex(self) -> None:
|
|
# Given: a flat-footed enemy (Dex 14, touch 13 -> effective AC 11)
|
|
# When: the caster (bonus +1) rolls a 9 (total 10)
|
|
# Then: the attack misses the reduced touch AC.
|
|
caster = _caster()
|
|
foe = _state(_foe(touch_ac=13, dex_score=14, melee=False), "monsters", (0, 1))
|
|
foe.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["flat_footed"],
|
|
rounds_left=1,
|
|
source="test",
|
|
)
|
|
)
|
|
c = _state(caster, "players", (0, 0))
|
|
engine = _engine([c, foe], [9])
|
|
engine._cast_spell(c, foe, "Zap")
|
|
assert foe.hp == 100
|
|
assert any("AC 11" in line for line in engine._transcript)
|
|
|
|
def test_touch_spell_on_ally_needs_no_attack_roll(self) -> None:
|
|
# Given: an ally on the caster's side
|
|
# When: a touch spell is cast on them with an EMPTY die queue
|
|
# Then: no attack roll is consumed and the effect lands.
|
|
caster = _caster()
|
|
ally = _state(_foe(), "players", (0, 1))
|
|
c = _state(caster, "players", (0, 0))
|
|
engine = _engine([c, ally], [])
|
|
engine._cast_spell(c, ally, "Grace")
|
|
assert ally.active_conditions[0].condition.name == "dazzled"
|
|
|
|
def test_self_touch_spell_needs_no_attack_roll(self) -> None:
|
|
# Given: a caster targeting self
|
|
# When: a touch spell is cast with an EMPTY die queue
|
|
# Then: it lands without any attack roll (unchanged behaviour).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
engine = _engine([c], [])
|
|
engine._cast_spell(c, c, "Grace")
|
|
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 -
|
|
# and the spell is NOT held (SRD: "ranged touch attacks cannot be
|
|
# held until a later turn").
|
|
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 c.held_charge is None
|
|
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)
|
|
|
|
|
|
class TestTouchDeliveryDefense:
|
|
"""L3 conformance: a touch/ranged-touch attack is an attack roll, so the
|
|
target keeps its cover bonus (CRB "Cover"), concealment can make it miss
|
|
(CRB "Concealment"), and a damage-dealing touch spell threatens on a
|
|
natural 20 and deals double damage on confirmation (CRB "Cast a Spell —
|
|
Touch Attacks", "Range — Touch")."""
|
|
|
|
def _wall_engine(
|
|
self, queue: list[int]
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
|
"W": TerrainType(type="ruin", move_cost=None, cover_bonus=2),
|
|
}
|
|
grid = Grid.from_spec(
|
|
MapSpec(
|
|
name="cover",
|
|
terrain=("..W..", "..W..", ".#...", "....."),
|
|
legend=legend,
|
|
)
|
|
)
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (1, 4))
|
|
return (
|
|
CombatEngine(ScriptedRng(queue), grid, [c, f], spell_registry=_REGISTRY),
|
|
c,
|
|
f,
|
|
)
|
|
|
|
def test_ranged_touch_terrain_cover_raises_touch_ac(self) -> None:
|
|
# Given: ruins grazing the firing line (+2 partial cover, LoE intact)
|
|
# When: Ray is cast with roll 10 (total 11)
|
|
# Then: the attack is resolved against touch AC 10 + 2 = 12 and misses.
|
|
engine, c, f = self._wall_engine([10])
|
|
engine._cast_spell(c, f, "Ray")
|
|
assert f.hp == 100
|
|
assert any(
|
|
"ranged touch attack vs foe" in line and "AC 12" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_ranged_touch_soft_cover_from_interposed_ally(self) -> None:
|
|
# Given: an ally standing between the caster and the target
|
|
# When: Ray is cast with roll 5 (total 6)
|
|
# Then: the target gains soft cover +4 — resolved vs AC 14, miss.
|
|
c = _state(_caster(), "players", (0, 0))
|
|
ally = _state(_foe(), "players", (0, 1))
|
|
f = _state(_foe(melee=False), "monsters", (0, 2))
|
|
engine = _engine([c, ally, f], [5])
|
|
engine._cast_spell(c, f, "Ray")
|
|
assert f.hp == 100
|
|
assert any(
|
|
"ranged touch attack vs foe" in line and "AC 14" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_concealment_makes_a_landed_touch_spell_miss(self) -> None:
|
|
# Given: a blurred target (20% concealment); the touch roll hits
|
|
# When: the concealment d100 comes up under the miss chance
|
|
# Then: the spell is lost (slot burned, no effect).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
f.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["blurred"],
|
|
rounds_left=5,
|
|
source="test",
|
|
)
|
|
)
|
|
engine = _engine([c, f], [15, 10])
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100
|
|
assert c.spell_slots_left[1] == 0
|
|
assert any(
|
|
"concealment vs foe" in line and "spell lost" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_concealment_high_d100_delivers_the_touch_spell(self) -> None:
|
|
# Given: the same blurred target, hit roll and a high concealment die
|
|
# When: Zap resolves
|
|
# Then: the spell lands for its normal 1d6 (4).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
f.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["blurred"],
|
|
rounds_left=5,
|
|
source="test",
|
|
)
|
|
)
|
|
engine = _engine([c, f], [15, 90, 4])
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100 - 4
|
|
assert any(
|
|
"concealment vs foe" in line and "spell delivered" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_touch_nat20_confirmed_crit_doubles_damage(self) -> None:
|
|
# Given: a damage-dealing touch spell (Zap 1d6), natural 20 threatens
|
|
# When: the confirm roll (10+1 >= 10) succeeds
|
|
# Then: the spell deals double damage (4 -> 8).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [20, 10, 4])
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100 - 8
|
|
assert any(
|
|
"touch attack crit confirm" in line and "-> CRIT" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_touch_crit_failed_confirmation_deals_normal_damage(self) -> None:
|
|
# Given: natural 20 threatens
|
|
# When: the confirm roll is a natural 1 (auto-fail)
|
|
# Then: the spell still hits for normal damage (4).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [20, 1, 4])
|
|
engine._cast_spell(c, f, "Zap")
|
|
assert f.hp == 100 - 4
|
|
assert any(
|
|
"touch attack crit confirm" in line and "-> no crit" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_ranged_touch_nat20_crit_doubles_damage(self) -> None:
|
|
# Given: a damage ray (1d6), natural 20 threatens
|
|
# When: the confirm succeeds
|
|
# Then: double damage (4 -> 8).
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [20, 10, 4])
|
|
engine._cast_spell(c, f, "Ray")
|
|
assert f.hp == 100 - 8
|
|
assert any(
|
|
"ranged touch attack crit confirm" in line and "-> CRIT" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_non_damage_touch_spell_does_not_threaten(self) -> None:
|
|
# Given: a condition-only touch spell (Grace) cast on a natural 20
|
|
# When: the delivery resolves with only the attack die in the queue
|
|
# Then: no confirmation roll is consumed (CRB: criticals only for
|
|
# spells that deal damage) and the condition still lands.
|
|
c = _state(_caster(), "players", (0, 0))
|
|
f = _state(_foe(melee=False), "monsters", (0, 1))
|
|
engine = _engine([c, f], [20])
|
|
engine._cast_spell(c, f, "Grace")
|
|
assert any("dazzled" in line for line in engine._transcript)
|
|
assert not any("crit confirm" 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 damage rolls in the queue (a level-5 caster
|
|
# fires the SRD three missiles, each 1d4+1)
|
|
# Then: no attack roll is consumed — every 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, 3, 3]), # 3 missiles, each 1d4+1 -> 4; no attack roll
|
|
_grid(),
|
|
[c, f],
|
|
spell_registry={"Magic Missile": missile},
|
|
)
|
|
engine._cast_spell(c, f, "Magic Missile")
|
|
assert f.hp == 100 - 12
|
|
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:
|
|
"""B24: casting defensively trades the AoO for a DC 15 + 2x level check;
|
|
a grappled caster must pass DC 10 + grappler CMB + level (CRB,
|
|
Concentration). Combat Casting's +4 applies to both."""
|
|
|
|
def test_defensive_cast_skips_aoo_and_lands_on_passed_check(self) -> None:
|
|
# Given: a threatened caster (adjacent armed enemy)
|
|
# When: Bolt is cast defensively (DC 17) with roll 8 + lvl 5 + int 4
|
|
# Then: no AoO is provoked and the spell resolves.
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(), "monsters", (0, 1))
|
|
engine = _engine([c, f], [8, 3, 3]) # concentration, then 2d4
|
|
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
|
|
assert engine._aoo_used == {}
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_defensive_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
|
|
# Given: the same threatened caster
|
|
# When: the defensive check fails (roll 1 + 5 + 4 = 10 < 17)
|
|
# Then: the spell is lost, the slot spent, no damage roll happens.
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(), "monsters", (0, 1))
|
|
engine = _engine([c, f], [1])
|
|
engine._cast_spell(c, f, "Bolt", cast_defensively=True)
|
|
assert f.hp == 100
|
|
assert c.spell_slots_left[1] == 0
|
|
assert any(
|
|
"casting defensively: concentration failed (DC 17), spell lost" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_combat_casting_helps_the_defensive_check(self) -> None:
|
|
# Given: a wis-based caster (mod 0) with and without Combat Casting
|
|
# When: the defensive check roll is 8 (DC 17)
|
|
# Then: with the feat 8+5+4 = 17 passes; without, 13 fails.
|
|
plain = _caster(casting_ability="wis", int_score=10, combat_casting=False)
|
|
pc = _state(plain, "players", (0, 0))
|
|
pf = _state(_foe(), "monsters", (0, 1))
|
|
engine_fail = _engine([pc, pf], [8])
|
|
engine_fail._cast_spell(pc, pf, "Bolt", cast_defensively=True)
|
|
assert pf.hp == 100
|
|
|
|
feat = _caster(casting_ability="wis", int_score=10, combat_casting=True)
|
|
fc = _state(feat, "players", (0, 0))
|
|
ff = _state(_foe(), "monsters", (0, 1))
|
|
engine_pass = _engine([fc, ff], [8, 3, 3])
|
|
engine_pass._cast_spell(fc, ff, "Bolt", cast_defensively=True)
|
|
assert ff.hp == 100 - 6
|
|
|
|
def test_grappled_caster_must_pass_the_grapple_check(self) -> None:
|
|
# Given: a caster grappled by an enemy with CMB 7 (DC 10+7+1 = 18)
|
|
# When: the check roll is 9 (9 + lvl 5 + int 4 = 18)
|
|
# Then: the cast succeeds — and no AoO path is taken.
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
|
c.grappled_with = "foe"
|
|
engine = _engine([c, f], [9, 3, 3])
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert engine._aoo_used == {}
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_grappled_cast_fizzles_on_failed_check_and_burns_the_slot(self) -> None:
|
|
# Given: the same grappled caster
|
|
# When: the check roll is 8 (17 < 18)
|
|
# Then: the spell is lost with the grapple fizzle log.
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
|
c.grappled_with = "foe"
|
|
engine = _engine([c, f], [8])
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert f.hp == 100
|
|
assert c.spell_slots_left[1] == 0
|
|
assert any(
|
|
"casting while grappled: concentration failed (DC 18), spell lost" in line
|
|
for line in engine._transcript
|
|
)
|
|
|
|
def test_combat_casting_helps_the_grapple_check(self) -> None:
|
|
# Given: a grappled wis-based caster (mod 0) with Combat Casting
|
|
# When: the check roll is 9 (9 + 5 + 4 = 18 >= DC 18)
|
|
# Then: the cast lands.
|
|
caster = _caster(casting_ability="wis", int_score=10, combat_casting=True)
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(cmb=7), "monsters", (0, 1))
|
|
c.grappled_with = "foe"
|
|
engine = _engine([c, f], [9, 3, 3])
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_normal_cast_still_provokes_the_aoo(self) -> None:
|
|
# Given: a threatened caster NOT casting defensively
|
|
# When: Bolt is cast (enemy AoO roll 2 misses AC 12)
|
|
# Then: the AoO is provoked and the spell still resolves.
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(), "monsters", (0, 1))
|
|
engine = _engine([c, f], [2, 3, 3]) # AoO d20, then 2d4
|
|
engine._cast_spell(c, f, "Bolt")
|
|
assert engine._aoo_used.get("foe") == 1
|
|
assert f.hp == 100 - 6
|
|
|
|
def test_action_cast_defensively_threads_through_execution(self) -> None:
|
|
# Given: a threatened caster
|
|
# When: the cast is issued as an Action with cast_defensively
|
|
# Then: _execute dispatches the defensive cast (no AoO, check made).
|
|
caster = _caster()
|
|
c = _state(caster, "players", (0, 0))
|
|
f = _state(_foe(), "monsters", (0, 1))
|
|
engine = _engine([c, f], [8, 3, 3])
|
|
result = engine._execute(
|
|
c,
|
|
Action(
|
|
kind="cast_spell", target_id="foe", spell_name="Bolt",
|
|
cast_defensively=True,
|
|
),
|
|
)
|
|
assert result.ok is True
|
|
assert engine._aoo_used == {}
|
|
assert f.hp == 100 - 6
|