"""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, Self from pydantic import BaseModel, ConfigDict, Field, field_validator, model_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 @property def str_mod(self) -> int: """Strength ability modifier.""" return (self.str_score - 10) // 2 @property def dex_mod(self) -> int: """Dexterity ability modifier.""" return (self.dex_score - 10) // 2 class ACProfile(_FrozenModel): total: int touch: int flat_footed: int class Saves(_FrozenModel): fort: int ref: int will: int class SkillSpec(_FrozenModel): """Definition of a PF1e skill.""" name: str ability: Literal["str", "dex", "con", "int", "wis", "cha"] trained_only: bool = False armor_check_penalty: bool = False class SkillRank(_FrozenModel): """A combatant's rank in a skill.""" rank: int = 0 class_skill: bool = False armor_penalty: int = 0 # Predefined PF1e skills (37 skills) PREDEFINED_SKILLS: dict[str, SkillSpec] = { "acrobatics": SkillSpec( name="Acrobatics", ability="dex", trained_only=False, armor_check_penalty=True ), "appraise": SkillSpec( name="Appraise", ability="int", trained_only=False, armor_check_penalty=False ), "bluff": SkillSpec( name="Bluff", ability="cha", trained_only=False, armor_check_penalty=False ), "climb": SkillSpec( name="Climb", ability="str", trained_only=False, armor_check_penalty=True ), "craft": SkillSpec( name="Craft", ability="int", trained_only=True, armor_check_penalty=False ), "diplomacy": SkillSpec( name="Diplomacy", ability="cha", trained_only=False, armor_check_penalty=False ), "disable_device": SkillSpec( name="Disable Device", ability="dex", trained_only=True, armor_check_penalty=False ), "disguise": SkillSpec( name="Disguise", ability="cha", trained_only=False, armor_check_penalty=False ), "escape_artist": SkillSpec( name="Escape Artist", ability="dex", trained_only=False, armor_check_penalty=True ), "fly": SkillSpec( name="Fly", ability="dex", trained_only=False, armor_check_penalty=True ), "handle_animal": SkillSpec( name="Handle Animal", ability="cha", trained_only=True, armor_check_penalty=False ), "heal": SkillSpec( name="Heal", ability="wis", trained_only=False, armor_check_penalty=False ), "intimidate": SkillSpec( name="Intimidate", ability="cha", trained_only=False, armor_check_penalty=False ), "knowledge_arcana": SkillSpec( name="Knowledge (Arcana)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_dungeoneering": SkillSpec( name="Knowledge (Dungeoneering)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_engineering": SkillSpec( name="Knowledge (Engineering)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_geography": SkillSpec( name="Knowledge (Geography)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_history": SkillSpec( name="Knowledge (History)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_local": SkillSpec( name="Knowledge (Local)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_nature": SkillSpec( name="Knowledge (Nature)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_nobility": SkillSpec( name="Knowledge (Nobility)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_planes": SkillSpec( name="Knowledge (Planes)", ability="int", trained_only=True, armor_check_penalty=False ), "knowledge_religion": SkillSpec( name="Knowledge (Religion)", ability="int", trained_only=True, armor_check_penalty=False ), "linguistics": SkillSpec( name="Linguistics", ability="int", trained_only=True, armor_check_penalty=False ), "perception": SkillSpec( name="Perception", ability="wis", trained_only=False, armor_check_penalty=False ), "perform": SkillSpec( name="Perform", ability="cha", trained_only=False, armor_check_penalty=False ), "profession": SkillSpec( name="Profession", ability="wis", trained_only=True, armor_check_penalty=False ), "ride": SkillSpec( name="Ride", ability="dex", trained_only=False, armor_check_penalty=True ), "sense_motive": SkillSpec( name="Sense Motive", ability="wis", trained_only=False, armor_check_penalty=False ), "sleight_of_hand": SkillSpec( name="Sleight of Hand", ability="dex", trained_only=True, armor_check_penalty=True ), "spellcraft": SkillSpec( name="Spellcraft", ability="int", trained_only=True, armor_check_penalty=False ), "stealth": SkillSpec( name="Stealth", ability="dex", trained_only=False, armor_check_penalty=True ), "survival": SkillSpec( name="Survival", ability="wis", trained_only=False, armor_check_penalty=False ), "swim": SkillSpec( name="Swim", ability="str", trained_only=False, armor_check_penalty=True ), "use_magic_device": SkillSpec( name="Use Magic Device", ability="cha", trained_only=True, armor_check_penalty=False ), } 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 SplashSpec(_FrozenModel): """Splash payload of a thrown splash weapon (SRD "Throw Splash Weapon"). ``damage`` is the FLAT splash damage every creature within 5 feet of the point of impact takes on a direct hit — no attack roll, no saving throw (SRD "Alchemist's fire": "Every creature within 5 feet of the point where the flask hits takes 1 point of fire damage from the splash"). ``type`` is the energy type ("fire", "acid", ...): splash is energy damage, so the component-based energy resistance/immunity path applies and damage reduction does not; the flat amount is never multiplied on a critical hit. On a miss the weapon scatters (1d8 direction, one range increment) and deals only this splash at where it landed. """ damage: int = Field(ge=0) type: str = Field(min_length=1) _HAFTED_WEAPON_TOKENS: tuple[str, ...] = ( "axe", "spear", "hammer", "mace", "flail", "staff", "pick", "javelin", ) 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. `firearm` marks a ranged-touch weapon (gun) whose shots resolve against touch AC within the first range increment and against full AC beyond it. `two_handed` marks a weapon swung in two hands (two-handed weapon, one-handed weapon used two hands, or a primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon with the reach weapon quality (CRB "Big and Little Creatures in Combat"): it doubles the wielder's NATURAL reach but cannot strike at the wielder's natural reach or less — the usable zone is a RING (dead zone = natural reach). Without the flag a melee attack follows the creature's natural reach, which is a DISK: a Large creature's natural attack (its JSON `reach_ft` IS its natural reach, 10 ft) still strikes and threatens adjacent squares. `natural` marks a natural weapon (bite, claw, etc.): PF1e (CRB "Natural Attacks") grants no BAB iteratives for natural weapons. `secondary` marks a secondary natural attack (tail, wing, etc.): the engine applies -5 to the attack bonus (reduced to -2 with the Multiattack feat). Object statistics (CRB "Breaking and Entering", Table 7-12): `hardness` and `hit_points` are authorable overrides; when absent the engine derives them from the weapon class via `object_stats()`. `enhancement` is the weapon's enhancement bonus (0 = masterwork or plain); each +1 adds +2 hardness and +10 hit points (CRB "Magic Armor, Shields, and Weapons"). """ 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 reach_weapon: bool = False # Positive or None (None = no range increments: melee, or touch at natural # reach). A zero increment would crash the engine's range_penalty with a # ZeroDivisionError (dist_ft // 0); a negative one silently makes the # weapon unusable -- both are data errors, rejected at the schema. range_increment_ft: int | None = Field(default=None, ge=1) count: int = Field(default=1, ge=1) firearm: bool = False two_handed: bool = False natural: bool = False secondary: bool = False hardness: int | None = Field(default=None, ge=0) hit_points: int | None = Field(default=None, ge=1) enhancement: int = Field(default=0, ge=0, le=20) # SRD "Throw Splash Weapon": a thrown splash weapon (alchemist's fire, # acid, holy water) is a ranged attack with a splash payload; it must be # thrown (ranged kind or ranged touch with a range increment), so the # validator below rejects splash on melee or increment-less attacks. splash: SplashSpec | None = None @model_validator(mode="after") def _splash_needs_thrown_range(self) -> Self: if self.splash is not None and ( self.kind == "melee" or self.range_increment_ft is None ): msg = ( f"attack {self.id!r}: splash requires a thrown weapon " "(kind ranged/touch with range_increment_ft)" ) raise ValueError(msg) return self def object_stats(self) -> tuple[int, int]: """Hardness and hit points of the weapon as an OBJECT (Medium item). Defaults from CRB Table 7-12 "Common Armor, Weapon, and Shield Hardness and Hit Points" when not authored: projectile weapons 5/5; hafted weapons (wooden haft: axe, spear, hammer, mace, flail, staff, pick, javelin) hardness 5, 5 hp one-handed / 10 hp two-handed; blades steel hardness 10, 5 hp one-handed / 10 hp two-handed. The light/one-handed granularity is collapsed to one-handed (the model has no weapon-weight field). Enhancement adds +2 hardness and +10 hp per +1 (footnotes 1 and 3). """ if self.kind == "ranged" or self.firearm: base_hardness, base_hp = 5, 5 elif any(token in self.name.lower() for token in _HAFTED_WEAPON_TOKENS): base_hardness, base_hp = 5, (10 if self.two_handed else 5) else: base_hardness, base_hp = 10, (10 if self.two_handed else 5) hardness = self.hardness if self.hardness is not None else base_hardness hit_points = self.hit_points if self.hit_points is not None else base_hp return ( hardness + 2 * self.enhancement, hit_points + 10 * self.enhancement, ) class DamageReduction(_FrozenModel): """DR amount and the damage types that bypass it; empty bypass = DR/—.""" amount: int = Field(ge=1) bypass: frozenset[str] = frozenset() _ALIGNMENT_CODES: frozenset[str] = frozenset( {"LG", "NG", "CG", "LN", "N", "CN", "LE", "NE", "CE"} ) _LAWFUL_CODES: frozenset[str] = frozenset({"LG", "LN", "LE"}) _CHAOTIC_CODES: frozenset[str] = frozenset({"CG", "CN", "CE"}) _GOOD_CODES: frozenset[str] = frozenset({"LG", "NG", "CG"}) _EVIL_CODES: frozenset[str] = frozenset({"LE", "NE", "CE"}) class Combatant(_FrozenModel): """A fully parsed combatant, ready for the engine.""" id: str name: str level: int = Field(ge=1) size: str = "Medium" creature_type: Literal["living", "undead", "construct"] = "living" 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] = [] skills: dict[str, SkillRank] = Field(default_factory=dict) cmb: int | None = None # Combat Maneuver Bonus cmd: int | None = None # Combat Maneuver Defense casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha spell_slots: dict[int, int] = Field(default_factory=dict) # max slots per spell level resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell spells: list[str] = Field(default_factory=list) spell_resistance: int = 0 # PF1e (SRD, Magic - Arcane Spells and Armor): the armor/shield worn # imposes an arcane spell failure chance (percent, 0-100) on arcane # spells with a somatic component. The model has no equipment, so the # chance is authored directly on the combatant JSON. # rules/prpg-core-rulebook/arcane-spells-and-armor.md armor_spell_failure: int = Field(default=0, ge=0, le=100) # True when the combatant's spellcasting is arcane (sorcerer, wizard, # magus...) - only arcane casters incur armor spell failure; divine # casters (cleric, druid...) wear any armor freely. arcane_caster: bool = False resistances: dict[str, int] = Field(default_factory=dict) immunities: tuple[str, ...] = () # PF1e (SRD "Surprise"): "If some but not all of the combatants are aware # of their opponents, a surprise round happens before regular rounds # begin." Unaware combatants do not act in the surprise round. Default # True keeps every existing encounter on a full first round. aware: bool = True # PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of # LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral). alignment: str = "N" # PF1e (APG "Steal"): carried items that are NEITHER wielded in a hand # NOR closely worn - potions, scrolls, tools, pouches, keys, rations... # "This maneuver can be used in melee to take any item that is neither # held nor hidden in a bag or pack [...] Items that are closely worn # (such as armor, backpacks, boots, clothing, or rings) cannot be taken # with this maneuver. Items held in the hands (such as wielded weapons # or wands) also cannot be taken with the steal maneuver - you must use # the disarm combat maneuver instead." Wielded weapons live in # ``attacks``; closely worn gear is not modeled (the AC in the JSON is # a final value), so this list holds only stealable carried items. # rules/advanced-players-guide/steal.md carried_items: tuple[str, ...] = () @field_validator("alignment") @classmethod def _validate_alignment(cls, value: str) -> str: if value not in _ALIGNMENT_CODES: msg = ( f"alignment must be one of {', '.join(sorted(_ALIGNMENT_CODES))}, " f"got {value!r}" ) raise ValueError(msg) return value @field_validator("id") @classmethod def _validate_id(cls, value: str) -> str: # The transcript line format is "round N : "; an id # containing ": " would be mis-split by cli._parse_transcript_line, # and leading/trailing whitespace breaks the "id: " join. if not value or value != value.strip(): msg = f"combatant id must be non-empty without surrounding whitespace, got {value!r}" raise ValueError(msg) if ": " in value: msg = f"combatant id must not contain ': ' (breaks transcript parsing), got {value!r}" raise ValueError(msg) return value @property def is_lawful(self) -> bool: """Law axis: LG/LN/LE.""" return self.alignment in _LAWFUL_CODES @property def is_chaotic(self) -> bool: """Law axis: CG/CN/CE.""" return self.alignment in _CHAOTIC_CODES @property def is_good(self) -> bool: """Good axis: LG/NG/CG.""" return self.alignment in _GOOD_CODES @property def is_evil(self) -> bool: """Evil axis: LE/NE/CE.""" return self.alignment in _EVIL_CODES