fix(rules): guidance is a one-shot +1 competence bonus on a single attack roll - SRD spells/guidance.md grants '+1 competence bonus on a SINGLE attack roll, saving throw, or skill check' (duration 1 minute or until discharged); the engine had no spell effect that could write the one-shot attack track (aid_attack_bonus is hardwired to aid-another +2 and expires at the start of the beneficiary's next turn), and a persistent BuffEffect would misrepresent the discharge rule (it would boost every roll for a minute) and would become the first buff spell in the casters' known-spell order, degrading default_policy; a new OneShotEffect type is added (track/value/bonus_type/duration_rounds, parser 'one_shot', SpellEffect union, summary 'one-shot +X type on track'), stored as ActiveOneShot on CombatantState (one_shot_bonuses, decremented with the other duration-tracked effects, exposed in the snapshot), discharged by the FIRST roll of the encoded track via _discharge_one_shot (best value wins, each casting is its own charge) and folded into the resolve_modifiers pool so bonus-type stacking applies; the attack track rides the attack roll only, NOT the crit-confirm roll (the SRD grants it to a SINGLE attack roll, so unlike aid another the confirmation is untouched - _attack_roll_modifiers returns (pool_mod, attack_roll_mod) and resolve_attack uses the latter for the total while the confirm keeps the former); the save and skill tracks are wired in the mechanism (resolve_save folds a save-track shot into save_mods, resolve_skill discharges a skill-track shot into _skill_static_bonus via extra_mods) but guidance.json encodes the attack track only - the SRD 'attack OR save OR skill' choice has no declaration channel in the engine, so the attack-roll variant is encoded (documented); _apply_targeted_spell_effect dispatches OneShotEffect to _apply_one_shot_effect (a successful save negates it, 'one-shot negated'); guidance.json now carries the effect (1 minute = 10 rounds) and default_policy never casts it (cantrips have no spell slot and the one-shot type is not a buff/heal/offensive pick); _spell_effect_summary split into _damage_effect_summary/_non_damage_effect_summary to stay under the PLR0911 return cap; test_spell_registry_batch2._EFFECT_TYPES gains OneShotEffect; +14 tests (test_one_shot_guidance.py: cast-and-discharge on the next attack, not on the crit-confirm roll, save/skill tracks, stacking by bonus type, expiry at 10 rounds, save negation, snapshot exposure, no auto-cast by default_policy); golden untouched; 1705 tests green

This commit is contained in:
2026-09-20 09:53:53 +02:00
parent 154c3f69a0
commit 08f9af9e3e
7 changed files with 592 additions and 25 deletions
+329
View File
@@ -0,0 +1,329 @@
"""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"