feat(models): combatant schema, Foundry sheet loader, monster JSON schema
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""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.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 = ""
|
||||
Reference in New Issue
Block a user