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:
@@ -1277,6 +1277,20 @@ class SaveResult:
|
|||||||
dc: int
|
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)
|
@dataclass(frozen=True)
|
||||||
class Action:
|
class Action:
|
||||||
"""What a policy wants a combatant to do this turn.
|
"""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)
|
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(
|
def skill_check(
|
||||||
self, state: CombatantState, skill: str, dc: int
|
self,
|
||||||
) -> SaveResult:
|
state: CombatantState,
|
||||||
"""Roll a skill check against ``dc``."""
|
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()
|
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")
|
shot = self._discharge_one_shot(state, "skill")
|
||||||
extra: tuple[StatModifier, ...] = ()
|
extra = list(extra_mods)
|
||||||
if shot is not None:
|
if shot is not None:
|
||||||
if not is_stat_target(skill):
|
if not is_stat_target(skill):
|
||||||
msg = f"Unknown skill: {skill}"
|
msg = f"Unknown skill: {skill}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
extra = (
|
extra.append(
|
||||||
StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type),
|
StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type)
|
||||||
)
|
)
|
||||||
bonus = self._skill_static_bonus(state, skill, extra_mods=extra)
|
bonus = self._skill_static_bonus(state, skill, extra_mods=tuple(extra))
|
||||||
total = roll + bonus
|
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
|
# Natural 1/20 are not auto-fail/success for skills
|
||||||
success = total >= dc
|
return SaveResult(
|
||||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
success=check.total >= dc, roll=check.roll, total=check.total, dc=dc
|
||||||
|
)
|
||||||
|
|
||||||
def _skill_static_bonus(
|
def _skill_static_bonus(
|
||||||
self,
|
self,
|
||||||
@@ -4123,8 +4160,12 @@ class CombatEngine:
|
|||||||
|
|
||||||
skill_rank = state.combatant.skills.get(skill, None)
|
skill_rank = state.combatant.skills.get(skill, None)
|
||||||
rank = skill_rank.rank if skill_rank else 0
|
rank = skill_rank.rank if skill_rank else 0
|
||||||
# Class skill bonus
|
# Class skill bonus. CRB "Skill Checks" (KB rules/prpg-core-rulebook/
|
||||||
class_bonus = 3 if skill_rank and skill_rank.class_skill else 0
|
# 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 check penalty
|
||||||
armor_penalty = skill_rank.armor_penalty if skill_rank else 0
|
armor_penalty = skill_rank.armor_penalty if skill_rank else 0
|
||||||
if armor_penalty and skill_spec.armor_check_penalty:
|
if armor_penalty and skill_spec.armor_check_penalty:
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""PF1e skill-check subsystem (SRD "Skill Checks" conformance).
|
||||||
|
|
||||||
|
KB rules/prpg-core-rulebook/skill-checks.md:
|
||||||
|
- "you roll 1d20 and then add your ranks and the appropriate ability score
|
||||||
|
modifier"; Table 4-2 row "Trained Class Skill" adds +3 ONLY when ranks are
|
||||||
|
invested; "Armor check penalty applies to all Strength- and Dexterity-based
|
||||||
|
skill checks".
|
||||||
|
- Success is purely "result equal to or greater than the DC" - a skill check
|
||||||
|
has NO natural 1/20 auto-fail/auto-success (those overrides belong to
|
||||||
|
attack rolls, saving throws and combat maneuver checks).
|
||||||
|
- Opposed checks: "the attempt is successful if your check result exceeds
|
||||||
|
the result of the target".
|
||||||
|
|
||||||
|
CombatEngine.skill_check is the public d20 + full-static-bonus helper every
|
||||||
|
in-combat skill use (tumble, hide) rolls through; resolve_skill is the
|
||||||
|
DC-checking wrapper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.effects import ActiveOneShot
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
SkillRank,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant(
|
||||||
|
cid: str,
|
||||||
|
*,
|
||||||
|
dex: int = 10,
|
||||||
|
wis: int = 10,
|
||||||
|
skills: dict[str, SkillRank] | None = None,
|
||||||
|
) -> Combatant:
|
||||||
|
sword = AttackSpec(
|
||||||
|
id=f"{cid}-sword",
|
||||||
|
name="short sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=1,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=1,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10,
|
||||||
|
dex_score=dex,
|
||||||
|
con_score=10,
|
||||||
|
int_score=10,
|
||||||
|
wis_score=wis,
|
||||||
|
cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=20,
|
||||||
|
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||||
|
bab=1,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=0, ref=0, will=0),
|
||||||
|
attacks=[sword],
|
||||||
|
skills=skills or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(rolls: list[int], *combatants: Combatant) -> CombatEngine:
|
||||||
|
states = [
|
||||||
|
CombatantState(combatant=c, side=f"s{i}", pos=(i, 0), hp=c.hp_max)
|
||||||
|
for i, c in enumerate(combatants)
|
||||||
|
]
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
||||||
|
return CombatEngine(ScriptedRng(rolls), Grid.from_spec(spec), states)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(engine: CombatEngine, cid: str) -> CombatantState:
|
||||||
|
return next(s for s in engine._states if s.combatant.id == cid)
|
||||||
|
|
||||||
|
|
||||||
|
# ── the total: d20 + ranks + ability + class-skill +3 + armor penalty ────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_check_total_is_d20_plus_ranks_ability_class_and_armor() -> None:
|
||||||
|
hero = _combatant(
|
||||||
|
"hero",
|
||||||
|
dex=14,
|
||||||
|
skills={"acrobatics": SkillRank(rank=4, class_skill=True, armor_penalty=2)},
|
||||||
|
)
|
||||||
|
engine = _engine([11], hero)
|
||||||
|
check = engine.skill_check(_state(engine, "hero"), "acrobatics")
|
||||||
|
# 11 + 4 ranks + 2 Dex + 3 trained class skill - 2 armor check penalty.
|
||||||
|
assert check.roll == 11
|
||||||
|
assert check.total == 18
|
||||||
|
|
||||||
|
|
||||||
|
def test_untrained_class_skill_gets_no_synergy_bonus() -> None:
|
||||||
|
# Table 4-2 grants +3 on the "Trained Class Skill" row only: a class
|
||||||
|
# skill with zero invested ranks adds nothing beyond the ability mod.
|
||||||
|
hero = _combatant("hero", dex=14, skills={"acrobatics": SkillRank(rank=0, class_skill=True)})
|
||||||
|
engine = _engine([10], hero)
|
||||||
|
check = engine.skill_check(_state(engine, "hero"), "acrobatics")
|
||||||
|
assert check.total == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_check_consumes_exactly_one_d20() -> None:
|
||||||
|
hero = _combatant("hero", dex=16)
|
||||||
|
engine = _engine([7, 99], hero)
|
||||||
|
check = engine.skill_check(_state(engine, "hero"), "stealth")
|
||||||
|
assert check.roll == 7
|
||||||
|
# The next roll of the shared stream is untouched by the check.
|
||||||
|
assert engine._rng.d20() == 99
|
||||||
|
|
||||||
|
|
||||||
|
# ── no natural 1/20 auto-fail/auto-success ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_natural_20_does_not_auto_succeed_a_skill_check() -> None:
|
||||||
|
hero = _combatant("hero")
|
||||||
|
engine = _engine([20], hero)
|
||||||
|
result = engine.resolve_skill(_state(engine, "hero"), "perception", 25)
|
||||||
|
assert result.roll == 20
|
||||||
|
assert not result.success
|
||||||
|
|
||||||
|
|
||||||
|
def test_natural_1_can_still_succeed_a_skill_check_on_bonus() -> None:
|
||||||
|
hero = _combatant(
|
||||||
|
"hero",
|
||||||
|
wis=20,
|
||||||
|
skills={"perception": SkillRank(rank=5, class_skill=True)},
|
||||||
|
)
|
||||||
|
engine = _engine([1], hero)
|
||||||
|
result = engine.resolve_skill(_state(engine, "hero"), "perception", 13)
|
||||||
|
# 1 + 5 ranks + 5 Wis + 3 trained class skill = 14 >= 13: RAW success.
|
||||||
|
assert result.total == 14
|
||||||
|
assert result.success
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_skill_delegates_to_the_same_bonus_pipeline() -> None:
|
||||||
|
hero = _combatant("hero", dex=12, skills={"acrobatics": SkillRank(rank=2, class_skill=True)})
|
||||||
|
engine = _engine([5], hero)
|
||||||
|
s = _state(engine, "hero")
|
||||||
|
resolved = engine.resolve_skill(s, "acrobatics", 10)
|
||||||
|
assert resolved.roll == 5
|
||||||
|
assert resolved.total == 5 + 2 + 1 + 3
|
||||||
|
|
||||||
|
|
||||||
|
# ── one-shot skill charges discharge through skill_check ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_check_discharges_the_one_shot_skill_charge() -> None:
|
||||||
|
hero = _combatant("hero")
|
||||||
|
engine = _engine([10], hero)
|
||||||
|
s = _state(engine, "hero")
|
||||||
|
s.one_shot_bonuses.append(
|
||||||
|
ActiveOneShot(
|
||||||
|
track="skill",
|
||||||
|
value=1,
|
||||||
|
bonus_type="competence",
|
||||||
|
rounds_left=1,
|
||||||
|
source="spell: guidance",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
check = engine.skill_check(s, "perception")
|
||||||
|
assert check.total == 11
|
||||||
|
assert s.one_shot_bonuses == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_skill_raises() -> None:
|
||||||
|
hero = _combatant("hero")
|
||||||
|
engine = _engine([10], hero)
|
||||||
|
with pytest.raises(ValueError, match="Unknown skill"):
|
||||||
|
engine.skill_check(_state(engine, "hero"), "baking")
|
||||||
Reference in New Issue
Block a user