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