feat(combat): skills system
This commit is contained in:
@@ -81,6 +81,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
|
||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||
from pf1e_simulator.spells import (
|
||||
BuffEffect,
|
||||
ConditionEffect,
|
||||
@@ -561,6 +562,39 @@ class CombatEngine:
|
||||
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||
|
||||
def resolve_skill(
|
||||
self, state: CombatantState, skill: str, dc: int
|
||||
) -> SaveResult:
|
||||
"""Roll a skill check against ``dc``.
|
||||
|
||||
"""
|
||||
skill_spec = PREDEFINED_SKILLS.get(skill)
|
||||
if skill_spec is None:
|
||||
msg = f"Unknown skill: {skill}"
|
||||
raise ValueError(msg)
|
||||
|
||||
roll = self._rng.d20()
|
||||
skill_rank = state.combatant.skills.get(skill, None)
|
||||
rank = skill_rank.rank if skill_rank else 0
|
||||
# Class skill bonus
|
||||
class_bonus = 3 if skill_rank and skill_rank.class_skill else 0
|
||||
# Armor check penalty
|
||||
armor_penalty = skill_rank.armor_penalty if skill_rank else 0
|
||||
if armor_penalty and skill_spec.armor_check_penalty:
|
||||
armor_penalty = -abs(armor_penalty)
|
||||
else:
|
||||
armor_penalty = 0
|
||||
# Ability modifier
|
||||
ability_name = skill_spec.ability
|
||||
ability_score = getattr(state.combatant.abilities, f"{ability_name}_score")
|
||||
ability_mod = (ability_score - 10) // 2
|
||||
# Effect/condition modifiers
|
||||
mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill))
|
||||
total = roll + rank + class_bonus + ability_mod + armor_penalty + mod_bonus
|
||||
# Natural 1/20 are not auto-fail/success for skills
|
||||
success = total >= dc
|
||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||
|
||||
def _roll_initiative(self) -> list[CombatantState]:
|
||||
rolls: dict[str, tuple[int, int]] = {}
|
||||
for state in self._states:
|
||||
|
||||
@@ -43,6 +43,16 @@ StatTarget = Literal[
|
||||
"attack", "damage", "ac", "touch_ac", "flat_footed_ac",
|
||||
"fort", "ref", "will", "initiative",
|
||||
"cmb", "cmd", "speed_ft", "hp", "concentration", "cl",
|
||||
# Skills (37 PF1e skills)
|
||||
"acrobatics", "appraise", "bluff", "climb", "craft",
|
||||
"diplomacy", "disable_device", "disguise", "escape_artist", "fly",
|
||||
"handle_animal", "heal", "intimidate", "knowledge_arcana",
|
||||
"knowledge_dungeoneering", "knowledge_engineering", "knowledge_geography",
|
||||
"knowledge_history", "knowledge_local", "knowledge_nature",
|
||||
"knowledge_nobility", "knowledge_planes", "knowledge_religion",
|
||||
"linguistics", "perception", "perform", "profession", "ride",
|
||||
"sense_motive", "sleight_of_hand", "spellcraft", "stealth",
|
||||
"survival", "swim", "use_magic_device",
|
||||
]
|
||||
|
||||
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
|
||||
|
||||
@@ -36,6 +36,7 @@ from pf1e_simulator.models import (
|
||||
Combatant,
|
||||
DamageComponent,
|
||||
Saves,
|
||||
SkillRank,
|
||||
)
|
||||
|
||||
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None
|
||||
@@ -108,6 +109,30 @@ def _extract_spells(spellcasting: Json) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
def _extract_skills(skills_section: Json) -> dict[str, SkillRank]:
|
||||
"""Extract skills from Foundry sheet skills section."""
|
||||
if not isinstance(skills_section, dict):
|
||||
return {}
|
||||
skills: dict[str, SkillRank] = {}
|
||||
for name, data in skills_section.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
key = name.lower().replace(" ", "_").replace("(", "").replace(")", "")
|
||||
# Handle special cases
|
||||
key = key.replace("knowledge_", "knowledge_")
|
||||
rank = data.get("rank", 0)
|
||||
class_skill = data.get("classSkill", False)
|
||||
armor_penalty = data.get("armorPenalty", 0)
|
||||
if isinstance(armor_penalty, bool):
|
||||
armor_penalty = 0
|
||||
if isinstance(rank, bool):
|
||||
rank = 0
|
||||
skills[key] = SkillRank(
|
||||
rank=int(rank) if isinstance(rank, (int, float)) else 0,
|
||||
class_skill=bool(class_skill),
|
||||
armor_penalty=int(armor_penalty) if isinstance(armor_penalty, (int, float)) else 0,
|
||||
)
|
||||
return skills
|
||||
class SheetLoadError(Exception):
|
||||
"""Raised when a sheet cannot be loaded; carries path and reason."""
|
||||
|
||||
@@ -300,7 +325,8 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
|
||||
speed_raw = reader.as_dict(combat.get("speed"), "combat.speed")
|
||||
ac_raw = reader.as_dict(combat.get("ac"), "combat.ac")
|
||||
hp_raw = reader.as_dict(combat.get("hp"), "combat.hp")
|
||||
|
||||
skills_raw = root.get("skills")
|
||||
skills = _extract_skills(skills_raw)
|
||||
sheet_id = reader.req_str(meta, "id", "meta.id")
|
||||
land_speed = reader.speed(speed_raw.get("land"), "combat.speed.land")
|
||||
if land_speed is None:
|
||||
@@ -350,6 +376,7 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
|
||||
attacks=kept,
|
||||
features=_extract_features(root.get("feats")),
|
||||
spells=_extract_spells(root.get("spellcasting")),
|
||||
skills=skills,
|
||||
)
|
||||
report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped))
|
||||
return combatant, report
|
||||
|
||||
@@ -40,6 +40,166 @@ class Saves(_FrozenModel):
|
||||
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).
|
||||
|
||||
@@ -116,3 +276,4 @@ class Combatant(_FrozenModel):
|
||||
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha
|
||||
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
|
||||
skills: dict[str, SkillRank] = Field(default_factory=dict)
|
||||
|
||||
Reference in New Issue
Block a user