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
+3 -1
View File
@@ -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
+10 -2
View File
@@ -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)."
}
+171 -19
View File
@@ -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,
+31 -1
View File
@@ -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.
+46 -2
View File
@@ -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,
+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"
+2
View File
@@ -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,
)