481 lines
18 KiB
Python
481 lines
18 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, 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 <id>: <message>"; 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
|