fix(rules): correct the inspire courage bonus type and saves
PF1e CRB (Bardic Performance - Inspire Courage): the attack/weapon-damage bonus is a COMPETENCE bonus (not morale), so it stacks with morale sources like Heroism; the performance also grants a +1 MORALE bonus on saving throws against charm and fear effects, implemented as two descriptor-conditional will modifiers (fear / mind_affecting) that dedupe via morale stacking. Simplifications (documented): flat +1 (no scaling per four bard levels) and mind_affecting stands in for the missing charm descriptor. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -959,7 +959,15 @@ Règles modélisées :
|
|||||||
complet, incante un sort standard et porte toutes ses attaques de mêlée à
|
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
|
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
|
||||||
d'attaque et de confirmation ; simplification : le coût de pool arcanique
|
d'attaque et de confirmation ; simplification : le coût de pool arcanique
|
||||||
n'est pas modélisé)
|
n'est pas modélisé) ; Inspire Courage (CRB Bardic Performance) : **+1
|
||||||
|
compétence** (competence) aux jets d'attaque et de dégâts — type qui se
|
||||||
|
cumule donc avec les sources de moral (Heroism) — et **+1 moral** aux jets
|
||||||
|
de sauvegarde contre les effets de charme et de peur, portée par deux
|
||||||
|
modificateurs conditionnés (`fear` / `mind_affecting`, dédupliqués par la
|
||||||
|
règle d'empilement du moral) ; simplifications documentées : la
|
||||||
|
progression (+1 par quatre niveaux) n'est pas modélisée (plat +1) et le
|
||||||
|
moteur n'ayant pas de descripteur « charme », `mind_affecting` en tient
|
||||||
|
lieu (surcouverture : les effets de compulsion comptent aussi))
|
||||||
— 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
|
||||||
|
|||||||
@@ -4454,7 +4454,18 @@ class CombatEngine:
|
|||||||
def _execute_inspire_courage(
|
def _execute_inspire_courage(
|
||||||
self, state: CombatantState, target: CombatantState | None
|
self, state: CombatantState, target: CombatantState | None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute Inspire Courage (morale +1 attack/damage, 30ft radius, costs bardic_rounds)."""
|
"""Execute Inspire Courage (30ft radius, costs bardic_rounds).
|
||||||
|
|
||||||
|
PF1e (CRB, Bardic Performance — Inspire Courage): +1 competence bonus
|
||||||
|
on attack rolls and weapon damage rolls, and a +1 morale bonus on
|
||||||
|
saving throws against charm and fear effects. Simplifications: the
|
||||||
|
scaling (+1 per four bard levels) is not modeled (flat +1), and the
|
||||||
|
engine has no "charm" descriptor, so mind_affecting stands in for it
|
||||||
|
(documented over-coverage: compulsion effects also count). The save
|
||||||
|
bonus is carried by two conditional modifiers (fear /
|
||||||
|
mind_affecting); a save with both descriptors still totals +1 because
|
||||||
|
morale does not stack with itself.
|
||||||
|
"""
|
||||||
if not state.spend_resource("bardic_rounds", 1):
|
if not state.spend_resource("bardic_rounds", 1):
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: inspire courage "
|
f"round {self._current_round} {state.combatant.id}: inspire courage "
|
||||||
@@ -4481,13 +4492,21 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply +1 morale bonus to attack and damage
|
# Apply +1 competence attack/damage and +1 morale vs charm/fear saves
|
||||||
for ally in affected:
|
for ally in affected:
|
||||||
ally.active_buffs.append(
|
ally.active_buffs.append(
|
||||||
ActiveBuff(
|
ActiveBuff(
|
||||||
modifiers=(
|
modifiers=(
|
||||||
StatModifier(target="attack", value=1, bonus_type="morale"),
|
StatModifier(target="attack", value=1, bonus_type="competence"),
|
||||||
StatModifier(target="damage", value=1, bonus_type="morale"),
|
StatModifier(target="damage", value=1, bonus_type="competence"),
|
||||||
|
StatModifier(
|
||||||
|
target="will", value=1, bonus_type="morale",
|
||||||
|
condition="fear",
|
||||||
|
),
|
||||||
|
StatModifier(
|
||||||
|
target="will", value=1, bonus_type="morale",
|
||||||
|
condition="mind_affecting",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
rounds_left=1,
|
rounds_left=1,
|
||||||
source=f"inspire courage by {state.combatant.id}",
|
source=f"inspire courage by {state.combatant.id}",
|
||||||
@@ -4495,7 +4514,8 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
f"inspire courage +1 morale attack/damage to {len(affected)} allies"
|
f"inspire courage +1 competence attack/damage, +1 morale vs "
|
||||||
|
f"charm/fear saves to {len(affected)} allies"
|
||||||
)
|
)
|
||||||
def _execute_attack(
|
def _execute_attack(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from typing import Literal
|
|||||||
|
|
||||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy
|
||||||
from pf1e_simulator.dice import parse_dice
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.effects import ActiveBuff, StatModifier, resolve_modifiers
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import MapSpec, TerrainType
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
from pf1e_simulator.models import (
|
from pf1e_simulator.models import (
|
||||||
@@ -375,3 +376,61 @@ def test_normal_full_attack_has_no_spell_combat_penalty() -> None:
|
|||||||
assert any(
|
assert any(
|
||||||
"sword vs goblin d20=10+3=13" in line for line in engine._transcript
|
"sword vs goblin d20=10+3=13" in line for line in engine._transcript
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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})
|
||||||
|
ally = _combatant("ally")
|
||||||
|
engine, me, _ = _engine(misty, [_combatant("goblin", hp_max=6)])
|
||||||
|
ally_state = _state(ally, "players", (5, 7))
|
||||||
|
engine.states.append(ally_state)
|
||||||
|
engine._execute(
|
||||||
|
me, Action(kind="inspire_courage", target_id=ally_state.combatant.id)
|
||||||
|
)
|
||||||
|
return engine, me, ally_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspire_courage_grants_competence_attack_damage() -> None:
|
||||||
|
"""CRB Inspire Courage: the attack/damage bonus is COMPETENCE (not morale)."""
|
||||||
|
_, _, ally = _inspired_ally()
|
||||||
|
mods = {
|
||||||
|
(m.target, m.bonus_type): m.value
|
||||||
|
for buff in ally.active_buffs
|
||||||
|
for m in buff.modifiers
|
||||||
|
}
|
||||||
|
assert mods[("attack", "competence")] == 1
|
||||||
|
assert mods[("damage", "competence")] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspire_courage_stacks_with_heroism_morale() -> None:
|
||||||
|
"""CRB stacking: +1 competence (inspire courage) and +2 morale (heroism)
|
||||||
|
are different bonus types — both apply to the attack roll."""
|
||||||
|
engine, _, ally = _inspired_ally()
|
||||||
|
ally.active_buffs.append(
|
||||||
|
ActiveBuff(
|
||||||
|
modifiers=(StatModifier(target="attack", value=2, bonus_type="morale"),),
|
||||||
|
rounds_left=10,
|
||||||
|
source="heroism",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = resolve_modifiers(engine._stat_modifiers(ally, "attack"))
|
||||||
|
assert total == 3 # competence +1 + morale +2
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspire_courage_morale_bonus_on_charm_and_fear_saves() -> None:
|
||||||
|
"""CRB Inspire Courage: +1 morale on saves vs charm (mind_affecting) and
|
||||||
|
fear; morale never stacks with itself, so both descriptors total +1."""
|
||||||
|
engine, _, ally = _inspired_ally()
|
||||||
|
|
||||||
|
def will_bonus(*descriptors: str) -> int:
|
||||||
|
return resolve_modifiers(
|
||||||
|
engine._stat_modifiers(
|
||||||
|
ally, "will", save_descriptors=frozenset(descriptors)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert will_bonus("fear") == 1
|
||||||
|
assert will_bonus("mind_affecting") == 1
|
||||||
|
assert will_bonus("fear", "mind_affecting") == 1
|
||||||
|
assert will_bonus() == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user