From 529999da69d7a6005bee9e53ddbd983fbf232a4b Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 20:35:01 +0200 Subject: [PATCH] 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 --- README.md | 10 +++++- src/pf1e_simulator/combat.py | 30 +++++++++++++++--- tests/test_class_abilities.py | 59 +++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f6f3afd..f9a6a9e 100644 --- a/README.md +++ b/README.md @@ -959,7 +959,15 @@ Règles modélisées : 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é) + 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`. - Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b9af7ea..b2023ad 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4454,7 +4454,18 @@ class CombatEngine: def _execute_inspire_courage( self, state: CombatantState, target: CombatantState | 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): self._log( f"round {self._current_round} {state.combatant.id}: inspire courage " @@ -4481,13 +4492,21 @@ class CombatEngine: ) 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: ally.active_buffs.append( ActiveBuff( modifiers=( - StatModifier(target="attack", value=1, bonus_type="morale"), - StatModifier(target="damage", value=1, bonus_type="morale"), + StatModifier(target="attack", value=1, bonus_type="competence"), + 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, source=f"inspire courage by {state.combatant.id}", @@ -4495,7 +4514,8 @@ class CombatEngine: ) self._log( 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( self, diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 3af149c..36d636d 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -11,6 +11,7 @@ from typing import Literal from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy 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.map import MapSpec, TerrainType from pf1e_simulator.models import ( @@ -375,3 +376,61 @@ def test_normal_full_attack_has_no_spell_combat_penalty() -> None: assert any( "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