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:
@@ -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"
|
||||
@@ -20,6 +20,7 @@ from pf1e_simulator.spells import (
|
||||
ConditionEffect,
|
||||
DamageEffect,
|
||||
HealEffect,
|
||||
OneShotEffect,
|
||||
SpellEffect,
|
||||
SpellSpec,
|
||||
SummonEffect,
|
||||
@@ -78,6 +79,7 @@ _EFFECT_TYPES = (
|
||||
ConditionEffect,
|
||||
HealEffect,
|
||||
BuffEffect,
|
||||
OneShotEffect,
|
||||
AreaEffect,
|
||||
SummonEffect,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user