4d89305881
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural reach is a disk (a Large creature still threatens and strikes adjacent squares), while a reach weapon doubles the wielder's natural reach but 'can't strike at their natural reach or less' (a ring whose dead zone is the natural reach). The engine keyed the ring off reach_ft > 5, so reach-10 natural attacks (giant belostomatid bite/claw, mounted hooves) wrongly got a 5-ft dead zone. AttackSpec gains reach_weapon (parsed from the combatant JSON); the disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied consistently to weapon selection, melee usability, threatened squares and charge end placement. Esha's Lance is the only reach weapon in the shipped data; Large natural attackers keep the default (disk). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
312 lines
10 KiB
Python
312 lines
10 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
|
|
|
|
@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 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.
|
|
"""
|
|
|
|
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
|
|
range_increment_ft: int | None = None
|
|
count: int = Field(default=1, ge=1)
|
|
firearm: bool = False
|
|
two_handed: bool = False
|
|
|
|
|
|
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"
|
|
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
|
|
resistances: dict[str, int] = Field(default_factory=dict)
|
|
immunities: tuple[str, ...] = ()
|