"""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")