Files
pf1e-simulator/src/pf1e_simulator/models.py
T
ctan 4d89305881 fix(rules): distinguish natural reach from reach weapons
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>
2026-09-11 22:06:46 +02:00

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, ...] = ()