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:
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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)."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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)."
|
||||||
|
}
|
||||||
@@ -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)."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,3 +112,4 @@ class Combatant(_FrozenModel):
|
|||||||
source: str = ""
|
source: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
features: list[AbilitySpec] = []
|
features: list[AbilitySpec] = []
|
||||||
|
spells: list[str] = []
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user