From 99686158cf075af026d03ba967356d4508b065dd Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 29 Aug 2026 23:29:17 +0200 Subject: [PATCH] feat(combat): skills system --- src/pf1e_simulator/combat.py | 34 ++++++ src/pf1e_simulator/effects.py | 10 ++ src/pf1e_simulator/loaders/foundry.py | 29 ++++- src/pf1e_simulator/models.py | 161 ++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 1 deletion(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index bafe6d7..c49d42a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 4468f57..85da18a 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -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"}) diff --git a/src/pf1e_simulator/loaders/foundry.py b/src/pf1e_simulator/loaders/foundry.py index 13dafcc..d1e9970 100644 --- a/src/pf1e_simulator/loaders/foundry.py +++ b/src/pf1e_simulator/loaders/foundry.py @@ -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 diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 9e55eff..3d266a8 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -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)