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
+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,