feat(combat): AoO and concentration on casting
This commit is contained in:
@@ -640,6 +640,49 @@ class CombatEngine:
|
|||||||
return True
|
return True
|
||||||
return False
|
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:
|
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."""
|
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
|
||||||
path = self._move_path(state, target)
|
path = self._move_path(state, target)
|
||||||
@@ -878,6 +921,10 @@ class CombatEngine:
|
|||||||
f"round {self._current_round} {caster.combatant.id}: "
|
f"round {self._current_round} {caster.combatant.id}: "
|
||||||
f"cast {spell_name} -> NO LINE OF EFFECT"
|
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
|
# DC = 10 + spell level + caster's casting ability modifier
|
||||||
ability_name = caster.combatant.casting_ability.lower()
|
ability_name = caster.combatant.casting_ability.lower()
|
||||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
||||||
|
|||||||
+12
-11
@@ -93,7 +93,7 @@ def _make_engine(
|
|||||||
queue: list[int],
|
queue: list[int],
|
||||||
*,
|
*,
|
||||||
caster_pos: tuple[int, int] = (0, 0),
|
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,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
||||||
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
|
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:
|
def test_transcript_shows_saved(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
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")
|
engine._cast_spell(c, t, "Fireball")
|
||||||
assert any("SAVED" in line for line in engine._transcript)
|
assert any("SAVED" in line for line in engine._transcript)
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ class TestCureLightWounds:
|
|||||||
def test_heals_damage(self) -> None:
|
def test_heals_damage(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
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
|
t.hp = 10
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
assert t.hp == 10 + (5 + 1)
|
assert t.hp == 10 + (5 + 1)
|
||||||
@@ -289,7 +289,7 @@ class TestCureLightWounds:
|
|||||||
def test_heal_capped_at_max(self) -> None:
|
def test_heal_capped_at_max(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
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
|
t.hp = 28
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
assert t.hp == 30
|
assert t.hp == 30
|
||||||
@@ -303,7 +303,7 @@ class TestBullsStrength:
|
|||||||
def test_applies_buff_effects(self) -> None:
|
def test_applies_buff_effects(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
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")
|
engine._cast_spell(c, t, "Bull's Strength")
|
||||||
atk_mods = [e for e in t.effects if e.target == "attack"]
|
atk_mods = [e for e in t.effects if e.target == "attack"]
|
||||||
dmg_mods = [e for e in t.effects if e.target == "damage"]
|
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:
|
def test_transcript_shows_buff(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target()
|
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")
|
engine._cast_spell(c, t, "Bull's Strength")
|
||||||
assert any("buff" in line for line in engine._transcript)
|
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:
|
def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
|
||||||
caster = _make_caster(level=5) # int 18 -> mod +4
|
caster = _make_caster(level=5) # int 18 -> mod +4
|
||||||
target = _make_target()
|
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
|
engine._cast_spell(c, t, "Fireball") # fireball is level 3
|
||||||
# DC = 10 + spell_level(3) + int_mod(4) = 17
|
# DC = 10 + spell_level(3) + int_mod(4) = 17
|
||||||
assert any("DC17" in line for line in engine._transcript)
|
assert any("DC17" in line for line in engine._transcript)
|
||||||
@@ -389,11 +389,12 @@ class TestSpellDamageWithDr:
|
|||||||
def test_dr_reduces_spell_damage(self) -> None:
|
def test_dr_reduces_spell_damage(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=200)
|
target = _make_target(hp_max=200)
|
||||||
|
# DR 5/- (bypassed by nothing) applies to all damage including fire
|
||||||
target_with_dr = target.model_copy(update={
|
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
|
hp_before = t.hp
|
||||||
engine._cast_spell(c, t, "Fireball")
|
engine._cast_spell(c, t, "Fireball")
|
||||||
full = 6 * 6
|
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage
|
||||||
assert t.hp == hp_before - max(0, full - 5)
|
assert t.hp == hp_before - 13
|
||||||
|
|||||||
Reference in New Issue
Block a user