fix(spells): correct the sound burst save

PF1e (SRD spells/sound-burst.md): "Every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save to avoid being stunned for 1 round" — the Fortitude partial negates the stun only, never the dice. The damage component declared half_on_save: true, halving the sonic damage on a successful save. DamageEffect gains a save_negates flag (default True, existing behavior unchanged); sound_burst.json declares half_on_save: false + save_negates: false so the 1d8 is full regardless while the condition keeps save_negates. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 12:31:06 +02:00
parent e3818fa6c9
commit 8c7e7075ad
5 changed files with 21 additions and 8 deletions
+4 -1
View File
@@ -1097,7 +1097,10 @@ Règles modélisées :
Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès
annule ou divise par deux selon ce que l'effet déclare (`save_negates` / annule ou divise par deux selon ce que l'effet déclare (`save_negates` /
`half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde `half_on_save` / `AreaEffect.save_for_half`) — les zones « sauvegarde
annule » (Sleep, Color Spray, Web) sauvent donc aussi. annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de
dégâts peut déclarer `save_negates: false` (conformance D1 — SRD
« Sound Burst » : « Fortitude partial » n'annule que l'étourdissement,
jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts.
- Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux - Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux
soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness
lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en
+4 -3
View File
@@ -10,13 +10,14 @@
{ {
"type": "area", "type": "area",
"radius_ft": 10, "radius_ft": 10,
"save_for_half": true, "save_for_half": false,
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
"formula": "1d8", "formula": "1d8",
"types": ["sonic"], "types": ["sonic"],
"half_on_save": true "half_on_save": false,
"save_negates": false
}, },
{ {
"type": "condition", "type": "condition",
@@ -27,5 +28,5 @@
] ]
} }
], ],
"description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save or be stunned for 1 round (Fortitude partial — modeled as save for half damage and negates the stun). Creatures that cannot hear take the damage but are not stunned (not modeled)." "description": "A tremendous cacophony blasts a 10-ft.-radius spread: every creature in the area takes 1d8 points of sonic damage and must succeed on a Fortitude save to avoid being stunned for 1 round (SRD spells/sound-burst.md: the save only negates the stun - the damage is full regardless). Creatures that cannot hear take the damage but are not stunned (not modeled)."
} }
+1 -1
View File
@@ -5534,7 +5534,7 @@ class CombatEngine:
raw = effect.formula.max_value() raw = effect.formula.max_value()
if saved and effect.half_on_save: if saved and effect.half_on_save:
dmg = max(0, raw // 2) dmg = max(0, raw // 2)
elif saved: elif saved and effect.save_negates:
dmg = 0 dmg = 0
else: else:
dmg = raw dmg = raw
+6
View File
@@ -39,6 +39,11 @@ class DamageEffect:
types: tuple[str, ...] types: tuple[str, ...]
# False = save negates (targeted); True = half damage on save (ray/line/cone/AoE) # False = save negates (targeted); True = half damage on save (ray/line/cone/AoE)
half_on_save: bool = False half_on_save: bool = False
# Only consulted when half_on_save is False. True = a successful save deals
# no damage (save negates, the targeted-spell default); False = the save has
# no effect on this damage at all (SRD Sound Burst: "Fortitude partial"
# negates only the stun — every creature takes the full dice regardless).
save_negates: bool = True
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -258,6 +263,7 @@ def _parse_effect(data: Json) -> SpellEffect:
formula=parse_dice(_as_str(effect["formula"])), formula=parse_dice(_as_str(effect["formula"])),
types=tuple(_as_str(t) for t in _as_list(effect["types"])), types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(effect.get("half_on_save", False)), half_on_save=bool(effect.get("half_on_save", False)),
save_negates=bool(effect.get("save_negates", True)),
) )
if effect_type == "condition": if effect_type == "condition":
return ConditionEffect( return ConditionEffect(
+4 -1
View File
@@ -390,7 +390,10 @@ class TestAreaSavesPerSpellSaveType:
) )
engine._cast_spell(c, t, "Sound Burst") engine._cast_spell(c, t, "Sound Burst")
assert any(ac.condition.name == "stunned" for ac in c.active_conditions) assert any(ac.condition.name == "stunned" for ac in c.active_conditions)
assert t.hp == 200 - 1 # saved: 3 // 2 # SRD spells/sound-burst.md: the save only avoids the stun — the 1d8
# sonic damage is full regardless (save_negates: false on the damage).
assert t.hp == 200 - 3 # saved: full damage, no stun
assert not any(ac.condition.name == "stunned" for ac in t.active_conditions)
class TestTargetedSpellSaveNegates: class TestTargetedSpellSaveNegates: