fix(rules): apply the -2 spell combat penalty to melee attacks

PF1e CRB (Magus Spell Combat): as a full-round action the magus casts a
standard-action spell and makes all his melee attacks at -2. The penalty is
threaded through the full-attack chain (_execute_attack -> _full_attack ->
_full_attack_weapon -> _attack/_resolve_swing -> resolve_attack bonus_override)
so it applies to every attack roll and crit confirmation. Simplification: the
arcane pool cost is still not modeled.

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-11 20:30:19 +02:00
parent e321c67779
commit 0fefb9cdd2
3 changed files with 108 additions and 19 deletions
+5 -1
View File
@@ -955,7 +955,11 @@ Règles modélisées :
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
`inspire_courage` + `spell_combat` (misty)
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
complet, incante un sort standard et porte toutes ses attaques de mêlée à
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
d'attaque et de confirmation ; simplification : le coût de pool arcanique
n'est pas modélisé)
— couvertes par `tests/test_class_abilities.py`.
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
+64 -18
View File
@@ -176,6 +176,7 @@ _DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total
_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC
_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_SPELL_COMBAT_ATTACK_PENALTY = -2 # PF1e: magus Spell Combat melee attacks at -2
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
# 76-100 attack the nearest creature
@@ -4430,7 +4431,14 @@ class CombatEngine:
def _execute_spell_combat(
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
) -> None:
"""Execute Spell Combat (cast spell + full attack in same round)."""
"""Execute Spell Combat (cast spell + full attack in same round).
PF1e (CRB, Magus Spell Combat): as a full-round action the magus casts
a standard-action spell and makes all of his melee attacks at -2. The
penalty rides the full-attack chain to every swing and crit confirm.
Simplification: the spell may target any resolved enemy (the RAW
spellcasting-as-move restriction and arcane pool cost are not modeled).
"""
if target is None or not target.active or spell_name is None:
return
if not state.spend_resource("arcane_pool", 1):
@@ -4438,8 +4446,10 @@ class CombatEngine:
pass
# Cast the spell
self._cast_spell(state, target, spell_name)
# Then make a full attack
self._execute_attack(state, target, "full_attack")
# Then make a full attack at -2 (Spell Combat penalty)
self._execute_attack(
state, target, "full_attack", attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY
)
def _execute_inspire_courage(
self, state: CombatantState, target: CombatantState | None
@@ -4493,15 +4503,18 @@ class CombatEngine:
target: CombatantState | None,
kind: str,
weapon_id: str | None = None,
*,
attack_penalty: int = 0,
) -> ActionResult | None:
"""Execute an attack / full_attack (U5d: optional explicit weapon).
With ``weapon_id`` the attack is made with THAT weapon; a choice that
is unknown, broken, stolen, or unable to strike the target is logged
and rejected with ``weapon_unavailable`` (no attack is made). Without
it the ``weapon_for`` auto-selection is unchanged. Returns an explicit
rejection, or None when the helper ran (or silently no-oped) and the
caller reports ok.
it the ``weapon_for`` auto-selection is unchanged. ``attack_penalty``
is applied to every roll of the action (Spell Combat's -2). Returns an
explicit rejection, or None when the helper ran (or silently no-oped)
and the caller reports ok.
"""
if self.has_condition_flag(state, "cant_attack"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot attack)")
@@ -4526,9 +4539,11 @@ class CombatEngine:
self._single_attack(state, target, weapon)
elif kind == "full_attack":
if chosen is not None:
self._full_attack_weapon(state, target, chosen)
self._full_attack_weapon(
state, target, chosen, attack_penalty=attack_penalty
)
else:
self._full_attack(state, target)
self._full_attack(state, target, attack_penalty=attack_penalty)
return None
def _chosen_weapon(
@@ -5326,12 +5341,22 @@ class CombatEngine:
*,
label: str,
bonus_override: int | None = None,
attack_penalty: int = 0,
) -> bool:
"""Resolve one swing, log it, update stats. Return True if target dropped."""
"""Resolve one swing, log it, update stats. Return True if target dropped.
``attack_penalty`` is folded into the attack bonus (and thus the crit
confirmation): Spell Combat's -2 (PF1e, Magus Spell Combat) rides the
full-attack chain to every swing.
"""
live = self._stats[attacker.combatant.id]
target_live = self._stats[target.combatant.id]
hp_before = target.hp
result = self.resolve_attack(attacker, target, weapon, bonus_override=bonus_override)
override = bonus_override
if attack_penalty:
base = weapon.attack_bonus if override is None else override
override = base + attack_penalty
result = self.resolve_attack(attacker, target, weapon, bonus_override=override)
live.hits += int(result.hit)
live.crits += int(result.crit)
live.damage_dealt += result.damage
@@ -5390,7 +5415,12 @@ class CombatEngine:
return False
def _attack(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
attack_penalty: int = 0,
) -> None:
for swing in range(1, weapon.count + 1):
if not attacker.active:
@@ -5398,7 +5428,9 @@ class CombatEngine:
if self._check_provoked_aoo(attacker, weapon):
return
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
if self._resolve_swing(attacker, target, weapon, label=label):
if self._resolve_swing(
attacker, target, weapon, label=label, attack_penalty=attack_penalty
):
return
def _single_attack(
@@ -5411,7 +5443,11 @@ class CombatEngine:
self._resolve_swing(attacker, target, weapon, label=weapon.name)
def _full_attack(
self, attacker: CombatantState, target: CombatantState
self,
attacker: CombatantState,
target: CombatantState,
*,
attack_penalty: int = 0,
) -> None:
melee_weapons = [
w
@@ -5424,19 +5460,28 @@ class CombatEngine:
weapon = self.weapon_for(attacker, target)
if weapon is None:
return
self._full_attack_weapon(attacker, target, weapon)
self._full_attack_weapon(
attacker, target, weapon, attack_penalty=attack_penalty
)
return
for weapon in melee_weapons:
if not attacker.active or not target.active:
return
self._full_attack_weapon(attacker, target, weapon)
self._full_attack_weapon(
attacker, target, weapon, attack_penalty=attack_penalty
)
def _full_attack_weapon(
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
self,
attacker: CombatantState,
target: CombatantState,
weapon: AttackSpec,
*,
attack_penalty: int = 0,
) -> None:
"""Resolve one weapon's full-attack swings (count swings or BAB iteratives)."""
if weapon.count > 1:
self._attack(attacker, target, weapon)
self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
return
n = min(
_MAX_ITERATIVES,
@@ -5452,7 +5497,8 @@ class CombatEngine:
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
if self._resolve_swing(
attacker, target, weapon, label=label, bonus_override=bonus
attacker, target, weapon, label=label, bonus_override=bonus,
attack_penalty=attack_penalty,
):
return
+39
View File
@@ -336,3 +336,42 @@ def test_misty_silent_when_bardic_rounds_exhausted() -> None:
assert not any(a.kind == "inspire_courage" for a in actions)
assert not any(a.kind == "spell_combat" for a in actions)
def test_spell_combat_melee_attacks_take_minus_two() -> None:
"""CRB Magus Spell Combat: the full attack that accompanies the cast is
made at -2 on every attack roll (vs the same swing in a normal full attack)."""
misty = _combatant(
"misty", resources={"bardic_rounds": 12}, spells=["Create Alcool"]
)
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
# Queue: goblin AoO d20 (natural 1, auto-miss), then misty's swing d20=10.
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[1, 10])
engine._execute(
me,
Action(
kind="spell_combat", target_id=enemy[0].combatant.id,
spell_name="Create Alcool",
),
)
# Sword attack_bonus 3 + Spell Combat -2 = +1: d20 10 -> total 11 (misses AC 15).
assert any(
"sword vs goblin d20=10+1=11" in line for line in engine._transcript
)
def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
"""Control: the same scripted swing in a plain full attack keeps its +3."""
misty = _combatant("misty", resources={"bardic_rounds": 12})
goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR)
engine, me, enemy = _engine(misty, [goblin], spell_registry=reg, rng_queue=[10])
engine._execute(me, Action(kind="full_attack", target_id=enemy[0].combatant.id))
assert any(
"sword vs goblin d20=10+3=13" in line for line in engine._transcript
)