"""Tests for the Guidance one-shot bonus machinery (SRD spells/guidance.md). Given: the one_shot effect type (attack/save/skill tracks, typed, duration tracked) and the Guidance cantrip encoding a +1 competence attack charge When: casting on a willing ally / resolving attack, save and skill rolls / ticking durations Then: the charge is stored without a save roll on a willing target, is discharged by the first roll of its track only, stacks by bonus type, does not ride the crit-confirm roll, expires with its duration, and default_policy never proposes it. """ from __future__ import annotations from pathlib import Path from pf1e_simulator.combat import CombatantState, CombatEngine, _choose_spell_action from pf1e_simulator.dice import parse_dice from pf1e_simulator.effects import ( ActiveBuff, ActiveOneShot, BonusType, OneShotTrack, StatModifier, ) from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import OneShotEffect, load_spell_registry _SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells" def _make_combatant( cid: str, *, casting: bool = False, spells: list[str] | None = None ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="club", kind="melee", attack_bonus=3, damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])], ) return Combatant( id=cid, name=cid, level=4, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=18, wis_score=10, cha_score=10, ), hp_max=30, ac=ACProfile(total=12, touch=12, flat_footed=12), bab=2, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=2, ref=2, will=2), attacks=[attack], casting_ability="int" if casting else "wis", spell_slots={1: 3} if casting else {}, spells=spells or [], ) def _make_engine( queue: list[int], *, sides: dict[str, str] | None = None, positions: dict[str, tuple[int, int]] | None = None, combatants: list[Combatant] | None = None, ) -> CombatEngine: roster = combatants or [_make_combatant("hero")] side_map = sides or {c.id: "players" for c in roster} pos_map = positions or {c.id: (i, 0) for i, c in enumerate(roster)} states = [ CombatantState( combatant=c, side=side_map[c.id], pos=pos_map[c.id], hp=c.hp_max ) for c in roster ] legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) grid = Grid.from_spec(spec) return CombatEngine( ScriptedRng(queue), grid, states, spell_registry=load_spell_registry(_SPELLS_DIR) ) def _state(engine: CombatEngine, cid: str) -> CombatantState: return next(s for s in engine.states if s.combatant.id == cid) def _add_charge( state: CombatantState, track: OneShotTrack = "attack", value: int = 1, bonus_type: BonusType = "competence", rounds: int = 10, ) -> None: state.one_shot_bonuses.append( ActiveOneShot( track=track, value=value, bonus_type=bonus_type, rounds_left=rounds, source="spell: guidance", ) ) class TestGuidanceLoading: def test_guidance_encodes_a_one_shot_competence_attack_charge(self) -> None: spell = load_spell_registry(_SPELLS_DIR)["Guidance"] assert spell.effects == ( OneShotEffect( track="attack", value=1, bonus_type="competence", duration_rounds=10 ), ) assert spell.harmless is True assert spell.level == 0 class TestGuidanceCast: def test_willing_ally_gets_the_charge_without_a_save_roll(self) -> None: # An EMPTY queue proves no die is consumed: a harmless spell on a # same-side target forgoes the save (SRD "Voluntarily Giving up a # Saving Throw"), SR is 0, and a non-hostile touch needs no attack. caster = _make_combatant("caster", casting=True) ally = _make_combatant("ally") engine = _make_engine( [], combatants=[caster, ally], positions={"caster": (0, 0), "ally": (1, 0)}, ) c = _state(engine, "caster") a = _state(engine, "ally") engine._cast_spell(c, a, "Guidance") assert len(a.one_shot_bonuses) == 1 shot = a.one_shot_bonuses[0] assert (shot.track, shot.value, shot.bonus_type, shot.rounds_left) == ( "attack", 1, "competence", 10, ) assert shot.source == "spell: Guidance" assert any( "one-shot +1 competence on attack (10r)" in line for line in engine._transcript ) def test_hostile_target_that_saves_negates_the_charge(self) -> None: caster = _make_combatant("caster", casting=True) foe = _make_combatant("foe") # rolls: casting AoO from the adjacent foe d20=1 (natural 1, auto-miss, # one die); melee touch delivery d20=10 (10+2 BAB+0 str vs touch AC 12 # hits); Will save d20=20 (auto-success negates the charge). engine = _make_engine( [1, 10, 20], sides={"caster": "players", "foe": "monsters"}, combatants=[caster, foe], positions={"caster": (0, 0), "foe": (1, 0)}, ) c = _state(engine, "caster") f = _state(engine, "foe") engine._cast_spell(c, f, "Guidance") assert f.one_shot_bonuses == [] assert any("one-shot negated" in line for line in engine._transcript) class TestOneShotDischarge: def test_attack_roll_discharges_the_charge_and_adds_one(self) -> None: hero = _make_combatant("hero") foe = _make_combatant("foe") # roll 10: with the charge total = 10+3+1 = 14 (hits AC 12), damage # 1d6=2. engine = _make_engine([10, 2], combatants=[hero, foe]) h = _state(engine, "hero") f = _state(engine, "foe") _add_charge(h) result = engine.resolve_attack(h, f, h.combatant.attacks[0]) assert result.total == 14 assert result.hit is True assert h.one_shot_bonuses == [] def test_a_second_attack_without_charges_loses_the_bonus(self) -> None: hero = _make_combatant("hero") foe = _make_combatant("foe") engine = _make_engine([10, 2], combatants=[hero, foe]) h = _state(engine, "hero") f = _state(engine, "foe") result = engine.resolve_attack(h, f, h.combatant.attacks[0]) assert result.total == 13 def test_charge_does_not_ride_the_crit_confirm_roll(self) -> None: hero = _make_combatant("hero") foe = _make_combatant("foe") # natural 20 attack (auto-hit + threat), confirm d20=1 (auto-miss), # damage 1d6=3. The charge is discharged by the ATTACK roll (total # 20+3+1); the confirmation runs without it and auto-misses - the # SRD grants the bonus to a SINGLE attack roll. engine = _make_engine([20, 1, 3], combatants=[hero, foe]) h = _state(engine, "hero") f = _state(engine, "foe") _add_charge(h) result = engine.resolve_attack(h, f, h.combatant.attacks[0]) assert result.total == 24 assert result.crit is False assert h.one_shot_bonuses == [] def test_save_track_charge_is_spent_by_a_saving_throw(self) -> None: hero = _make_combatant("hero") engine = _make_engine([10], combatants=[hero]) h = _state(engine, "hero") _add_charge(h, track="save") result = engine.resolve_save(h, "fort", 25) assert result.total == 10 + 2 + 1 assert h.one_shot_bonuses == [] def test_skill_track_charge_is_spent_by_a_skill_check(self) -> None: hero = _make_combatant("hero") engine = _make_engine([10], combatants=[hero]) h = _state(engine, "hero") _add_charge(h, track="skill") result = engine.resolve_skill(h, "perception", 30) assert result.total == 10 + 1 assert h.one_shot_bonuses == [] def test_attack_charge_survives_saves_and_skills(self) -> None: hero = _make_combatant("hero") engine = _make_engine([10, 10], combatants=[hero]) h = _state(engine, "hero") _add_charge(h, track="attack") save = engine.resolve_save(h, "fort", 25) skill = engine.resolve_skill(h, "perception", 30) assert save.total == 10 + 2 assert skill.total == 10 assert len(h.one_shot_bonuses) == 1 def test_two_charges_discharge_one_per_attack_roll(self) -> None: hero = _make_combatant("hero") foe = _make_combatant("foe") engine = _make_engine([10, 2, 10, 2], combatants=[hero, foe]) h = _state(engine, "hero") f = _state(engine, "foe") _add_charge(h) _add_charge(h) first = engine.resolve_attack(h, f, h.combatant.attacks[0]) second = engine.resolve_attack(h, f, h.combatant.attacks[0]) assert first.total == 14 assert second.total == 14 assert h.one_shot_bonuses == [] def test_competence_charge_does_not_stack_with_a_competence_buff(self) -> None: hero = _make_combatant("hero") foe = _make_combatant("foe") engine = _make_engine([10, 2], combatants=[hero, foe]) h = _state(engine, "hero") f = _state(engine, "foe") h.active_buffs.append( ActiveBuff( modifiers=( StatModifier(target="attack", value=1, bonus_type="competence"), ), rounds_left=5, source="class ability: inspire courage", ) ) _add_charge(h) result = engine.resolve_attack(h, f, h.combatant.attacks[0]) # Same-type bonuses do not stack: the competence +1 buff and the # discharged competence +1 charge resolve to a single +1. assert result.total == 10 + 3 + 1 assert h.one_shot_bonuses == [] class TestOneShotDuration: def test_charge_expires_like_a_buff_duration(self) -> None: hero = _make_combatant("hero") engine = _make_engine([], combatants=[hero]) h = _state(engine, "hero") _add_charge(h, rounds=1) _add_charge(h, rounds=2) engine._decrement_durations(h) assert len(h.one_shot_bonuses) == 1 assert h.one_shot_bonuses[0].rounds_left == 1 engine._decrement_durations(h) assert h.one_shot_bonuses == [] class TestOneShotObservation: def test_snapshot_exposes_pending_charges(self) -> None: hero = _make_combatant("hero") engine = _make_engine([], combatants=[hero]) h = _state(engine, "hero") _add_charge(h) obs = engine._build_observation(h).to_dict() entry = obs["combatants"][0]["one_shot_bonuses"][0] assert entry["track"] == "attack" assert entry["value"] == 1 assert entry["bonus_type"] == "competence" assert entry["rounds_left"] == 10 class TestPolicyNeverPicksCantripOneShot: def test_default_policy_does_not_cast_guidance(self) -> None: caster = _make_combatant( "caster", casting=True, spells=["Guidance", "Divine Favor"] ) foe = _make_combatant("foe") engine = _make_engine( [], sides={"caster": "players", "foe": "monsters"}, combatants=[caster, foe], positions={"caster": (0, 0), "foe": (2, 0)}, ) c = _state(engine, "caster") f = _state(engine, "foe") action = _choose_spell_action(engine, c, f) # Guidance is not a buff/heal/offensive pick; Divine Favor (a real # leveled buff) is chosen instead - the one-shot never preempts it. assert action is not None assert action[0].spell_name == "Divine Favor"