From d792b6e28f0485993a67dbc235f7655ce1f2a4a9 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:40 +0200 Subject: [PATCH] feat(combat): AoO and concentration on casting --- src/pf1e_simulator/combat.py | 47 ++++++++++++++++++++++++++++++++++++ tests/test_spells.py | 23 +++++++++--------- 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 49a9847..bafe6d7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -640,6 +640,49 @@ class CombatEngine: return True return False + def _check_casting_aoo(self, caster: CombatantState, spell_level: int) -> bool: + """Check for AoO from casting a spell in a threatened square. + + Returns True if the caster dropped or failed concentration (spell fizzles). + """ + for enemy in self._enemies_threatening(caster, caster.pos): + if enemy.combatant.id in self._aoo_used: + continue + hp_before = caster.hp + self._resolve_aoo(enemy, caster) + if not caster.active: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"casting interrupted by AoO from {enemy.combatant.id}" + ) + return True + # Concentration check if damaged + dmg = hp_before - caster.hp + if dmg > 0 and not self._check_concentration(caster, spell_level, dmg): + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"concentration check failed (DC {15 + 2 * spell_level}), spell fizzles" + ) + return True + return False + + def _check_concentration( + self, caster: CombatantState, spell_level: int, damage_taken: int + ) -> bool: + """Concentration check: DC = 15 + 2 * spell_level + damage_taken. + + Combat Casting feat gives +4 bonus. + """ + dc = 15 + 2 * spell_level + damage_taken + has_combat_casting = any(f.name == "Combat Casting" for f in caster.combatant.features) + bonus = 4 if has_combat_casting else 0 + roll = self._rng.d20() + # Use caster level + ability mod for concentration check + caster_level = caster.combatant.level + # For concentration, the check is d20 + caster_level + ability_mod (usually Con) + con_mod = (caster.combatant.abilities.con_score - 10) // 2 + total = roll + caster_level + con_mod + bonus + return total >= dc def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool: """True if a 5-foot step toward ``target`` would put ``state`` in weapon range.""" path = self._move_path(state, target) @@ -878,6 +921,10 @@ class CombatEngine: f"round {self._current_round} {caster.combatant.id}: " f"cast {spell_name} -> NO LINE OF EFFECT" ) + return + # Check for AoO from casting in threatened square (before resolving spell) + if self._check_casting_aoo(caster, spell.level): + return # DC = 10 + spell level + caster's casting ability modifier ability_name = caster.combatant.casting_ability.lower() ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score") diff --git a/tests/test_spells.py b/tests/test_spells.py index e9aa67b..f54f235 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -93,7 +93,7 @@ def _make_engine( queue: list[int], *, caster_pos: tuple[int, int] = (0, 0), - target_pos: tuple[int, int] = (1, 1), + target_pos: tuple[int, int] = (3, 0), spell_registry: dict[str, SpellSpec] | None = None, ) -> tuple[CombatEngine, CombatantState, CombatantState]: c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max) @@ -228,7 +228,7 @@ class TestFireballSave: def test_transcript_shows_saved(self) -> None: caster = _make_caster() target = _make_target() - engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) + engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3]) engine._cast_spell(c, t, "Fireball") assert any("SAVED" in line for line in engine._transcript) @@ -281,7 +281,7 @@ class TestCureLightWounds: def test_heals_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=30) - engine, c, t = _make_engine(caster, target, [20, 5]) + engine, c, t = _make_engine(caster, target, [3, 3, 5], target_pos=(1, 0)) t.hp = 10 engine._cast_spell(c, t, "Cure Light Wounds") assert t.hp == 10 + (5 + 1) @@ -289,7 +289,7 @@ class TestCureLightWounds: def test_heal_capped_at_max(self) -> None: caster = _make_caster() target = _make_target(hp_max=30) - engine, c, t = _make_engine(caster, target, [20, 6]) + engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0)) t.hp = 28 engine._cast_spell(c, t, "Cure Light Wounds") assert t.hp == 30 @@ -303,7 +303,7 @@ class TestBullsStrength: def test_applies_buff_effects(self) -> None: caster = _make_caster() target = _make_target() - engine, c, t = _make_engine(caster, target, [20]) + engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0)) engine._cast_spell(c, t, "Bull's Strength") atk_mods = [e for e in t.effects if e.target == "attack"] dmg_mods = [e for e in t.effects if e.target == "damage"] @@ -315,7 +315,7 @@ class TestBullsStrength: def test_transcript_shows_buff(self) -> None: caster = _make_caster() target = _make_target() - engine, c, t = _make_engine(caster, target, [20]) + engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0)) engine._cast_spell(c, t, "Bull's Strength") assert any("buff" in line for line in engine._transcript) @@ -375,7 +375,7 @@ class TestSpellDcScalesWithLevel: def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None: caster = _make_caster(level=5) # int 18 -> mod +4 target = _make_target() - engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) + engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3]) engine._cast_spell(c, t, "Fireball") # fireball is level 3 # DC = 10 + spell_level(3) + int_mod(4) = 17 assert any("DC17" in line for line in engine._transcript) @@ -389,11 +389,12 @@ class TestSpellDamageWithDr: def test_dr_reduces_spell_damage(self) -> None: caster = _make_caster() target = _make_target(hp_max=200) + # DR 5/- (bypassed by nothing) applies to all damage including fire target_with_dr = target.model_copy(update={ - "dr": DamageReduction(amount=5, bypass=frozenset({"bludgeoning"})), + "dr": DamageReduction(amount=5, bypass=frozenset()), }) - engine, c, t = _make_engine(caster, target_with_dr, [2, 6, 6, 6, 6, 6, 6]) + engine, c, t = _make_engine(caster, target_with_dr, [2, 3, 3, 3, 3, 3, 3]) hp_before = t.hp engine._cast_spell(c, t, "Fireball") - full = 6 * 6 - assert t.hp == hp_before - max(0, full - 5) + # Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage + assert t.hp == hp_before - 13