diff --git a/README.md b/README.md index 83c0738..59b5dbe 100644 --- a/README.md +++ b/README.md @@ -1960,7 +1960,9 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : (rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur cible consentante), temps d'incantation `cast_time`) et types d'effets (`DamageEffect`, `ConditionEffect`, - `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un + `HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a, + `OneShotEffect` — bonus compétence à usage unique sur un jet d'attaque, de + sauvegarde ou de compétence, SRD guidance). Un `DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par `effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e diff --git a/data/spells/guidance.json b/data/spells/guidance.json index 70d5618..7ed010a 100644 --- a/data/spells/guidance.json +++ b/data/spells/guidance.json @@ -7,6 +7,14 @@ "harmless": true, "base_dc": 10, "cast_time": "standard", - "effects": [], - "description": "SRD spells/guidance.md: a cantrip - the subject gets a +1 competence bonus on a SINGLE attack roll, saving throw, or skill check, declared before the roll (duration 1 minute or until discharged). Deferred: the engine's one-shot attack track (aid_attack_bonus) is hardwired to the aid-another +2 and expires at the start of the beneficiary's next turn, and no spell effect can write it; a persistent +1 BuffEffect would misrepresent the discharge rule (it would boost every roll for a minute) and would make guidance the first buff spell in the casters' known-spell order, degrading default_policy. No mechanical effect is applied." + "effects": [ + { + "type": "one_shot", + "track": "attack", + "value": 1, + "bonus_type": "competence", + "duration_rounds": 10 + } + ], + "description": "SRD spells/guidance.md: a cantrip - the subject gets a +1 competence bonus on a SINGLE attack roll, saving throw, or skill check, declared before the roll (duration 1 minute or until discharged). Modeled as a one-shot +1 competence modifier on the ATTACK track: the subject's next weapon attack roll (resolve_attack) discharges it and the bonus rides that roll only, not the crit-confirm roll; spell touch-delivery attacks roll on a separate path and do not consume it. The SRD 'attack OR save OR skill' choice has no declaration channel in the engine, so the attack-roll variant is encoded (the mechanism also carries save and skill tracks). 1 minute = 10 rounds, expiring at the start of the bearer's turns like any duration-tracked effect. The bonus folds into the resolve_modifiers pool, so it stacks with other types but not with a higher competence bonus on the same roll. default_policy never casts it (cantrips have no spell slot, and the one-shot type is not a buff/heal/offensive pick)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2e5a985..a23ffa5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -125,7 +125,16 @@ from typing import TYPE_CHECKING, Literal from pf1e_simulator.areas import cone_cells, line_cells from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition from pf1e_simulator.dice import DiceExpr, dice_to_str -from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier, resolve_modifiers +from pf1e_simulator.effects import ( + ActiveBuff, + ActiveCondition, + ActiveOneShot, + OneShotTrack, + StatModifier, + StatTarget, + is_stat_target, + resolve_modifiers, +) from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.spells import ( @@ -136,6 +145,7 @@ from pf1e_simulator.spells import ( ConditionEffect, DamageEffect, HealEffect, + OneShotEffect, PenaltyEffect, RecurringDamageSpec, SaveType, @@ -451,23 +461,37 @@ def _burst_center( return None -def _spell_effect_summary(effect: SpellEffect) -> str: - """Short serializable summary of one spell effect (never the description).""" - if isinstance(effect, (DamageEffect, PenaltyEffect)): - tags = effect.types if isinstance(effect, DamageEffect) else effect.targets - parts = [dice_to_str(effect.formula), *tags] - if effect.half_on_save: - parts.append("half on save") - return " ".join(parts) +def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]) -> str: + parts = [dice_to_str(effect.formula), *tags] + if effect.half_on_save: + parts.append("half on save") + return " ".join(parts) + + +def _non_damage_effect_summary( + effect: ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect +) -> str: if isinstance(effect, ConditionEffect): return f"{effect.condition_name} {effect.duration_rounds}r" if isinstance(effect, HealEffect): return f"heal {dice_to_str(effect.formula)}" - if isinstance(effect, BuffEffect): - return f"buff {effect.duration_rounds}r" if isinstance(effect, AreaEffect): return f"area {effect.radius_ft}ft" - return f"summon {effect.creature}" + if isinstance(effect, SummonEffect): + return f"summon {effect.creature}" + if isinstance(effect, OneShotEffect): + return f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}" + return f"buff {effect.duration_rounds}r" + + +def _spell_effect_summary(effect: SpellEffect) -> str: + """Short serializable summary of one spell effect (never the description).""" + if isinstance(effect, (DamageEffect, PenaltyEffect)): + tags: list[str] = ( + list(effect.types) if isinstance(effect, DamageEffect) else list(effect.targets) + ) + return _damage_effect_summary(effect, tags) + return _non_damage_effect_summary(effect) def _spell_summary(spell: SpellSpec) -> str: @@ -761,6 +785,9 @@ class CombatantState: # New duration-tracked fields active_buffs: list[ActiveBuff] = field(default_factory=list) active_conditions: list[ActiveCondition] = field(default_factory=list) + # One-shot typed bonuses (SRD spells/guidance.md) discharged by the first + # roll of their track and expiring with their duration. + one_shot_bonuses: list[ActiveOneShot] = field(default_factory=list) resources_left: dict[str, int] = field(default_factory=dict) spell_slots_left: dict[int, int] = field(default_factory=dict) moved_this_turn: bool = False @@ -1645,6 +1672,16 @@ class CombatEngine: } for buff in state.active_buffs ), + "one_shot_bonuses": tuple( + { + "track": shot.track, + "value": shot.value, + "bonus_type": shot.bonus_type, + "rounds_left": shot.rounds_left, + "source": shot.source, + } + for shot in state.one_shot_bonuses + ), "speed_land_ft": state.combatant.speed_land_ft, "speed_fly_ft": state.combatant.speed_fly_ft, "speed_effective_ft": _speed_ft(state), @@ -2207,6 +2244,17 @@ class CombatEngine: for c in state.active_conditions if c.rounds_left > 1 ] + state.one_shot_bonuses[:] = [ + ActiveOneShot( + track=o.track, + value=o.value, + bonus_type=o.bonus_type, + rounds_left=o.rounds_left - 1, + source=o.source, + ) + for o in state.one_shot_bonuses + if o.rounds_left > 1 + ] bleeding_expired = ( any(ac.condition.name == "bleeding" for ac in state.active_conditions) and not any(ac.condition.name == "bleeding" for ac in kept_conditions) @@ -2807,6 +2855,54 @@ class CombatEngine: """True if the combatant has a feature with the given name.""" return any(f.name == feat_name for f in state.combatant.features) + def _discharge_one_shot( + self, state: CombatantState, track: OneShotTrack + ) -> ActiveOneShot | None: + """Remove and return the best pending one-shot for ``track``. + + PF1e (SRD spells/guidance.md): the bonus applies to a SINGLE roll and + "must [be] chosen ... before making the roll" - the engine has no + declaration channel, so the first roll of the encoded track spends it. + The caller folds the returned bonus into its resolve_modifiers pool + so the bonus-type stacking rules apply; other pending charges of the + same track survive (each casting is its own charge). + """ + best: ActiveOneShot | None = None + for entry in state.one_shot_bonuses: + if entry.track == track and (best is None or entry.value > best.value): + best = entry + if best is None: + return None + state.one_shot_bonuses.remove(best) + return best + + def _one_shot_modifier( + self, state: CombatantState, track: OneShotTrack, target: StatTarget + ) -> StatModifier | None: + """Discharge this track's one-shot as a modifier on ``target``, if any.""" + shot = self._discharge_one_shot(state, track) + if shot is None: + return None + return StatModifier(target=target, value=shot.value, bonus_type=shot.bonus_type) + + def _attack_roll_modifiers( + self, attacker: CombatantState, atk_mods: list[StatModifier] + ) -> tuple[int, int]: + """Return (pool_mod, attack_roll_mod) for an attack roll. + + ``pool_mod`` is the resolve_modifiers total of the standing pool (it + also rides the crit-confirm roll); ``attack_roll_mod`` additionally + folds in a discharged one-shot (SRD spells/guidance.md). The one-shot + rides the attack roll only, not the confirmation (the SRD grants it to + a SINGLE attack roll, so unlike aid another the confirm is untouched); + it goes through resolve_modifiers so its bonus type stacks normally. + """ + pool_mod = resolve_modifiers(atk_mods) + one_shot = self._one_shot_modifier(attacker, "attack", "attack") + if one_shot is None: + return pool_mod, pool_mod + return pool_mod, resolve_modifiers([*atk_mods, one_shot]) + def _is_paralyzing_touch(self, attacker: CombatantState, weapon: AttackSpec) -> bool: """True if a hit with this weapon should apply the Lich's paralyzing touch. @@ -2957,7 +3053,7 @@ class CombatEngine: # unconscious (stunned is NOT helpless). Ranged attacks gain no bonus. if self._is_melee_attack(weapon) and self._is_helpless(target): atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS)) - attack_mod = resolve_modifiers(atk_mods) + attack_mod, attack_roll_mod = self._attack_roll_modifiers(attacker, atk_mods) # PF1e (CRB Power Attack): the -X penalty rides ALL melee attack rolls # — a melee touch (no range increment) is a melee attack. The damage # bonus it buys stays weapon-only (see _resolve_hit_damage). @@ -2968,7 +3064,7 @@ class CombatEngine: _FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0 ) total = ( - roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt + roll + base_bonus + penalty + flank + high_ground + attack_roll_mod - pa_amt + fim_penalty + fd_penalty + attacker.aid_attack_bonus ) # Check if target denies DEX to AC (flat-footed); a running target @@ -3498,9 +3594,15 @@ class CombatEngine: """ roll = self._rng.d20() base = getattr(state.combatant.saves, save_type) - bonus = resolve_modifiers( - self._stat_modifiers(state, save_type, save_descriptors=save_descriptors) + save_mods = self._stat_modifiers( + state, save_type, save_descriptors=save_descriptors ) + # PF1e one-shot save bonus (SRD spells/guidance.md, save track): the + # first save of the bearer discharges it (stacked by bonus type). + one_shot = self._one_shot_modifier(state, "save", save_type) + if one_shot is not None: + save_mods.append(one_shot) + bonus = resolve_modifiers(save_mods) total = roll + base + bonus + flat_bonus success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc) return SaveResult(success=success, roll=roll, total=total, dc=dc) @@ -3511,13 +3613,30 @@ class CombatEngine: """Roll a skill check against ``dc``.""" roll = self._rng.d20() - bonus = self._skill_static_bonus(state, skill) + # PF1e one-shot skill bonus (SRD spells/guidance.md, skill track): + # the first skill check discharges it (stacked by bonus type). + shot = self._discharge_one_shot(state, "skill") + extra: tuple[StatModifier, ...] = () + if shot is not None: + if not is_stat_target(skill): + msg = f"Unknown skill: {skill}" + raise ValueError(msg) + extra = ( + StatModifier(target=skill, value=shot.value, bonus_type=shot.bonus_type), + ) + bonus = self._skill_static_bonus(state, skill, extra_mods=extra) total = roll + bonus # Natural 1/20 are not auto-fail/success for skills success = total >= dc return SaveResult(success=success, roll=roll, total=total, dc=dc) - def _skill_static_bonus(self, state: CombatantState, skill: str) -> int: + def _skill_static_bonus( + self, + state: CombatantState, + skill: str, + *, + extra_mods: tuple[StatModifier, ...] = (), + ) -> int: """Return a skill's static bonus (no d20). Shared by resolve_skill.""" skill_spec = PREDEFINED_SKILLS.get(skill) if skill_spec is None: @@ -3545,6 +3664,7 @@ class CombatEngine: mods = self._stat_modifiers(state, skill) if skill in _DEX_BASED_SKILLS: mods.extend(self._stat_modifiers(state, "dex_skills")) + mods.extend(extra_mods) mod_bonus = resolve_modifiers(mods) return rank + class_bonus + ability_mod + armor_penalty + mod_bonus @@ -7007,7 +7127,10 @@ class CombatEngine: def _apply_targeted_spell_effect( self, - effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect, + effect: ( + DamageEffect | ConditionEffect | HealEffect | BuffEffect + | PenaltyEffect | OneShotEffect + ), target: CombatantState, caster_live: _LiveStats, target_live: _LiveStats, @@ -7044,11 +7167,40 @@ class CombatEngine: effect, target, caster=caster, saved=saved, empower=empower, maximize=maximize, spell_name=spell_name, ) + if isinstance(effect, OneShotEffect): + return self._apply_one_shot_effect( + effect, target, saved=saved, spell_name=spell_name, + ) return self._apply_buff_effect( effect, target, spell_name=spell_name, caster_level=caster.combatant.level, ) + def _apply_one_shot_effect( + self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str + ) -> str: + """Store a one-shot typed bonus on the target (SRD guidance). + + A successful save negates it (SRD spells/guidance.md: "Will negates + (harmless)"). Same-type charges do not merge: each casting is its own + charge, discharged one-per-roll by ``_discharge_one_shot``. + """ + if saved: + return "one-shot negated" + target.one_shot_bonuses.append( + ActiveOneShot( + track=effect.track, + value=effect.value, + bonus_type=effect.bonus_type, + rounds_left=effect.duration_rounds, + source=f"spell: {spell_name}", + ) + ) + return ( + f"one-shot +{effect.value} {effect.bonus_type} on {effect.track} " + f"({effect.duration_rounds}r)" + ) + def _apply_damage_effect( self, effect: DamageEffect, diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 59fe378..d137d3b 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -20,7 +20,7 @@ engine collects and sums via resolve_modifiers. from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, TypeGuard, get_args if TYPE_CHECKING: from collections.abc import Iterable @@ -73,6 +73,17 @@ StatTarget = Literal[ _STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"}) +def is_stat_target(value: str) -> TypeGuard[StatTarget]: + """True when ``value`` is one of the StatTarget literal names.""" + return value in get_args(StatTarget) + +# The roll kinds a one-shot bonus (SRD spells/guidance.md: "a +1 competence +# bonus on a SINGLE attack roll, saving throw, or skill check") can be spent +# on. The engine has no declaration channel, so a one-shot is encoded for one +# track at a time and discharged by the first roll of that kind. +OneShotTrack = Literal["attack", "save", "skill"] + + @dataclass(frozen=True) class StatModifier: """+X to a stat, optionally with a bonus type for stacking and a condition. @@ -126,6 +137,25 @@ class ActiveCondition: source: str +@dataclass(frozen=True) +class ActiveOneShot: + """A typed bonus discharged by the FIRST roll of its track (SRD guidance). + + - ``track``: which roll kind spends it ("attack", "save" or "skill"). + - ``value`` / ``bonus_type``: the bonus and its stacking type. + - ``rounds_left``: duration in rounds (decremented at the start of the + bearer's turn); the SRD "or until discharged" clause is the consumption + path, not this counter. + - ``source``: what created it (e.g. "spell: guidance"). + """ + + track: OneShotTrack + value: int + bonus_type: BonusType + rounds_left: int + source: str + + def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int: """Sum stat modifiers applying PF1e stacking rules. diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 7238f12..062f6ec 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -5,6 +5,7 @@ Spells are SpellSpec dataclasses with typed effects: - ConditionEffect: applies a condition (looked up in CONDITIONS_BY_NAME) - HealEffect: restores HP (capped at hp_max) - BuffEffect: grants StatModifiers as transient effects +- OneShotEffect: a typed bonus discharged by the first roll of one track Range categories: personal, touch, close, medium, long. Save types: fort, ref, will, none. @@ -20,7 +21,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Literal, TypeGuard, get_args from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice -from pf1e_simulator.effects import BonusType, StatModifier, StatTarget +from pf1e_simulator.effects import BonusType, OneShotTrack, StatModifier, StatTarget if TYPE_CHECKING: from collections.abc import Callable @@ -268,6 +269,23 @@ class BuffEffect: scaling_flat: DamageFlatScaling | None = None +@dataclass(frozen=True) +class OneShotEffect: + """A typed bonus discharged by the first roll of one track (SRD guidance). + + PF1e (SRD spells/guidance.md): "+1 competence bonus on a SINGLE attack + roll, saving throw, or skill check [...] must choose to use the bonus + before making the roll", duration 1 minute or until discharged. The + engine has no declaration channel, so the track is fixed in the spell + data and the bonus rides the bearer's first roll of that kind. + """ + + track: OneShotTrack + value: int + bonus_type: BonusType + duration_rounds: int + + @dataclass(frozen=True) class PenaltyEffect: """PF1e rolled ability penalty (SRD Ray of Enfeeblement). @@ -349,7 +367,7 @@ class SummonEffect: SpellEffect = ( DamageEffect | ConditionEffect | HealEffect | BuffEffect - | PenaltyEffect | AreaEffect | SummonEffect + | PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect ) @@ -729,6 +747,31 @@ def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect: ) +def _is_one_shot_track(value: str) -> TypeGuard[OneShotTrack]: + return value in get_args(OneShotTrack) + + +def _parse_one_shot_effect(effect: dict[str, Json]) -> OneShotEffect: + track_raw = _as_str(effect["track"]) + if not _is_one_shot_track(track_raw): + msg = f"unknown one-shot track: {track_raw!r}" + raise ValueError(msg) + bonus = _as_str(effect["bonus_type"]) + if not _is_bonus_type(bonus): + msg = f"unknown bonus type: {bonus!r}" + raise ValueError(msg) + duration = _as_int(effect["duration_rounds"]) + if duration < 1: + msg = f"one_shot duration_rounds must be >= 1, got {duration}" + raise ValueError(msg) + return OneShotEffect( + track=track_raw, + value=_as_int(effect["value"]), + bonus_type=bonus, + duration_rounds=duration, + ) + + def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect: flat_raw = effect.get("scaling_flat") scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) @@ -781,6 +824,7 @@ _EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = { "condition": _parse_condition_effect, "heal": _parse_heal_effect, "buff": _parse_buff_effect, + "one_shot": _parse_one_shot_effect, "penalty": _parse_penalty_effect, "area": _parse_area_effect, "summon": _parse_summon_effect, diff --git a/tests/test_one_shot_guidance.py b/tests/test_one_shot_guidance.py new file mode 100644 index 0000000..8bfb50e --- /dev/null +++ b/tests/test_one_shot_guidance.py @@ -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" diff --git a/tests/test_spell_registry_batch2.py b/tests/test_spell_registry_batch2.py index 4cfbacc..712917a 100644 --- a/tests/test_spell_registry_batch2.py +++ b/tests/test_spell_registry_batch2.py @@ -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, )