fix(spells): set the correct area shapes

SRD-verified area templates: Burning Hands 15-ft cone, Color Spray 15-ft cone, Fear 30-ft cone, Flurry of Snowballs 30-ft cone, Lightning Bolt 120-ft line (all converted from single-target/burst approximations to proper AreaEffect shapes); Fireball 20-ft burst, Sound Burst 10-ft spread, Sleep 10-ft burst, Web 20-ft spread kept. Close-range spells note the 15/30-ft RAW ranges as an approximation in their descriptions. README: cone/scaling limitations moved out of the limitations list, new area-shape + caster-level scaling rules documented, areas.py added to the architecture.

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-13 11:01:07 +02:00
parent a6cc5aef36
commit ee8c8f80ff
8 changed files with 108 additions and 45 deletions
+45 -16
View File
@@ -909,6 +909,35 @@ Règles modélisées :
centré sur la créature est inchangé. `default_policy` ne choisit pas de point centré sur la créature est inchangé. `default_policy` ne choisit pas de point
(disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via (disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`. `_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- Formes de zone (S1a) et scalage par niveau d'incanteur (S1b) :
`AreaEffect.shape` vaut `burst` (défaut), `cone` ou `line` (CRB « Magic —
Aiming a Spell »). Le **burst** reste un cercle centré sur la case résolue
(cible ou `target_pos`, U7b). Le **cône** (90° — « a quarter-circle », la
largeur valant la longueur à chaque distance) et la **ligne** (5 ft de large,
longueur = `radius_ft`) partent de la **case du jeteur** et sont pointés
vers la cible résolue (`target_id`) ou le point désigné (`target_pos`) — le
cône exclut la case d'origine, la ligne commence dans la case du jeteur
(« the bolt begins at your fingertips », SRD Lightning Bolt : le jeteur est
donc dans sa propre ligne). Toute créature dont une case occupée intersecte
le gabarit subit l'effet, avec sauvegarde et RS par créature comme pour un
burst (application partagée, géométrie dans `areas.py`). Le snapshot
d'observation expose `{shape, radius_ft}` via la clé `area`.
Un `DamageEffect` peut déclarer `"scaling": {"dice": "1d6", "per_levels": 1,
"max_dice": 10}` (dés par niveau — Fireball/Lightning Bolt « 1d6 per caster
level (maximum 10d6) », Burning Hands « 1d4 per level (maximum 5d4) »,
Shocking Grasp « 1d6 per level (maximum 5d6) ») et/ou
`"scaling_flat": {"amount": 1, "per_levels": 1, "max": 5}` (bonus plat par
niveau — Inflict Light Wounds « 1d8 + 1 per caster level (maximum +5) ») ;
à l'incantation, `effective_formula` résout dés additionnels =
`min(max_dice, niveau // per_levels)` et bonus plat =
`min(max, (niveau // per_levels) × amount)` avec la formule de base — le
total scalé suit ensuite le critique, la résistance énergétique et la
métamagie comme n'importe quelle formule (le chargeur refuse un dé de
scalage de type différent du dé de base). Absence de scalage = comportement
inchangé. Simplification documentée : le scalage ne s'applique qu'aux
dégâts (`DamageEffect`) — les soins par niveau (Cure Light Wounds
« +1 per caster level (maximum +5) ») ne sont pas scalés (la fiche dorée
épingle `1d8+1`).
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant - Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est `d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
@@ -1361,15 +1390,8 @@ plutôt que bricolées) :
Dextérité/Force du CMB (conformance L1) tient compte de leur taille. Dextérité/Force du CMB (conformance L1) tient compte de leur taille.
Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le
moteur n'a ni gabarit de cône, ni scalage par niveau d'incanteur, ni moteur n'a ni projectiles multiples, ni sauvegarde partielle) :
projectiles multiples, ni sauvegarde partielle) :
- **Burning Hands — ni cône ni dégâts par niveau** (SRD spells/burning-hands.md :
« 1d4 points of fire damage per caster level (maximum 5d4) », cône de 15 ft) :
le moteur n'a pas de scalage par niveau d'incanteur ni de zone en cône ; le
sort est modélisé en dégâts fixes `1d4` feu, sauvegarde de Réflexes pour
moitié, sur la cible résolue (la formule `1d4+1` qui surestimait le dégât de
base a été corrigée — conformance D6).
- **Acid Arrow — sans dégâts continus** (SRD spells/acid-arrow.md : portée - **Acid Arrow — sans dégâts continus** (SRD spells/acid-arrow.md : portée
**longue** — corrigée, conformance D7 — et « For every three caster levels **longue** — corrigée, conformance D7 — et « For every three caster levels
you possess, the acid, unless neutralized, lasts for another round [...], you possess, the acid, unless neutralized, lasts for another round [...],
@@ -1377,14 +1399,13 @@ projectiles multiples, ni sauvegarde partielle) :
subsystem de dégâts continus par sort (ni neutralisation d'acide) ; seuls les subsystem de dégâts continus par sort (ni neutralisation d'acide) ; seuls les
2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont 2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont
hors périmètre. hors périmètre.
- **Fear — sauvegarde totale et pas de cône** (SRD spells/fear.md : « each - **Fear — sauvegarde totale** (SRD spells/fear.md : « each
living creature in the area [...] becomes panicked unless it succeeds on a living creature in the area [...] becomes panicked unless it succeeds on a
Will save [...]. If the Will save succeeds, the creature is shaken for 1 Will save [...]. If the Will save succeeds, the creature is shaken for 1
round » ; cône de 30 ft) : la condition appliquée est bien `panicked` round » ; cône de 30 ft — modélisé, S1a/S1c) : la condition appliquée est
(conformance D8), mais le moteur n'a pas de sauvegarde partielle pour les bien `panicked` (conformance D8), mais le moteur n'a pas de sauvegarde
conditions — un succès annule totalement l'effet au lieu de poser `shaken` partielle pour les conditions — un succès annule totalement l'effet au lieu
1 round — et le gabarit en cône n'existe pas (sort résolu sur la cible) ; de poser `shaken` 1 round ; la durée « 1 round/niveau » est fixée à 4 rounds.
la durée « 1 round/niveau » est fixée à 4 rounds.
- **Magic Missile — un seul projectile** (SRD spells/magic-missile.md : - **Magic Missile — un seul projectile** (SRD spells/magic-missile.md :
« You strike [...] with one missile, plus one additional missile per two « You strike [...] with one missile, plus one additional missile per two
caster levels beyond 1st [...] maximum of five missiles ») : le moteur caster levels beyond 1st [...] maximum of five missiles ») : le moteur
@@ -1415,6 +1436,11 @@ projectiles multiples, ni sauvegarde partielle) :
- `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne - `los.py` — ligne d'effet et couvert par la règle des coins : gate de ligne
d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via d'effet dans `weapon_for`, bonus de couvert par niveau (+2/+4/+8 terrain via
`cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`. `cover_bonus`, +4 couvert mou des créatures) dans `resolve_attack`.
- `areas.py` — géométrie des gabarits de zone cône et ligne (CRB « Magic —
Aiming a Spell ») : `cone_cells` (quart de cercle de 90°, test d'angle ±45°
en arithmétique entière exacte, case d'origine exclue) et `line_cells`
(raie de Bresenham de 5 ft de large, longue de `radius_ft`, partant de la
case du jeteur). Les bursts restent gérés par `_creatures_in_range`.
- `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution - `combat.py` — `CombatEngine` déterministe : initiative, actions, résolution
des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact des attaques (couvert, ligne d'effet, pénalités de portée, CA de contact
pour les attaques touch et firearms), tailles et allonges (occupation pour les attaques touch et firearms), tailles et allonges (occupation
@@ -1467,7 +1493,10 @@ projectiles multiples, ni sauvegarde partielle) :
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch` sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), temps d'incantation `cast_time`) et types d'effets (rayons), temps d'incantation `cast_time`) et types d'effets
(`DamageEffect`, `ConditionEffect`, (`DamageEffect`, `ConditionEffect`,
`HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium, `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
`DamageEffect` porte des specs de scalage optionnels (`scaling` en dés,
`scaling_flat` en bonus plat) résolues par `effective_formula(niveau)` au
moment de l'incantation (S1b). Portées PF1e (personal, touch, close, medium,
long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`,
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget `load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des `cast_time` et leurs alias RAW (« 1 round », « 1 minute »…). Des
@@ -1498,7 +1527,7 @@ projectiles multiples, ni sauvegarde partielle) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 714 tests uv run pytest -q # 1364 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
+15 -7
View File
@@ -2,18 +2,26 @@
"name": "Burning Hands", "name": "Burning Hands",
"level": 1, "level": 1,
"school": "evocation", "school": "evocation",
"range": "touch", "range": "close",
"save": "ref", "save": "ref",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"effects": [ "effects": [
{ {
"type": "damage", "type": "area",
"formula": "0", "shape": "cone",
"types": ["fire"], "radius_ft": 15,
"half_on_save": true, "save_for_half": true,
"scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 } "effects": [
{
"type": "damage",
"formula": "0",
"types": ["fire"],
"half_on_save": true,
"scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 }
}
]
} }
], ],
"description": "SRD spells/burning-hands.md: 1d4 points of fire damage per caster level (maximum 5d4) in a 15-ft. cone; Reflex half. The cone template is applied in the area-shapes batch; until then the spell resolves on the target." "description": "SRD spells/burning-hands.md: a 15-ft. cone of searing flame dealing 1d4 points of fire damage per caster level (maximum 5d4); Reflex half. The SRD range is 15 ft. - modeled as the closest category (close, 25 ft. at low levels)."
} }
+2 -1
View File
@@ -9,6 +9,7 @@
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
"shape": "cone",
"radius_ft": 15, "radius_ft": 15,
"save_for_half": false, "save_for_half": false,
"effects": [ "effects": [
@@ -21,5 +22,5 @@
] ]
} }
], ],
"description": "A cone of chaotic colors (modeled as a 15-ft burst) blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned). Will negates." "description": "SRD spells/color-spray.md: a 15-ft. cone of chaotic colors blinds and stuns creatures with fewer HD than your level for 1d4 rounds (approximated as 1 round of stunned; the HD tiers are not modeled). Will negates."
} }
+14 -6
View File
@@ -2,17 +2,25 @@
"name": "Fear", "name": "Fear",
"level": 4, "level": 4,
"school": "necromancy", "school": "necromancy",
"range": "medium", "range": "close",
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"effects": [ "effects": [
{ {
"type": "condition", "type": "area",
"condition_name": "panicked", "shape": "cone",
"duration_rounds": 4, "radius_ft": 30,
"save_negates": true "save_for_half": false,
"effects": [
{
"type": "condition",
"condition_name": "panicked",
"duration_rounds": 4,
"save_negates": true
}
]
} }
], ],
"description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save (a successful save makes it shaken for 1 round instead). Modeled as panicked for 4 rounds on a failed save; the shaken-on-success partial needs partial-save support and the cone template is absent - both documented limitations." "description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save (a successful save makes it shaken for 1 round instead). Modeled as panicked for 4 rounds on a failed save; the shaken-on-success partial needs partial-save support (documented limitation). The SRD range is 30 ft. - modeled as the closest category (close)."
} }
+3 -2
View File
@@ -9,7 +9,8 @@
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
"radius_ft": 20, "shape": "cone",
"radius_ft": 30,
"save_for_half": true, "save_for_half": true,
"effects": [ "effects": [
{ {
@@ -21,5 +22,5 @@
] ]
} }
], ],
"description": "A flurry of snowballs hurtles at your foes: any creature in the area takes 4d6 points of cold damage. Reflex half. The RAW 30-ft cone-shaped burst is approximated as a 20-ft burst centered on the target point." "description": "SRD spells/flurry-of-snowballs.md: any creature in the 30-ft. cone-shaped burst takes 4d6 points of cold damage. Reflex half. The SRD range is 30 ft. - modeled as the closest category (close)."
} }
+14 -6
View File
@@ -8,12 +8,20 @@
"cast_time": "standard", "cast_time": "standard",
"effects": [ "effects": [
{ {
"type": "damage", "type": "area",
"formula": "0", "shape": "line",
"types": ["electricity"], "radius_ft": 120,
"half_on_save": true, "save_for_half": true,
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 } "effects": [
{
"type": "damage",
"formula": "0",
"types": ["electricity"],
"half_on_save": true,
"scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 }
}
]
} }
], ],
"description": "SRD spells/lightning-bolt.md: you release a powerful stroke of electrical energy dealing 1d6 points of electricity damage per caster level (maximum 10d6). Reflex save for half. The 120-ft line template is applied in the area-shapes batch; until then the spell resolves on the target." "description": "SRD spells/lightning-bolt.md: a 120-ft. line dealing 1d6 points of electricity damage per caster level (maximum 10d6). Reflex half. The line starts at the caster's cell (the bolt begins at your fingertips), so the caster's own square is in the area."
} }
+3 -1
View File
@@ -391,8 +391,10 @@ def test_tammara_deed_out_of_range_spends_no_grit() -> None:
def test_misty_emits_inspire_courage_and_spell_combat() -> None: def test_misty_emits_inspire_courage_and_spell_combat() -> None:
# Shocking Grasp (not Burning Hands): the offensive-spell scan reads
# top-level damage effects, and Burning Hands is a cone area spell now.
misty = _combatant( misty = _combatant(
"misty", resources={"bardic_rounds": 12}, spells=["Burning Hands"] "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"]
) )
goblin = _combatant("goblin", hp_max=6) goblin = _combatant("goblin", hp_max=6)
reg = load_spell_registry(SPELLS_DIR) reg = load_spell_registry(SPELLS_DIR)
+12 -6
View File
@@ -458,20 +458,26 @@ class TestTargetedSpellSaveNegates:
class TestLightningBoltSave: class TestLightningBoltSave:
"""Given: Lightning Bolt (ref save for half, half_on_save=True, 1d6 per """Given: Lightning Bolt (SRD spells/lightning-bolt.md: a 120-ft. line,
caster level max 10d6 — SRD spells/lightning-bolt.md) 1d6 per caster level max 10d6, ref save for half) cast by the level-5
When: the level-5 target succeeds its save fixture at a target straight ahead
Then: damage is halved from the SCALED 5d6.""" When: the primary target succeeds its save
Then: damage is halved — and the caster, whose own square the line
starts in ("the bolt begins at your fingertips"), is caught as well."""
def test_successful_save_still_halves(self) -> None: def test_successful_save_still_halves(self) -> None:
caster = _make_caster() caster = _make_caster()
target = _make_target(hp_max=200) target = _make_target(hp_max=200)
# 1 save roll (natural 20 = success) + 5 damage dice (caster level 5) # primary save (target) + secondary save (caster, same line) +
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3]) # 5 damage dice each (1d6/level, level 5)
engine, c, t = _make_engine(
caster, target, [20, 20] + [3] * 10
)
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Lightning Bolt") engine._cast_spell(c, t, "Lightning Bolt")
full = 3 + 3 + 3 + 3 + 3 full = 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2) assert t.hp == hp_before - (full // 2)
assert c.hp == 20 - (full // 2)
class TestDamageEffectHalfOnSaveDefault: class TestDamageEffectHalfOnSaveDefault: