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:
2026-09-12 11:14:30 +02:00
parent 068b49794e
commit a9b33e4055
2 changed files with 97 additions and 15 deletions
+52
View File
@@ -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})