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:
@@ -955,7 +955,11 @@ Règles modélisées :
|
|||||||
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
|
(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 ;
|
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),
|
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`.
|
— couvertes par `tests/test_class_abilities.py`.
|
||||||
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
|
- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie
|
||||||
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
|
positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou
|
||||||
|
|||||||
@@ -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
|
_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_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
|
||||||
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
|
_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
|
_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,
|
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
|
||||||
# 76-100 attack the nearest creature
|
# 76-100 attack the nearest creature
|
||||||
@@ -4430,7 +4431,14 @@ class CombatEngine:
|
|||||||
def _execute_spell_combat(
|
def _execute_spell_combat(
|
||||||
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
||||||
) -> 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:
|
if target is None or not target.active or spell_name is None:
|
||||||
return
|
return
|
||||||
if not state.spend_resource("arcane_pool", 1):
|
if not state.spend_resource("arcane_pool", 1):
|
||||||
@@ -4438,8 +4446,10 @@ class CombatEngine:
|
|||||||
pass
|
pass
|
||||||
# Cast the spell
|
# Cast the spell
|
||||||
self._cast_spell(state, target, spell_name)
|
self._cast_spell(state, target, spell_name)
|
||||||
# Then make a full attack
|
# Then make a full attack at -2 (Spell Combat penalty)
|
||||||
self._execute_attack(state, target, "full_attack")
|
self._execute_attack(
|
||||||
|
state, target, "full_attack", attack_penalty=_SPELL_COMBAT_ATTACK_PENALTY
|
||||||
|
)
|
||||||
|
|
||||||
def _execute_inspire_courage(
|
def _execute_inspire_courage(
|
||||||
self, state: CombatantState, target: CombatantState | None
|
self, state: CombatantState, target: CombatantState | None
|
||||||
@@ -4493,15 +4503,18 @@ class CombatEngine:
|
|||||||
target: CombatantState | None,
|
target: CombatantState | None,
|
||||||
kind: str,
|
kind: str,
|
||||||
weapon_id: str | None = None,
|
weapon_id: str | None = None,
|
||||||
|
*,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> ActionResult | None:
|
) -> ActionResult | None:
|
||||||
"""Execute an attack / full_attack (U5d: optional explicit weapon).
|
"""Execute an attack / full_attack (U5d: optional explicit weapon).
|
||||||
|
|
||||||
With ``weapon_id`` the attack is made with THAT weapon; a choice that
|
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
|
is unknown, broken, stolen, or unable to strike the target is logged
|
||||||
and rejected with ``weapon_unavailable`` (no attack is made). Without
|
and rejected with ``weapon_unavailable`` (no attack is made). Without
|
||||||
it the ``weapon_for`` auto-selection is unchanged. Returns an explicit
|
it the ``weapon_for`` auto-selection is unchanged. ``attack_penalty``
|
||||||
rejection, or None when the helper ran (or silently no-oped) and the
|
is applied to every roll of the action (Spell Combat's -2). Returns an
|
||||||
caller reports ok.
|
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"):
|
if self.has_condition_flag(state, "cant_attack"):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot 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)
|
self._single_attack(state, target, weapon)
|
||||||
elif kind == "full_attack":
|
elif kind == "full_attack":
|
||||||
if chosen is not None:
|
if chosen is not None:
|
||||||
self._full_attack_weapon(state, target, chosen)
|
self._full_attack_weapon(
|
||||||
|
state, target, chosen, attack_penalty=attack_penalty
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self._full_attack(state, target)
|
self._full_attack(state, target, attack_penalty=attack_penalty)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _chosen_weapon(
|
def _chosen_weapon(
|
||||||
@@ -5326,12 +5341,22 @@ class CombatEngine:
|
|||||||
*,
|
*,
|
||||||
label: str,
|
label: str,
|
||||||
bonus_override: int | None = None,
|
bonus_override: int | None = None,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> bool:
|
) -> 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]
|
live = self._stats[attacker.combatant.id]
|
||||||
target_live = self._stats[target.combatant.id]
|
target_live = self._stats[target.combatant.id]
|
||||||
hp_before = target.hp
|
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.hits += int(result.hit)
|
||||||
live.crits += int(result.crit)
|
live.crits += int(result.crit)
|
||||||
live.damage_dealt += result.damage
|
live.damage_dealt += result.damage
|
||||||
@@ -5390,7 +5415,12 @@ class CombatEngine:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _attack(
|
def _attack(
|
||||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
self,
|
||||||
|
attacker: CombatantState,
|
||||||
|
target: CombatantState,
|
||||||
|
weapon: AttackSpec,
|
||||||
|
*,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
for swing in range(1, weapon.count + 1):
|
for swing in range(1, weapon.count + 1):
|
||||||
if not attacker.active:
|
if not attacker.active:
|
||||||
@@ -5398,7 +5428,9 @@ class CombatEngine:
|
|||||||
if self._check_provoked_aoo(attacker, weapon):
|
if self._check_provoked_aoo(attacker, weapon):
|
||||||
return
|
return
|
||||||
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
|
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
|
return
|
||||||
|
|
||||||
def _single_attack(
|
def _single_attack(
|
||||||
@@ -5411,7 +5443,11 @@ class CombatEngine:
|
|||||||
self._resolve_swing(attacker, target, weapon, label=weapon.name)
|
self._resolve_swing(attacker, target, weapon, label=weapon.name)
|
||||||
|
|
||||||
def _full_attack(
|
def _full_attack(
|
||||||
self, attacker: CombatantState, target: CombatantState
|
self,
|
||||||
|
attacker: CombatantState,
|
||||||
|
target: CombatantState,
|
||||||
|
*,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
melee_weapons = [
|
melee_weapons = [
|
||||||
w
|
w
|
||||||
@@ -5424,19 +5460,28 @@ class CombatEngine:
|
|||||||
weapon = self.weapon_for(attacker, target)
|
weapon = self.weapon_for(attacker, target)
|
||||||
if weapon is None:
|
if weapon is None:
|
||||||
return
|
return
|
||||||
self._full_attack_weapon(attacker, target, weapon)
|
self._full_attack_weapon(
|
||||||
|
attacker, target, weapon, attack_penalty=attack_penalty
|
||||||
|
)
|
||||||
return
|
return
|
||||||
for weapon in melee_weapons:
|
for weapon in melee_weapons:
|
||||||
if not attacker.active or not target.active:
|
if not attacker.active or not target.active:
|
||||||
return
|
return
|
||||||
self._full_attack_weapon(attacker, target, weapon)
|
self._full_attack_weapon(
|
||||||
|
attacker, target, weapon, attack_penalty=attack_penalty
|
||||||
|
)
|
||||||
|
|
||||||
def _full_attack_weapon(
|
def _full_attack_weapon(
|
||||||
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
|
self,
|
||||||
|
attacker: CombatantState,
|
||||||
|
target: CombatantState,
|
||||||
|
weapon: AttackSpec,
|
||||||
|
*,
|
||||||
|
attack_penalty: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Resolve one weapon's full-attack swings (count swings or BAB iteratives)."""
|
"""Resolve one weapon's full-attack swings (count swings or BAB iteratives)."""
|
||||||
if weapon.count > 1:
|
if weapon.count > 1:
|
||||||
self._attack(attacker, target, weapon)
|
self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
|
||||||
return
|
return
|
||||||
n = min(
|
n = min(
|
||||||
_MAX_ITERATIVES,
|
_MAX_ITERATIVES,
|
||||||
@@ -5452,7 +5497,8 @@ class CombatEngine:
|
|||||||
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
|
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
|
||||||
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
|
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
|
||||||
if self._resolve_swing(
|
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
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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 == "inspire_courage" for a in actions)
|
||||||
assert not any(a.kind == "spell_combat" 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
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user