feat(spells): add spell system with 10 combat spells, cast_spell action

Spells are SpellSpec dataclasses with typed effects: DamageEffect
(dice + types, half-on-save), ConditionEffect (condition lookup +
save negates), HealEffect (capped at hp_max), BuffEffect (transient
StatModifiers). Range categories (personal/touch/close/medium/long)
scale with caster level via spell_range_ft. JSON loader
(load_spell/load_spell_registry) reads data/spells/*.json.

The engine integrates spells via Action(kind='cast_spell') and
_cast_spell: range check, line-of-effect gate, save resolution
(resolve_save), effect application (_apply_spell_effects with DR
via _apply_dr_for_types), transcript logging. The spell_registry
is passed to CombatEngine; Combatant.spells holds known spell names.

- spells.py: SpellSpec, 4 effect types, SpellRange/SaveType literals,
  spell_range_ft, JSON loader (uses Json type from foundry.py)
- conditions.py: CONDITIONS_BY_NAME registry for condition lookup
- combat.py: cast_spell Action kind, _execute_cast_spell dispatch,
  _cast_spell (range/LoE/save/effects/log), _apply_spell_effects,
  _apply_dr_for_types, _save_label helper, SpellSpec/BuffEffect/etc
  imports, spell_registry parameter on CombatEngine
- models.py: spells field on Combatant
- data/spells/: 10 spells (Magic Missile, Burning Hands, Fireball,
  Lightning Bolt, Acid Arrow, Cure Light/Moderate Wounds, Hold Person,
  Fear, Bull's Strength)
- tests/test_spells.py: 29 tests (loading, range, damage/save/condition/
  heal/buff/DR, out-of-range, unknown spell, dispatch, DC scaling)
- pyproject.toml: PLR0913 ignore for combat.py (CombatEngine.__init__)
- README.md: spells.py architecture section, Sorts removed from
  non-modeled list, 336 tests
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 97c0431479
commit 828afc15c0
17 changed files with 862 additions and 8 deletions
+15 -7
View File
@@ -339,10 +339,6 @@ Règles modélisées :
Non modélisé (couches `elevation`/`markers` présentes mais non appliquées Non modélisé (couches `elevation`/`markers` présentes mais non appliquées
dans la résolution) : 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. - Manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft - Tailles Large+ (2×2), allonge > 5 ft
@@ -386,6 +382,16 @@ dans la résolution) :
`state.power_attack`). Les modificateurs sont collectés par `state.power_attack`). Les modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes. `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`, - `metrics.py` — statistiques en forme fermée : `win_rate`, `win_rate_sigma`,
`win_rate_band` (bande 3σ bornée à [0, 1]). `win_rate_band` (bande 3σ bornée à [0, 1]).
- `runner.py``EncounterSpec`/`Side`, `build_states` (placement en zone + - `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) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 307 tests uv run pytest -q # 336 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
``` ```
@@ -419,8 +425,10 @@ uv run basedpyright src # mode strict
attaques à outrance, attaques d'opportunité, charge, retraite et pas de 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 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 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`) système de conditions (`conditions.py`), les dons passifs et actifs
sont en place. (`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 - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et - **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+17
View File
@@ -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."
}
+19
View File
@@ -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)."
}
+17
View File
@@ -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."
}
+15
View File
@@ -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)."
}
+15
View File
@@ -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)."
}
+17
View File
@@ -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."
}
+17
View File
@@ -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."
}
+17
View File
@@ -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."
}
+17
View File
@@ -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."
}
+17
View File
@@ -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."
}
+2
View File
@@ -52,6 +52,8 @@ allowed-confusables = ["σ"]
"src/pf1e_simulator/rng.py" = ["S311"] "src/pf1e_simulator/rng.py" = ["S311"]
# cli.py: stdout printing is the point of a CLI # cli.py: stdout printing is the point of a CLI
"src/pf1e_simulator/cli.py" = ["T201"] "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 ────────────────────────────────────────────────────────────── # ── Basedpyright ──────────────────────────────────────────────────────────────
[tool.basedpyright] [tool.basedpyright]
+128 -1
View File
@@ -79,7 +79,14 @@ from collections.abc import Callable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import StatModifier, resolve_modifiers from pf1e_simulator.effects import StatModifier, resolve_modifiers
from pf1e_simulator.spells import (
ConditionEffect,
DamageEffect,
HealEffect,
spell_range_ft,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Literal from typing import Literal
@@ -89,6 +96,7 @@ if TYPE_CHECKING:
from pf1e_simulator.map import Pos from pf1e_simulator.map import Pos
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
from pf1e_simulator.rng import Rng from pf1e_simulator.rng import Rng
from pf1e_simulator.spells import SpellSpec
from pf1e_simulator.los import has_cover, has_line_of_effect 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_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) _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, ...] = ( _STEP_DELTAS: tuple[Pos, ...] = (
(-1, -1), (-1, -1),
(-1, 0), (-1, 0),
@@ -218,9 +233,10 @@ class Action:
kind: Literal[ kind: Literal[
"attack", "move", "wait", "swift", "free", "immediate", "full_round", "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 target_id: str | None = None
spell_name: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -252,12 +268,14 @@ class CombatEngine:
*, *,
round_cap: int = 100, round_cap: int = 100,
policy: Policy | None = None, policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
) -> None: ) -> None:
self._rng = rng self._rng = rng
self._grid = grid self._grid = grid
self._states = states self._states = states
self._round_cap = round_cap self._round_cap = round_cap
self._policy = policy if policy is not None else default_policy self._policy = policy if policy is not None else default_policy
self._spell_registry = spell_registry or {}
self._transcript: list[str] = [] self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0 self._current_round = 0
@@ -450,6 +468,16 @@ class CombatEngine:
return damage return damage
return max(0, damage - dr.amount) 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( def resolve_save(
self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int
) -> SaveResult: ) -> SaveResult:
@@ -714,6 +742,18 @@ class CombatEngine:
self._move(state, target) self._move(state, target)
elif action.kind == "wait": elif action.kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: 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( def _execute_attack(
self, state: CombatantState, target: CombatantState | None, kind: str self, state: CombatantState, target: CombatantState | None, kind: str
@@ -738,6 +778,93 @@ class CombatEngine:
return return
self._charge(state, target, weapon) 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( def _resolve_swing(
self, self,
attacker: CombatantState, attacker: CombatantState,
+10
View File
@@ -208,3 +208,13 @@ DAZED = Condition(
STAGGERED = Condition( STAGGERED = Condition(
name="staggered", 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,
]
}
+1
View File
@@ -112,3 +112,4 @@ class Combatant(_FrozenModel):
source: str = "" source: str = ""
notes: str = "" notes: str = ""
features: list[AbilitySpec] = [] features: list[AbilitySpec] = []
spells: list[str] = []
+140
View File
@@ -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
+398
View File
@@ -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)