Files
pf1e-simulator/src/pf1e_simulator/models.py
T

115 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] = []