diff --git a/README.md b/README.md index 9ef669d..f6f3afd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 840da0a..b9af7ea 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index 4136447..3af149c 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -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 + )