feat(rules): skill-check subsystem foundation - public skill_check helper (d20 + ranks + ability + trained-class-skill +3 + armor check penalty + typed bonuses, one-shot skill track discharged, no natural 1/20 auto-success/fail per SRD Skill Checks), resolve_skill delegates to it, class-skill +3 now requires invested ranks (Table 4-2 Trained Class Skill row), 8 tests

This commit is contained in:
2026-09-20 20:48:26 +02:00
parent 678a4d8b31
commit e0fb5beae2
2 changed files with 240 additions and 15 deletions
+56 -15
View File
@@ -1277,6 +1277,20 @@ class SaveResult:
dc: int
@dataclass(frozen=True)
class SkillCheckResult:
"""Outcome of one d20 skill check: the raw die and the full total.
No success/failure verdict lives here - a skill check only succeeds
against a DC (``CombatEngine.resolve_skill``) or against an opposing
check (CRB "Skill Checks": "the attempt is successful if your check
result exceeds the result of the target").
"""
roll: int
total: int
@dataclass(frozen=True)
class Action:
"""What a policy wants a combatant to do this turn.
@@ -4085,28 +4099,51 @@ 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``."""
def skill_check(
self,
state: CombatantState,
skill: str,
*,
extra_mods: tuple[StatModifier, ...] = (),
) -> SkillCheckResult:
"""Roll one d20 skill check: d20 + ranks + ability mod + class-skill
+3 (trained class skills only) + armor check penalty + every typed
bonus/penalty the effect system carries for the skill (trait, feat,
condition, circumstance - all through ``_skill_static_bonus``).
PF1e (CRB "Skill Checks", KB rules/prpg-core-rulebook/skill-checks.md):
"you roll 1d20 and then add your ranks and the appropriate ability
score modifier"; "If the skill you're using is a class skill (and you
have invested ranks into that skill), you gain a +3 bonus"; "Armor
check penalty applies to all Strength- and Dexterity-based skill
checks". A skill check has NO natural 1/20 auto-fail/auto-success -
the SRD grants those overrides to attack rolls, saves and combat
maneuver checks only; success is purely "result equal to or greater
than the DC". A PF1e one-shot skill bonus (SRD spells/guidance.md,
skill track) discharges on this roll (stacked by bonus type).
"""
roll = self._rng.d20()
# PF1e one-shot skill bonus (SRD spells/guidance.md, skill track):
# the first skill check discharges it (stacked by bonus type).
shot = self._discharge_one_shot(state, "skill")
extra: tuple[StatModifier, ...] = ()
extra = list(extra_mods)
if shot is not None:
if not is_stat_target(skill):
msg = f"Unknown skill: {skill}"
raise ValueError(msg)
extra = (
StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type),
extra.append(
StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type)
)
bonus = self._skill_static_bonus(state, skill, extra_mods=extra)
total = roll + bonus
bonus = self._skill_static_bonus(state, skill, extra_mods=tuple(extra))
return SkillCheckResult(roll=roll, total=roll + bonus)
def resolve_skill(
self, state: CombatantState, skill: str, dc: int
) -> SaveResult:
"""Roll a skill check against ``dc``."""
check = self.skill_check(state, skill)
# Natural 1/20 are not auto-fail/success for skills
success = total >= dc
return SaveResult(success=success, roll=roll, total=total, dc=dc)
return SaveResult(
success=check.total >= dc, roll=check.roll, total=check.total, dc=dc
)
def _skill_static_bonus(
self,
@@ -4123,8 +4160,12 @@ class CombatEngine:
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
# Class skill bonus. CRB "Skill Checks" (KB rules/prpg-core-rulebook/
# skill-checks.md): "If the skill you're using is a class skill (and
# you have invested ranks into that skill), you gain a +3 bonus" -
# Table 4-2 names the row "Trained Class Skill": an untrained class
# skill gets NO synergy bonus.
class_bonus = 3 if skill_rank and skill_rank.class_skill and rank > 0 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: