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
(disponible via une politique personnalisée). `default_policy` choisit soin / offensif / buff via
`_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
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
@@ -1361,15 +1390,8 @@ plutôt que bricolées) :
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
moteur n'a ni gabarit de cône, ni scalage par niveau d'incanteur, ni
projectiles multiples, ni sauvegarde partielle) :
moteur n'a ni 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
**longue** — corrigée, conformance D7 — et « For every three caster levels
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
2d4 de dégâts initiaux sont modélisés, les rounds de dégâts persistants sont
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
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`
(conformance D8), mais le moteur n'a pas de sauvegarde partielle pour les
conditions — un succès annule totalement l'effet au lieu de poser `shaken`
1 round — et le gabarit en cône n'existe pas (sort résolu sur la cible) ;
la durée « 1 round/niveau » est fixée à 4 rounds.
round » ; cône de 30 ft — modélisé, S1a/S1c) : la condition appliquée est
bien `panicked` (conformance D8), mais le moteur n'a pas de sauvegarde
partielle pour les conditions — un succès annule totalement l'effet au lieu
de poser `shaken` 1 round ; la durée « 1 round/niveau » est fixée à 4 rounds.
- **Magic Missile — un seul projectile** (SRD spells/magic-missile.md :
« You strike [...] with one missile, plus one additional missile per two
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
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`.
- `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
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
@@ -1467,7 +1493,10 @@ projectiles multiples, ni sauvegarde partielle) :
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
(rayons), temps d'incantation `cast_time`) et types d'effets
(`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`,
`load_spell_registry`) pour `data/spells/`, acceptant les codes de budget
`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) :
```bash
uv run pytest -q # 714 tests
uv run pytest -q # 1364 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```