diff --git a/README.md b/README.md index f97120e..2b4af73 100644 --- a/README.md +++ b/README.md @@ -339,10 +339,6 @@ Règles modélisées : Non modélisé (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : -- Sorts (le système d'effets `effects.py`, les règles d'empilement des - bonus, les jets de sauvegarde `resolve_save`, le système de conditions - `conditions.py` et les dons passifs `abilities.py` sont en place — - l'intégration des sorts au moteur est en cours). - Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft @@ -386,6 +382,16 @@ dans la résolution) : `state.power_attack`). Les modificateurs sont collectés par `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ `features` de `Combatant` contient les aptitudes permanentes. +- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée, + sauvegarde, DC de base) et types d'effets (`DamageEffect`, `ConditionEffect`, + `HealEffect`, `BuffEffect`). Portées PF1e (personal, touch, close, medium, + long) calculées par `spell_range_ft`. Chargeur JSON (`load_spell`, + `load_spell_registry`) pour `data/spells/`. Le moteur intègre les sorts via + `Action(kind="cast_spell")` et `_cast_spell` : résolution de sauvegarde, + application des dégâts (avec RD et demi-dégâts sur sauvegarde réussie), + conditions, soins (plafonnés à hp_max) et buffs. Le champ `spells` de + `Combatant` contient les noms de sorts connus ; le `spell_registry` est + passé au `CombatEngine`. - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée à [0, 1]). - `runner.py` — `EncounterSpec`/`Side`, `build_states` (placement en zone + @@ -401,7 +407,7 @@ dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 307 tests +uv run pytest -q # 336 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -419,8 +425,10 @@ uv run basedpyright src # mode strict attaques à outrance, attaques d'opportunité, charge, retraite et pas de placement sont déjà modélisés. Le système d'effets (`effects.py`), les règles d'empilement des bonus, les jets de sauvegarde (`resolve_save`), le - système de conditions (`conditions.py`) et les dons passifs (`abilities.py`) - sont en place. + système de conditions (`conditions.py`), les dons passifs et actifs + (`abilities.py`) et les sorts (`spells.py` + base JSON `data/spells/`) + sont en place. Extension du chargeur Foundry pour l'extraction des sorts et + dons en cours. - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites en politiques, balayage de matrices de positionnement. - **Phase 3** — rapporteur LLM local : agrégation des statistiques et diff --git a/data/spells/acid_arrow.json b/data/spells/acid_arrow.json new file mode 100644 index 0000000..8888fae --- /dev/null +++ b/data/spells/acid_arrow.json @@ -0,0 +1,17 @@ +{ + "name": "Acid Arrow", + "level": 2, + "school": "evocation", + "range": "medium", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "2d4", + "types": ["acid"], + "half_on_save": false + } + ], + "description": "An arrow of acid springs from your hand and speeds to its target, dealing 2d4 points of acid damage. No save." +} diff --git a/data/spells/bulls_strength.json b/data/spells/bulls_strength.json new file mode 100644 index 0000000..d315734 --- /dev/null +++ b/data/spells/bulls_strength.json @@ -0,0 +1,19 @@ +{ + "name": "Bull's Strength", + "level": 2, + "school": "transmutation", + "range": "touch", + "save": "fort", + "base_dc": 10, + "effects": [ + { + "type": "buff", + "modifiers": [ + { "target": "attack", "value": 1, "bonus_type": "enhancement" }, + { "target": "damage", "value": 1, "bonus_type": "enhancement" } + ], + "duration_rounds": 60 + } + ], + "description": "The subject gains a +4 enhancement bonus to Strength, granting +2 attack and +2 damage (approximated as +1/+1). Fortitude negates (harmless)." +} diff --git a/data/spells/burning_hands.json b/data/spells/burning_hands.json new file mode 100644 index 0000000..3ee9f37 --- /dev/null +++ b/data/spells/burning_hands.json @@ -0,0 +1,17 @@ +{ + "name": "Burning Hands", + "level": 1, + "school": "evocation", + "range": "touch", + "save": "ref", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "1d4+1", + "types": ["fire"], + "half_on_save": true + } + ], + "description": "A cone of searing flame shoots from your fingertips, dealing 1d4+1 points of fire damage per caster level (max 5d4+5). Reflex save for half." +} diff --git a/data/spells/cure_light_wounds.json b/data/spells/cure_light_wounds.json new file mode 100644 index 0000000..6279d1b --- /dev/null +++ b/data/spells/cure_light_wounds.json @@ -0,0 +1,15 @@ +{ + "name": "Cure Light Wounds", + "level": 1, + "school": "conjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "heal", + "formula": "1d8+1" + } + ], + "description": "Cures 1d8+1 points of damage. Will save negates (harmless)." +} diff --git a/data/spells/cure_moderate_wounds.json b/data/spells/cure_moderate_wounds.json new file mode 100644 index 0000000..902f5a7 --- /dev/null +++ b/data/spells/cure_moderate_wounds.json @@ -0,0 +1,15 @@ +{ + "name": "Cure Moderate Wounds", + "level": 2, + "school": "conjuration", + "range": "touch", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "heal", + "formula": "2d8+3" + } + ], + "description": "Cures 2d8+3 points of damage. Will save negates (harmless)." +} diff --git a/data/spells/fear.json b/data/spells/fear.json new file mode 100644 index 0000000..57a82f8 --- /dev/null +++ b/data/spells/fear.json @@ -0,0 +1,17 @@ +{ + "name": "Fear", + "level": 4, + "school": "necromancy", + "range": "medium", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "condition", + "condition_name": "frightened", + "duration_rounds": 4, + "save_negates": true + } + ], + "description": "The affected creature becomes frightened and flees from the caster. Will negates." +} diff --git a/data/spells/fireball.json b/data/spells/fireball.json new file mode 100644 index 0000000..a378bc2 --- /dev/null +++ b/data/spells/fireball.json @@ -0,0 +1,17 @@ +{ + "name": "Fireball", + "level": 3, + "school": "evocation", + "range": "long", + "save": "ref", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "6d6", + "types": ["fire"], + "half_on_save": true + } + ], + "description": "A glowing bead streaks from you and bursts into a 20-foot radius explosion of fire, dealing 6d6 points of fire damage. Reflex save for half." +} diff --git a/data/spells/hold_person.json b/data/spells/hold_person.json new file mode 100644 index 0000000..343a8f9 --- /dev/null +++ b/data/spells/hold_person.json @@ -0,0 +1,17 @@ +{ + "name": "Hold Person", + "level": 3, + "school": "enchantment", + "range": "medium", + "save": "will", + "base_dc": 10, + "effects": [ + { + "type": "condition", + "condition_name": "paralyzed", + "duration_rounds": 3, + "save_negates": true + } + ], + "description": "The subject becomes paralyzed and freezes in place. Will negates." +} diff --git a/data/spells/lightning_bolt.json b/data/spells/lightning_bolt.json new file mode 100644 index 0000000..186fc0d --- /dev/null +++ b/data/spells/lightning_bolt.json @@ -0,0 +1,17 @@ +{ + "name": "Lightning Bolt", + "level": 3, + "school": "evocation", + "range": "medium", + "save": "ref", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "6d6", + "types": ["electricity"], + "half_on_save": true + } + ], + "description": "You release a powerful stroke of electrical energy, dealing 6d6 points of electricity damage. Reflex save for half." +} diff --git a/data/spells/magic_missile.json b/data/spells/magic_missile.json new file mode 100644 index 0000000..21aa47d --- /dev/null +++ b/data/spells/magic_missile.json @@ -0,0 +1,17 @@ +{ + "name": "Magic Missile", + "level": 1, + "school": "evocation", + "range": "medium", + "save": "none", + "base_dc": 10, + "effects": [ + { + "type": "damage", + "formula": "1d4+1", + "types": ["force"], + "half_on_save": false + } + ], + "description": "A missile of magical energy darts forth from your fingertip and strikes its target, dealing 1d4+1 points of force damage." +} diff --git a/pyproject.toml b/pyproject.toml index 7ce8e30..9cac199 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,8 @@ allowed-confusables = ["σ"] "src/pf1e_simulator/rng.py" = ["S311"] # cli.py: stdout printing is the point of a CLI "src/pf1e_simulator/cli.py" = ["T201"] +# combat.py: CombatEngine.__init__ needs 6 params (rng, grid, states, round_cap, policy, spell_registry) +"src/pf1e_simulator/combat.py" = ["PLR0913"] # ── Basedpyright ────────────────────────────────────────────────────────────── [tool.basedpyright] diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 12d99f3..29fe47a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -79,7 +79,14 @@ from collections.abc import Callable from dataclasses import asdict, dataclass, field from typing import TYPE_CHECKING +from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.effects import StatModifier, resolve_modifiers +from pf1e_simulator.spells import ( + ConditionEffect, + DamageEffect, + HealEffect, + spell_range_ft, +) if TYPE_CHECKING: from typing import Literal @@ -89,6 +96,7 @@ if TYPE_CHECKING: from pf1e_simulator.map import Pos from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction from pf1e_simulator.rng import Rng + from pf1e_simulator.spells import SpellSpec from pf1e_simulator.los import has_cover, has_line_of_effect @@ -106,6 +114,13 @@ _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) + +def _save_label(result: SaveResult | None) -> str: + if result is None: + return "NO SAVE" + return "SAVED" if result.success else "FAILED" + + _STEP_DELTAS: tuple[Pos, ...] = ( (-1, -1), (-1, 0), @@ -218,9 +233,10 @@ class Action: kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", - "full_attack", "charge", "withdraw", "5foot_step", + "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", ] target_id: str | None = None + spell_name: str | None = None @dataclass(frozen=True) @@ -252,12 +268,14 @@ class CombatEngine: *, round_cap: int = 100, policy: Policy | None = None, + spell_registry: dict[str, SpellSpec] | None = None, ) -> None: self._rng = rng self._grid = grid self._states = states self._round_cap = round_cap self._policy = policy if policy is not None else default_policy + self._spell_registry = spell_registry or {} self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 @@ -450,6 +468,16 @@ class CombatEngine: return damage return max(0, damage - dr.amount) + def _apply_dr_for_types( + self, damage: int, damage_types: frozenset[str], target: CombatantState + ) -> int: + dr: DamageReduction | None = target.combatant.dr + if dr is None: + return damage + if damage_types & dr.bypass: + return damage + return max(0, damage - dr.amount) + def resolve_save( self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int ) -> SaveResult: @@ -714,6 +742,18 @@ class CombatEngine: self._move(state, target) elif action.kind == "wait": self._log(f"round {self._current_round} {state.combatant.id}: wait") + elif action.kind == "cast_spell": + self._execute_cast_spell(state, target, action.spell_name) + + def _execute_cast_spell( + self, + state: CombatantState, + target: CombatantState | None, + spell_name: str | None, + ) -> None: + if spell_name is None: + return + self._cast_spell(state, target, spell_name) def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str @@ -738,6 +778,93 @@ class CombatEngine: return self._charge(state, target, weapon) + def _cast_spell( + self, caster: CombatantState, target: CombatantState | None, spell_name: str + ) -> None: + spell = self._spell_registry.get(spell_name) + if spell is None: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} -> UNKNOWN SPELL" + ) + return + if target is None or not target.active: + return + dist_ft = self._grid.distance(caster.pos, target.pos) * _SQUARE_FT + max_range = spell_range_ft(spell, caster.combatant.level) + if dist_ft > max_range: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} -> OUT OF RANGE" + ) + return + if not has_line_of_effect(self._grid, caster.pos, target.pos): + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} -> NO LINE OF EFFECT" + ) + return + dc = spell.base_dc + caster.combatant.level + save_result = ( + self.resolve_save(target, spell.save, dc) if spell.save != "none" else None + ) + caster_live = self._stats[caster.combatant.id] + target_live = self._stats[target.combatant.id] + hp_before = target.hp + parts = self._apply_spell_effects(spell, target, save_result, caster_live, target_live) + save_str = _save_label(save_result) + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"cast {spell_name} vs {target.combatant.id} " + f"DC{dc} {save_str} " + f"-> {'; '.join(parts)} " + f"({hp_before}->{target.hp})" + ) + if target.dead: + self._log( + f"round {self._current_round} {caster.combatant.id}: " + f"{target.combatant.id} dead" + ) + + def _apply_spell_effects( + self, + spell: SpellSpec, + target: CombatantState, + save_result: SaveResult | None, + caster_live: _LiveStats, + target_live: _LiveStats, + ) -> list[str]: + parts: list[str] = [] + saved = save_result is not None and save_result.success + for effect in spell.effects: + if isinstance(effect, DamageEffect): + raw = effect.formula.roll(self._rng) + dmg = max(0, raw // 2) if saved and effect.half_on_save else raw + dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target) + target.hp -= dmg + caster_live.damage_dealt += dmg + target_live.damage_taken += dmg + parts.append(f"{dmg} damage") + elif isinstance(effect, ConditionEffect): + if saved and effect.save_negates: + parts.append(f"{effect.condition_name} negated") + else: + cond = CONDITIONS_BY_NAME.get(effect.condition_name) + if cond is not None: + target.conditions.append(cond) + parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)") + elif isinstance(effect, HealEffect): + heal = effect.formula.roll(self._rng) + old_hp = target.hp + target.hp = min(target.hp + heal, target.combatant.hp_max) + actual = target.hp - old_hp + parts.append(f"+{actual} HP") + else: + for mod in effect.modifiers: + target.effects.append(mod) + parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)") + return parts + def _resolve_swing( self, attacker: CombatantState, diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index a8002c5..6ebee0d 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -208,3 +208,13 @@ DAZED = Condition( STAGGERED = Condition( name="staggered", ) + +# ── Registry ───────────────────────────────────────────────────────────────── +CONDITIONS_BY_NAME: dict[str, Condition] = { + c.name: c + for c in [ + SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED, + BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, + NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, + ] +} diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 43eba4a..6015047 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -112,3 +112,4 @@ class Combatant(_FrozenModel): source: str = "" notes: str = "" features: list[AbilitySpec] = [] + spells: list[str] = [] diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py new file mode 100644 index 0000000..3d3c00a --- /dev/null +++ b/src/pf1e_simulator/spells.py @@ -0,0 +1,140 @@ +"""PF1e spells: spell definitions, effect types, and JSON loader. + +Spells are SpellSpec dataclasses with typed effects: +- DamageEffect: dice damage with optional save for half +- ConditionEffect: applies a condition (looked up in CONDITIONS_BY_NAME) +- HealEffect: restores HP (capped at hp_max) +- BuffEffect: grants StatModifiers as transient effects + +Range categories: personal, touch, close, medium, long. +Save types: fort, ref, will, none. + +Sources: PRPG Core Rulebook, Magic chapter. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +from pf1e_simulator.dice import DiceExpr, parse_dice +from pf1e_simulator.effects import BonusType, StatModifier + +if TYPE_CHECKING: + from pf1e_simulator.loaders.foundry import Json + +SpellRange = Literal["personal", "touch", "close", "medium", "long"] +SaveType = Literal["fort", "ref", "will", "none"] + + +@dataclass(frozen=True) +class DamageEffect: + formula: DiceExpr + types: tuple[str, ...] + half_on_save: bool = True + + +@dataclass(frozen=True) +class ConditionEffect: + condition_name: str + duration_rounds: int + save_negates: bool = True + + +@dataclass(frozen=True) +class HealEffect: + formula: DiceExpr + + +@dataclass(frozen=True) +class BuffEffect: + modifiers: tuple[StatModifier, ...] + duration_rounds: int + + +SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect + + +@dataclass(frozen=True) +class SpellSpec: + name: str + level: int + school: str + range: SpellRange = "medium" + save: SaveType = "none" + base_dc: int = 10 + effects: tuple[SpellEffect, ...] = () + description: str = "" + + +def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: + if spell.range == "personal": + return 0 + if spell.range == "touch": + return 5 + if spell.range == "close": + return 25 + 5 * (caster_level // 2) + if spell.range == "medium": + return 100 + 10 * caster_level + return 400 + 40 * caster_level + + +def _parse_effect(data: dict[str, Json]) -> SpellEffect: + effect_type = str(data["type"]) + if effect_type == "damage": + return DamageEffect( + formula=parse_dice(str(data["formula"])), + types=tuple(str(t) for t in data["types"]), # type: ignore[index] + half_on_save=bool(data.get("half_on_save", True)), + ) + if effect_type == "condition": + return ConditionEffect( + condition_name=str(data["condition_name"]), + duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] + save_negates=bool(data.get("save_negates", True)), + ) + if effect_type == "heal": + return HealEffect( + formula=parse_dice(str(data["formula"])), + ) + if effect_type == "buff": + return BuffEffect( + modifiers=tuple( + StatModifier( + target=str(m["target"]), # type: ignore[index] + value=int(m["value"]), # type: ignore[arg-type] + bonus_type=cast("BonusType", str(m.get("bonus_type", ""))), # type: ignore[union-attr] + condition=m.get("condition"), # type: ignore[assignment] + weapon_filter=m.get("weapon_filter"), # type: ignore[assignment] + ) + for m in cast("list[dict[str, Json]]", data["modifiers"]) + ), + duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] + ) + msg = f"Unknown effect type: {effect_type}" + raise ValueError(msg) + + +def load_spell(path: str | Path) -> SpellSpec: + data: dict[str, Json] = json.loads(Path(path).read_text()) + effects = tuple(_parse_effect(e) for e in data.get("effects", [])) # type: ignore[arg-type] + return SpellSpec( + name=str(data["name"]), + level=int(data["level"]), # type: ignore[arg-type] + school=str(data["school"]), + range=str(data.get("range", "medium")), # type: ignore[assignment] + save=str(data.get("save", "none")), # type: ignore[assignment] + base_dc=int(data.get("base_dc", 10)), # type: ignore[arg-type] + effects=effects, + description=str(data.get("description", "")), + ) + + +def load_spell_registry(dir_path: str | Path) -> dict[str, SpellSpec]: + registry: dict[str, SpellSpec] = {} + for path in sorted(Path(dir_path).glob("*.json")): + spell = load_spell(path) + registry[spell.name] = spell + return registry diff --git a/tests/test_spells.py b/tests/test_spells.py new file mode 100644 index 0000000..f9c5853 --- /dev/null +++ b/tests/test_spells.py @@ -0,0 +1,398 @@ +"""Tests for spell system: loading, range, casting (damage, save, condition, heal, buff).""" + +from __future__ import annotations + +from pathlib import Path + +from pf1e_simulator.combat import Action, CombatantState, CombatEngine +from pf1e_simulator.dice import parse_dice +from pf1e_simulator.grid import Grid +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.models import ( + AbilityScores, + ACProfile, + AttackSpec, + Combatant, + DamageComponent, + DamageReduction, + Saves, +) +from pf1e_simulator.rng import ScriptedRng +from pf1e_simulator.spells import ( + BuffEffect, + ConditionEffect, + DamageEffect, + HealEffect, + SpellSpec, + load_spell, + load_spell_registry, + spell_range_ft, +) + +_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells" + + +def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant: + attack = AttackSpec( + id=f"{cid}-w", + name="dagger", + kind="melee", + attack_bonus=2, + damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], + ) + return Combatant( + id=cid, + name=cid, + level=level, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=14, con_score=12, + int_score=18, wis_score=12, cha_score=10, + ), + hp_max=20, + ac=ACProfile(total=12, touch=12, flat_footed=10), + bab=2, + initiative_mod=2, + speed_land_ft=30, + saves=Saves(fort=3, ref=4, will=4), + attacks=[attack], + ) + + +def _make_target(cid: str = "target", *, hp_max: int = 30) -> Combatant: + attack = AttackSpec( + id=f"{cid}-w", + name="club", + kind="melee", + attack_bonus=3, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])], + ) + return Combatant( + id=cid, + name=cid, + level=1, + size="Medium", + abilities=AbilityScores( + str_score=10, dex_score=10, con_score=10, + int_score=10, wis_score=10, cha_score=10, + ), + hp_max=hp_max, + ac=ACProfile(total=10, touch=10, flat_footed=10), + bab=1, + initiative_mod=0, + speed_land_ft=30, + saves=Saves(fort=2, ref=2, will=2), + attacks=[attack], + ) + + +def _make_engine( + caster: Combatant, + target: Combatant, + queue: list[int], + *, + caster_pos: tuple[int, int] = (0, 0), + target_pos: tuple[int, int] = (1, 1), + spell_registry: dict[str, SpellSpec] | None = None, +) -> tuple[CombatEngine, CombatantState, CombatantState]: + c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max) + t = CombatantState(combatant=target, side="monsters", pos=target_pos, hp=target.hp_max) + legend = {".": TerrainType(type="floor", move_cost=1)} + spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) + grid = Grid.from_spec(spec) + reg = spell_registry if spell_registry is not None else load_spell_registry(_SPELLS_DIR) + engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg) + return engine, c, t + + +class TestSpellLoading: + """Given: data/spells/ directory with 10 JSON spell files + When: load_spell_registry is called + Then: all 10 spells are loaded with correct fields.""" + + def test_loads_all_10_spells(self) -> None: + reg = load_spell_registry(_SPELLS_DIR) + assert len(reg) == 10 + + def test_spell_names_present(self) -> None: + reg = load_spell_registry(_SPELLS_DIR) + expected = { + "Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt", + "Cure Light Wounds", "Cure Moderate Wounds", "Hold Person", + "Fear", "Bull's Strength", "Acid Arrow", + } + assert set(reg) == expected + + def test_fireball_fields(self) -> None: + spell = load_spell(_SPELLS_DIR / "fireball.json") + assert spell.name == "Fireball" + assert spell.level == 3 + assert spell.school == "evocation" + assert spell.range == "long" + assert spell.save == "ref" + assert spell.base_dc == 10 + assert len(spell.effects) == 1 + assert isinstance(spell.effects[0], DamageEffect) + assert spell.effects[0].half_on_save is True + + def test_hold_person_has_condition_effect(self) -> None: + spell = load_spell(_SPELLS_DIR / "hold_person.json") + eff = spell.effects[0] + assert isinstance(eff, ConditionEffect) + assert eff.condition_name == "paralyzed" + assert eff.save_negates is True + + def test_cure_light_has_heal_effect(self) -> None: + spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json") + assert isinstance(spell.effects[0], HealEffect) + + def test_bulls_strength_has_buff_effect(self) -> None: + spell = load_spell(_SPELLS_DIR / "bulls_strength.json") + eff = spell.effects[0] + assert isinstance(eff, BuffEffect) + assert len(eff.modifiers) == 2 + + +class TestSpellRangeFt: + """Given: a spell with a range category + When: spell_range_ft is called + Then: returns the correct range in feet.""" + + def test_personal_is_zero(self) -> None: + spell = SpellSpec(name="t", level=1, school="x", range="personal") + assert spell_range_ft(spell, 10) == 0 + + def test_touch_is_five(self) -> None: + spell = SpellSpec(name="t", level=1, school="x", range="touch") + assert spell_range_ft(spell, 10) == 5 + + def test_close_scales_with_level(self) -> None: + spell = SpellSpec(name="t", level=1, school="x", range="close") + assert spell_range_ft(spell, 2) == 25 + 5 * 1 + assert spell_range_ft(spell, 4) == 25 + 5 * 2 + + def test_medium_scales_with_level(self) -> None: + spell = SpellSpec(name="t", level=1, school="x", range="medium") + assert spell_range_ft(spell, 3) == 100 + 30 + + def test_long_scales_with_level(self) -> None: + spell = SpellSpec(name="t", level=1, school="x", range="long") + assert spell_range_ft(spell, 2) == 400 + 80 + + +class TestMagicMissile: + """Given: a caster casting Magic Missile (no save, force damage) + When: _cast_spell is called + Then: target takes full damage with no save.""" + + def test_deals_damage_no_save(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine(caster, target, [3]) + hp_before = t.hp + engine._cast_spell(c, t, "Magic Missile") + assert t.hp < hp_before + assert t.hp == hp_before - (3 + 1) + + def test_transcript_shows_no_save(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [3]) + engine._cast_spell(c, t, "Magic Missile") + assert any("NO SAVE" in line for line in engine._transcript) + + +class TestFireballSave: + """Given: a caster casting Fireball (ref save for half) + When: target saves / fails + Then: damage is halved / full.""" + + def test_failed_save_full_damage(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=200) + engine, c, t = _make_engine(caster, target, [2, 3, 3, 3, 3, 3, 3]) + hp_before = t.hp + engine._cast_spell(c, t, "Fireball") + assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3) + + def test_successful_save_half_damage(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=200) + engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3]) + hp_before = t.hp + engine._cast_spell(c, t, "Fireball") + full = 3 + 3 + 3 + 3 + 3 + 3 + assert t.hp == hp_before - (full // 2) + + def test_transcript_shows_saved(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) + engine._cast_spell(c, t, "Fireball") + assert any("SAVED" in line for line in engine._transcript) + + +class TestHoldPerson: + """Given: a caster casting Hold Person (will negates, paralyzed) + When: target fails save + Then: paralyzed condition is applied.""" + + def test_failed_save_applies_condition(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [2]) + engine._cast_spell(c, t, "Hold Person") + assert any(cond.name == "paralyzed" for cond in t.conditions) + + def test_successful_save_no_condition(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Hold Person") + assert not any(cond.name == "paralyzed" for cond in t.conditions) + + def test_transcript_shows_negated_on_save(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Hold Person") + assert any("negated" in line for line in engine._transcript) + + +class TestFear: + """Given: a caster casting Fear (will negates, frightened) + When: target fails save + Then: frightened condition is applied.""" + + def test_failed_save_applies_frightened(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [2]) + engine._cast_spell(c, t, "Fear") + assert any(cond.name == "frightened" for cond in t.conditions) + + +class TestCureLightWounds: + """Given: a caster casting Cure Light Wounds on a wounded target + When: _cast_spell is called + Then: HP is restored, capped at hp_max.""" + + def test_heals_damage(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=30) + engine, c, t = _make_engine(caster, target, [20, 5]) + t.hp = 10 + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 10 + (5 + 1) + + def test_heal_capped_at_max(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=30) + engine, c, t = _make_engine(caster, target, [20, 6]) + t.hp = 28 + engine._cast_spell(c, t, "Cure Light Wounds") + assert t.hp == 30 + + +class TestBullsStrength: + """Given: a caster casting Bull's Strength on a target + When: _cast_spell is called + Then: buff modifiers are added to target's effects.""" + + def test_applies_buff_effects(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Bull's Strength") + atk_mods = [e for e in t.effects if e.target == "attack"] + dmg_mods = [e for e in t.effects if e.target == "damage"] + assert len(atk_mods) == 1 + assert len(dmg_mods) == 1 + assert atk_mods[0].value == 1 + assert dmg_mods[0].value == 1 + + def test_transcript_shows_buff(self) -> None: + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Bull's Strength") + assert any("buff" in line for line in engine._transcript) + + +class TestOutOfRange: + """Given: a target beyond the spell's maximum range + When: _cast_spell is called + Then: no damage is dealt and transcript shows OUT OF RANGE.""" + + def test_out_of_range_no_damage(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine( + caster, target, [], caster_pos=(0, 0), target_pos=(11, 11) + ) + hp_before = t.hp + engine._cast_spell(c, t, "Burning Hands") + assert t.hp == hp_before + assert any("OUT OF RANGE" in line for line in engine._transcript) + + +class TestUnknownSpell: + """Given: a spell name not in the registry + When: _cast_spell is called + Then: transcript shows UNKNOWN SPELL and no damage.""" + + def test_unknown_spell_logged(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine(caster, target, []) + hp_before = t.hp + engine._cast_spell(c, t, "Nonexistent Spell") + assert t.hp == hp_before + assert any("UNKNOWN SPELL" in line for line in engine._transcript) + + +class TestSpellDispatchViaExecute: + """Given: an Action(kind='cast_spell') with a spell_name + When: _execute processes it + Then: the spell is cast via _cast_spell.""" + + def test_execute_cast_spell(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=100) + engine, c, t = _make_engine(caster, target, [4]) + hp_before = t.hp + engine._execute( + c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile") + ) + assert t.hp < hp_before + + +class TestSpellDcScalesWithLevel: + """Given: a caster with level N + When: a spell with base_dc 10 is cast + Then: the DC is 10 + N.""" + + def test_dc_is_base_plus_level(self) -> None: + caster = _make_caster(level=5) + target = _make_target() + engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1]) + engine._cast_spell(c, t, "Fireball") + assert any("DC15" in line for line in engine._transcript) + + +class TestSpellDamageWithDr: + """Given: a target with DR vs the spell's damage type + When: a spell deals damage + Then: DR is applied after save halving.""" + + def test_dr_reduces_spell_damage(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=200) + target_with_dr = target.model_copy(update={ + "dr": DamageReduction(amount=5, bypass=frozenset({"bludgeoning"})), + }) + engine, c, t = _make_engine(caster, target_with_dr, [2, 6, 6, 6, 6, 6, 6]) + hp_before = t.hp + engine._cast_spell(c, t, "Fireball") + full = 6 * 6 + assert t.hp == hp_before - max(0, full - 5)