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:
@@ -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
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
+166
-14
@@ -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
|
||||
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"
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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