feat(combat): skills system

This commit is contained in:
2026-09-04 22:55:41 +02:00
parent d792b6e28f
commit ab2874d79b
4 changed files with 233 additions and 1 deletions
+34
View File
@@ -81,6 +81,7 @@ from typing import TYPE_CHECKING
from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers
from pf1e_simulator.models import PREDEFINED_SKILLS
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
BuffEffect, BuffEffect,
ConditionEffect, ConditionEffect,
@@ -561,6 +562,39 @@ class CombatEngine:
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
return SaveResult(success=success, roll=roll, total=total, dc=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]: def _roll_initiative(self) -> list[CombatantState]:
rolls: dict[str, tuple[int, int]] = {} rolls: dict[str, tuple[int, int]] = {}
for state in self._states: for state in self._states:
+10
View File
@@ -43,6 +43,16 @@ StatTarget = Literal[
"attack", "damage", "ac", "touch_ac", "flat_footed_ac", "attack", "damage", "ac", "touch_ac", "flat_footed_ac",
"fort", "ref", "will", "initiative", "fort", "ref", "will", "initiative",
"cmb", "cmd", "speed_ft", "hp", "concentration", "cl", "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"}) _STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
+28 -1
View File
@@ -36,6 +36,7 @@ from pf1e_simulator.models import (
Combatant, Combatant,
DamageComponent, DamageComponent,
Saves, Saves,
SkillRank,
) )
type Json = dict[str, Json] | list[Json] | str | int | float | bool | None 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 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): class SheetLoadError(Exception):
"""Raised when a sheet cannot be loaded; carries path and reason.""" """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") speed_raw = reader.as_dict(combat.get("speed"), "combat.speed")
ac_raw = reader.as_dict(combat.get("ac"), "combat.ac") ac_raw = reader.as_dict(combat.get("ac"), "combat.ac")
hp_raw = reader.as_dict(combat.get("hp"), "combat.hp") 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") sheet_id = reader.req_str(meta, "id", "meta.id")
land_speed = reader.speed(speed_raw.get("land"), "combat.speed.land") land_speed = reader.speed(speed_raw.get("land"), "combat.speed.land")
if land_speed is None: if land_speed is None:
@@ -350,6 +376,7 @@ def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]:
attacks=kept, attacks=kept,
features=_extract_features(root.get("feats")), features=_extract_features(root.get("feats")),
spells=_extract_spells(root.get("spellcasting")), spells=_extract_spells(root.get("spellcasting")),
skills=skills,
) )
report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped)) report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped))
return combatant, report return combatant, report
+161
View File
@@ -40,6 +40,166 @@ class Saves(_FrozenModel):
will: 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): class DamageComponent(_FrozenModel):
"""One damage roll with its types (e.g. 1d8 piercing). """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 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 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 metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
skills: dict[str, SkillRank] = Field(default_factory=dict)