Files
pf1e-simulator/tests/test_skill_checks.py

185 lines
6.1 KiB
Python

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