fix(combat): correct spell DC formula

This commit is contained in:
2026-09-04 22:55:37 +02:00
parent 43303cf28e
commit 789d2f5396
11 changed files with 40 additions and 10 deletions
+2
View File
@@ -24,6 +24,8 @@
"ref": 10, "ref": 10,
"will": 5 "will": 5
}, },
"casting_ability": "int",
"resources": {},
"attacks": [ "attacks": [
{ {
"name": "Lance", "name": "Lance",
+2
View File
@@ -24,6 +24,8 @@
"ref": 5, "ref": 5,
"will": 2 "will": 2
}, },
"casting_ability": "wis",
"resources": {},
"attacks": [ "attacks": [
{ {
"name": "Talons (attaque naturelle)", "name": "Talons (attaque naturelle)",
+2
View File
@@ -24,6 +24,8 @@
"ref": 6, "ref": 6,
"will": 3 "will": 3
}, },
"casting_ability": "wis",
"resources": {},
"attacks": [ "attacks": [
{ {
"name": "Bite (attaque naturelle)", "name": "Bite (attaque naturelle)",
+2
View File
@@ -24,6 +24,8 @@
"ref": 6, "ref": 6,
"will": 2 "will": 2
}, },
"casting_ability": "wis",
"resources": {},
"attacks": [ "attacks": [
{ {
"name": "Hooves (attaque naturelle)", "name": "Hooves (attaque naturelle)",
+4
View File
@@ -24,6 +24,10 @@
"ref": 9, "ref": 9,
"will": 3 "will": 3
}, },
"casting_ability": "cha",
"resources": {
"bardic_rounds": 12
},
"attacks": [ "attacks": [
{ {
"name": "MWK Rapier", "name": "MWK Rapier",
+4
View File
@@ -24,6 +24,10 @@
"ref": 5, "ref": 5,
"will": 7 "will": 7
}, },
"casting_ability": "int",
"resources": {
"hexes": 5
},
"attacks": [ "attacks": [
{ {
"name": "Coup à mains nues (aucune arme encodée)", "name": "Coup à mains nues (aucune arme encodée)",
+4
View File
@@ -24,6 +24,10 @@
"ref": 7, "ref": 7,
"will": 9 "will": 9
}, },
"casting_ability": "wis",
"resources": {
"channel_energy": 6
},
"attacks": [ "attacks": [
{ {
"name": "Longbow", "name": "Longbow",
+4
View File
@@ -24,6 +24,10 @@
"ref": 10, "ref": 10,
"will": 8 "will": 8
}, },
"casting_ability": "wis",
"resources": {
"grit": 3
},
"attacks": [ "attacks": [
{ {
"name": "Rapier (masterwork)", "name": "Rapier (masterwork)",
+5 -2
View File
@@ -834,8 +834,11 @@ 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 # DC = 10 + spell level + caster's casting ability modifier
dc = spell.base_dc + caster.combatant.level ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
ability_mod = (ability_score - 10) // 2
dc = 10 + spell.level + ability_mod
save_result = ( save_result = (
self.resolve_save(target, spell.save, dc) if spell.save != "none" else None self.resolve_save(target, spell.save, dc) if spell.save != "none" else None
) )
+2
View File
@@ -113,3 +113,5 @@ class Combatant(_FrozenModel):
notes: str = "" notes: str = ""
features: list[AbilitySpec] = [] features: list[AbilitySpec] = []
spells: list[str] = [] spells: list[str] = []
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha
resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat
+9 -8
View File
@@ -56,6 +56,7 @@ def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
speed_land_ft=30, speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4), saves=Saves(fort=3, ref=4, will=4),
attacks=[attack], attacks=[attack],
casting_ability="int",
) )
@@ -366,18 +367,18 @@ class TestSpellDispatchViaExecute:
) )
assert t.hp < hp_before assert t.hp < hp_before
class TestSpellDcScalesWithLevel: class TestSpellDcScalesWithLevel:
"""Given: a caster with level N """Given: a caster with level N and casting ability mod M
When: a spell with base_dc 10 is cast When: a spell of level L is cast
Then: the DC is 10 + N.""" Then: the DC is 10 + L + M."""
def test_dc_is_base_plus_level(self) -> None: def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
caster = _make_caster(level=5) 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, 1, 1, 1, 1, 1, 1])
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball") # fireball is level 3
assert any("DC15" in line for line in engine._transcript) # DC = 10 + spell_level(3) + int_mod(4) = 17
assert any("DC17" in line for line in engine._transcript)
class TestSpellDamageWithDr: class TestSpellDamageWithDr: