828afc15c0
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
116 lines
3.0 KiB
Python
116 lines
3.0 KiB
Python
"""Combat data model — the boundary contract for the whole simulator.
|
|
|
|
Every combatant (PC from a Foundry sheet or hand-authored monster JSON) is
|
|
parsed into a `Combatant` exactly once, at the loading boundary. The engine
|
|
never sees raw JSON.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
|
|
from pf1e_simulator.abilities import AbilitySpec # noqa: TC001
|
|
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
|
|
|
|
|
class _FrozenModel(BaseModel):
|
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
|
|
|
|
class AbilityScores(_FrozenModel):
|
|
str_score: int
|
|
dex_score: int
|
|
con_score: int
|
|
int_score: int
|
|
wis_score: int
|
|
cha_score: int
|
|
|
|
|
|
class ACProfile(_FrozenModel):
|
|
total: int
|
|
touch: int
|
|
flat_footed: int
|
|
|
|
|
|
class Saves(_FrozenModel):
|
|
fort: int
|
|
ref: int
|
|
will: int
|
|
|
|
|
|
class DamageComponent(_FrozenModel):
|
|
"""One damage roll with its types (e.g. 1d8 piercing).
|
|
|
|
The formula accepts an already-parsed DiceExpr (Foundry loader path) or a
|
|
plain notation string like "2d6+3" (hand-authored monster JSON path).
|
|
"""
|
|
|
|
formula: DiceExpr
|
|
types: list[str] = Field(min_length=1)
|
|
|
|
@field_validator("formula", mode="before")
|
|
@classmethod
|
|
def _parse_formula(cls, value: object) -> object:
|
|
if isinstance(value, str):
|
|
try:
|
|
return parse_dice(value)
|
|
except DiceParseError as exc:
|
|
msg = str(exc)
|
|
raise ValueError(msg) from exc
|
|
return value
|
|
|
|
|
|
class AttackSpec(_FrozenModel):
|
|
"""One attack menu entry (weapon or natural attack).
|
|
|
|
`count` captures repeated identical swings ("2x Talons") without listing
|
|
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
|
`range_increment_ft` to ranged/touch kinds.
|
|
"""
|
|
|
|
id: str
|
|
name: str
|
|
kind: Literal["melee", "ranged", "touch"]
|
|
attack_bonus: int
|
|
damage: list[DamageComponent] = Field(min_length=1)
|
|
damage_bonus: int = 0
|
|
crit_range: int = Field(default=20, ge=2, le=20)
|
|
crit_mult: int = Field(default=2, ge=2, le=4)
|
|
reach_ft: int = 5
|
|
range_increment_ft: int | None = None
|
|
count: int = Field(default=1, ge=1)
|
|
|
|
|
|
class DamageReduction(_FrozenModel):
|
|
"""DR amount and the damage types that bypass it; empty bypass = DR/—."""
|
|
|
|
amount: int = Field(ge=1)
|
|
bypass: frozenset[str] = frozenset()
|
|
|
|
|
|
class Combatant(_FrozenModel):
|
|
"""A fully parsed combatant, ready for the engine."""
|
|
|
|
id: str
|
|
name: str
|
|
level: int = Field(ge=1)
|
|
size: str = "Medium"
|
|
abilities: AbilityScores
|
|
hp_max: int = Field(ge=1)
|
|
ac: ACProfile
|
|
bab: int
|
|
initiative_mod: int
|
|
speed_land_ft: int = Field(ge=0)
|
|
speed_fly_ft: int | None = None
|
|
saves: Saves
|
|
attacks: list[AttackSpec]
|
|
dr: DamageReduction | None = None
|
|
cr: str | None = None
|
|
xp: int | None = None
|
|
source: str = ""
|
|
notes: str = ""
|
|
features: list[AbilitySpec] = []
|
|
spells: list[str] = []
|