diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2cb0cf2..0b6cc40 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_skill_checks.py b/tests/test_skill_checks.py new file mode 100644 index 0000000..afcafb1 --- /dev/null +++ b/tests/test_skill_checks.py @@ -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")