Files
pf1e-simulator/src/pf1e_simulator/models.py
T
ctan e5691bdb6f fix(rules): natural weapons get no BAB iteratives, secondary attacks at -5
PF1e (CRB "Natural Attacks"):
- Natural weapons (natural=true) get exactly one swing per full attack,
  regardless of BAB (no BAB iteratives for natural attacks).
- Secondary natural attacks (secondary=true) attack at BAB -5; the
  Multiattack feat reduces this to -2.
- Manifested in combat.py (_full_attack_weapon, _resolve_swing),
  models.py (AttackSpec.natural/secondary), 6 data JSONs, 4 new tests,
  and README documentation.

1461 passed, ruff clean, basedpyright 0 errors.
2026-09-16 12:25:30 +02:00

323 lines
11 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. `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).
"""
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
natural: bool = False
secondary: 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, ...] = ()
# 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