From ee8c8f80ffb9a8f5e173b1b55bbf19825ff53252 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 11:01:07 +0200 Subject: [PATCH] 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 --- README.md | 61 ++++++++++++++++++++-------- data/spells/burning_hands.json | 22 ++++++---- data/spells/color_spray.json | 3 +- data/spells/fear.json | 20 ++++++--- data/spells/flurry_of_snowballs.json | 5 ++- data/spells/lightning_bolt.json | 20 ++++++--- tests/test_class_abilities.py | 4 +- tests/test_spells.py | 18 +++++--- 8 files changed, 108 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c46343c..de50fdc 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/data/spells/burning_hands.json b/data/spells/burning_hands.json index 1cd783a..c088e7c 100644 --- a/data/spells/burning_hands.json +++ b/data/spells/burning_hands.json @@ -2,18 +2,26 @@ "name": "Burning Hands", "level": 1, "school": "evocation", - "range": "touch", + "range": "close", "save": "ref", "base_dc": 10, "cast_time": "standard", "effects": [ { - "type": "damage", - "formula": "0", - "types": ["fire"], - "half_on_save": true, - "scaling": { "dice": "1d4", "per_levels": 1, "max_dice": 5 } + "type": "area", + "shape": "cone", + "radius_ft": 15, + "save_for_half": true, + "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)." } diff --git a/data/spells/color_spray.json b/data/spells/color_spray.json index 83452d6..9946a57 100644 --- a/data/spells/color_spray.json +++ b/data/spells/color_spray.json @@ -9,6 +9,7 @@ "effects": [ { "type": "area", + "shape": "cone", "radius_ft": 15, "save_for_half": false, "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." } diff --git a/data/spells/fear.json b/data/spells/fear.json index d17c183..566260c 100644 --- a/data/spells/fear.json +++ b/data/spells/fear.json @@ -2,17 +2,25 @@ "name": "Fear", "level": 4, "school": "necromancy", - "range": "medium", + "range": "close", "save": "will", "base_dc": 10, "cast_time": "standard", "effects": [ { - "type": "condition", - "condition_name": "panicked", - "duration_rounds": 4, - "save_negates": true + "type": "area", + "shape": "cone", + "radius_ft": 30, + "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)." } diff --git a/data/spells/flurry_of_snowballs.json b/data/spells/flurry_of_snowballs.json index d6f633b..926a062 100644 --- a/data/spells/flurry_of_snowballs.json +++ b/data/spells/flurry_of_snowballs.json @@ -9,7 +9,8 @@ "effects": [ { "type": "area", - "radius_ft": 20, + "shape": "cone", + "radius_ft": 30, "save_for_half": true, "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)." } diff --git a/data/spells/lightning_bolt.json b/data/spells/lightning_bolt.json index 433cc85..d89f747 100644 --- a/data/spells/lightning_bolt.json +++ b/data/spells/lightning_bolt.json @@ -8,12 +8,20 @@ "cast_time": "standard", "effects": [ { - "type": "damage", - "formula": "0", - "types": ["electricity"], - "half_on_save": true, - "scaling": { "dice": "1d6", "per_levels": 1, "max_dice": 10 } + "type": "area", + "shape": "line", + "radius_ft": 120, + "save_for_half": true, + "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." } diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index ee7af6c..c92966e 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -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: + # 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", resources={"bardic_rounds": 12}, spells=["Burning Hands"] + "misty", resources={"bardic_rounds": 12}, spells=["Shocking Grasp"] ) goblin = _combatant("goblin", hp_max=6) reg = load_spell_registry(SPELLS_DIR) diff --git a/tests/test_spells.py b/tests/test_spells.py index c4d2fac..483e257 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -458,20 +458,26 @@ class TestTargetedSpellSaveNegates: class TestLightningBoltSave: - """Given: Lightning Bolt (ref save for half, half_on_save=True, 1d6 per - caster level max 10d6 — SRD spells/lightning-bolt.md) - When: the level-5 target succeeds its save - Then: damage is halved from the SCALED 5d6.""" + """Given: Lightning Bolt (SRD spells/lightning-bolt.md: a 120-ft. line, + 1d6 per caster level max 10d6, ref save for half) cast by the level-5 + fixture at a target straight ahead + 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: caster = _make_caster() target = _make_target(hp_max=200) - # 1 save roll (natural 20 = success) + 5 damage dice (caster level 5) - engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3]) + # primary save (target) + secondary save (caster, same line) + + # 5 damage dice each (1d6/level, level 5) + engine, c, t = _make_engine( + caster, target, [20, 20] + [3] * 10 + ) hp_before = t.hp engine._cast_spell(c, t, "Lightning Bolt") full = 3 + 3 + 3 + 3 + 3 assert t.hp == hp_before - (full // 2) + assert c.hp == 20 - (full // 2) class TestDamageEffectHalfOnSaveDefault: