fix(rules): apply the spell combat penalty to the spell attack
PF1e (CRB Magus Spell Combat): "any attack roll made as part of this spell also takes this penalty". _execute_spell_combat now threads the -2 through _cast_spell into the melee/ranged touch attack helpers, so e.g. Shocking Grasp delivered via Spell Combat attacks at -2 too. No global state is touched: the penalty is a keyword threaded per cast. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -31,6 +31,7 @@ def _combatant(
|
||||
*,
|
||||
resources: dict[str, int] | None = None,
|
||||
spells: list[str] | None = None,
|
||||
spell_slots: dict[str, int] | None = None,
|
||||
hp_max: int = 20,
|
||||
creature_type: Literal["living", "undead", "construct"] = "living",
|
||||
) -> Combatant:
|
||||
@@ -59,6 +60,7 @@ def _combatant(
|
||||
attacks=[attack],
|
||||
resources=resources or {},
|
||||
spells=spells or [],
|
||||
spell_slots=spell_slots or {},
|
||||
creature_type=creature_type,
|
||||
)
|
||||
|
||||
@@ -378,6 +380,56 @@ def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_spell_combat_penalty_applies_to_spell_touch_attack() -> None:
|
||||
"""CRB Magus Spell Combat: "any attack roll made as part of this spell
|
||||
also takes this penalty" — Shocking Grasp's melee touch attack is at -2
|
||||
(bab 3 + str 2 - 2 = +3): d20 8 -> 11 misses touch AC 12, so the spell
|
||||
is wasted. Without the penalty the same roll (8+5=13) would have hit."""
|
||||
misty = _combatant(
|
||||
"misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"],
|
||||
spell_slots={1: 1},
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
# Queue: goblin AoO on the cast (nat 1 miss), touch d20=8, swing d20=10.
|
||||
engine, me, enemy = _engine(
|
||||
misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 10]
|
||||
)
|
||||
engine._execute(
|
||||
me,
|
||||
Action(
|
||||
kind="spell_combat", target_id=enemy[0].combatant.id,
|
||||
spell_name="Shocking Grasp",
|
||||
),
|
||||
)
|
||||
assert any(
|
||||
"touch attack vs goblin d20=8+3=11 AC 12 -> MISS" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
assert enemy[0].hp == 6 # the missed touch delivers no electricity
|
||||
|
||||
|
||||
def test_plain_cast_touch_attack_has_no_spell_combat_penalty() -> None:
|
||||
"""Control: the same Shocking Grasp cast through cast_spell keeps +5
|
||||
(bab 3 + str 2): d20 8 -> 13 hits touch AC 12 and deals damage."""
|
||||
misty = _combatant(
|
||||
"misty", spells=["Shocking Grasp"], spell_slots={1: 1}
|
||||
)
|
||||
goblin = _combatant("goblin", hp_max=6)
|
||||
reg = load_spell_registry(SPELLS_DIR)
|
||||
# Queue: goblin AoO (nat 1 miss), touch d20=8, damage 1d6=4.
|
||||
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 8, 4])
|
||||
engine._execute(
|
||||
me, Action(kind="cast_spell", target_id=enemy[0].combatant.id,
|
||||
spell_name="Shocking Grasp")
|
||||
)
|
||||
assert any(
|
||||
"touch attack vs goblin d20=8+5=13 AC 12 -> HIT" in line
|
||||
for line in engine._transcript
|
||||
)
|
||||
assert enemy[0].hp == 2
|
||||
|
||||
|
||||
def _inspired_ally() -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||
"""Misty + one ally in 30ft, inspire courage executed on the ally."""
|
||||
misty = _combatant("misty", resources={"bardic_rounds": 12})
|
||||
|
||||
Reference in New Issue
Block a user