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
|
(rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur
|
||||||
cible consentante), temps d'incantation `cast_time`) et types d'effets
|
cible consentante), temps d'incantation `cast_time`) et types d'effets
|
||||||
(`DamageEffect`, `ConditionEffect`,
|
(`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
|
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
|
||||||
optionnels (`scaling` en dés, `scaling_flat` en bonus plat) résolues par
|
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
|
`effective_formula(niveau)` au moment de l'incantation (S1b). Portées PF1e
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
"harmless": true,
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"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."
|
{
|
||||||
|
"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.areas import cone_cells, line_cells
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
from pf1e_simulator.conditions import CONDITIONS_BY_NAME, Condition
|
||||||
from pf1e_simulator.dice import DiceExpr, dice_to_str
|
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.loaders.combatant import CombatantLoadError, load_combatant
|
||||||
from pf1e_simulator.models import PREDEFINED_SKILLS
|
from pf1e_simulator.models import PREDEFINED_SKILLS
|
||||||
from pf1e_simulator.spells import (
|
from pf1e_simulator.spells import (
|
||||||
@@ -136,6 +145,7 @@ from pf1e_simulator.spells import (
|
|||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
|
OneShotEffect,
|
||||||
PenaltyEffect,
|
PenaltyEffect,
|
||||||
RecurringDamageSpec,
|
RecurringDamageSpec,
|
||||||
SaveType,
|
SaveType,
|
||||||
@@ -451,23 +461,37 @@ def _burst_center(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _spell_effect_summary(effect: SpellEffect) -> str:
|
def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]) -> 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]
|
parts = [dice_to_str(effect.formula), *tags]
|
||||||
if effect.half_on_save:
|
if effect.half_on_save:
|
||||||
parts.append("half on save")
|
parts.append("half on save")
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _non_damage_effect_summary(
|
||||||
|
effect: ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect
|
||||||
|
) -> str:
|
||||||
if isinstance(effect, ConditionEffect):
|
if isinstance(effect, ConditionEffect):
|
||||||
return f"{effect.condition_name} {effect.duration_rounds}r"
|
return f"{effect.condition_name} {effect.duration_rounds}r"
|
||||||
if isinstance(effect, HealEffect):
|
if isinstance(effect, HealEffect):
|
||||||
return f"heal {dice_to_str(effect.formula)}"
|
return f"heal {dice_to_str(effect.formula)}"
|
||||||
if isinstance(effect, BuffEffect):
|
|
||||||
return f"buff {effect.duration_rounds}r"
|
|
||||||
if isinstance(effect, AreaEffect):
|
if isinstance(effect, AreaEffect):
|
||||||
return f"area {effect.radius_ft}ft"
|
return f"area {effect.radius_ft}ft"
|
||||||
|
if isinstance(effect, SummonEffect):
|
||||||
return f"summon {effect.creature}"
|
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:
|
def _spell_summary(spell: SpellSpec) -> str:
|
||||||
@@ -761,6 +785,9 @@ class CombatantState:
|
|||||||
# New duration-tracked fields
|
# New duration-tracked fields
|
||||||
active_buffs: list[ActiveBuff] = field(default_factory=list)
|
active_buffs: list[ActiveBuff] = field(default_factory=list)
|
||||||
active_conditions: list[ActiveCondition] = 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)
|
resources_left: dict[str, int] = field(default_factory=dict)
|
||||||
spell_slots_left: dict[int, int] = field(default_factory=dict)
|
spell_slots_left: dict[int, int] = field(default_factory=dict)
|
||||||
moved_this_turn: bool = False
|
moved_this_turn: bool = False
|
||||||
@@ -1645,6 +1672,16 @@ class CombatEngine:
|
|||||||
}
|
}
|
||||||
for buff in state.active_buffs
|
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_land_ft": state.combatant.speed_land_ft,
|
||||||
"speed_fly_ft": state.combatant.speed_fly_ft,
|
"speed_fly_ft": state.combatant.speed_fly_ft,
|
||||||
"speed_effective_ft": _speed_ft(state),
|
"speed_effective_ft": _speed_ft(state),
|
||||||
@@ -2207,6 +2244,17 @@ class CombatEngine:
|
|||||||
for c in state.active_conditions
|
for c in state.active_conditions
|
||||||
if c.rounds_left > 1
|
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 = (
|
bleeding_expired = (
|
||||||
any(ac.condition.name == "bleeding" for ac in state.active_conditions)
|
any(ac.condition.name == "bleeding" for ac in state.active_conditions)
|
||||||
and not any(ac.condition.name == "bleeding" for ac in kept_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."""
|
"""True if the combatant has a feature with the given name."""
|
||||||
return any(f.name == feat_name for f in state.combatant.features)
|
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:
|
def _is_paralyzing_touch(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
|
||||||
"""True if a hit with this weapon should apply the Lich's paralyzing touch.
|
"""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.
|
# unconscious (stunned is NOT helpless). Ranged attacks gain no bonus.
|
||||||
if self._is_melee_attack(weapon) and self._is_helpless(target):
|
if self._is_melee_attack(weapon) and self._is_helpless(target):
|
||||||
atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS))
|
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
|
# PF1e (CRB Power Attack): the -X penalty rides ALL melee attack rolls
|
||||||
# — a melee touch (no range increment) is a melee attack. The damage
|
# — a melee touch (no range increment) is a melee attack. The damage
|
||||||
# bonus it buys stays weapon-only (see _resolve_hit_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
|
_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0
|
||||||
)
|
)
|
||||||
total = (
|
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
|
+ fim_penalty + fd_penalty + attacker.aid_attack_bonus
|
||||||
)
|
)
|
||||||
# Check if target denies DEX to AC (flat-footed); a running target
|
# Check if target denies DEX to AC (flat-footed); a running target
|
||||||
@@ -3498,9 +3594,15 @@ class CombatEngine:
|
|||||||
"""
|
"""
|
||||||
roll = self._rng.d20()
|
roll = self._rng.d20()
|
||||||
base = getattr(state.combatant.saves, save_type)
|
base = getattr(state.combatant.saves, save_type)
|
||||||
bonus = resolve_modifiers(
|
save_mods = self._stat_modifiers(
|
||||||
self._stat_modifiers(state, save_type, save_descriptors=save_descriptors)
|
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
|
total = roll + base + bonus + flat_bonus
|
||||||
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
success = roll != _NATURAL_ONE and (roll == _NATURAL_TWENTY or total >= dc)
|
||||||
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
return SaveResult(success=success, roll=roll, total=total, dc=dc)
|
||||||
@@ -3511,13 +3613,30 @@ class CombatEngine:
|
|||||||
"""Roll a skill check against ``dc``."""
|
"""Roll a skill check against ``dc``."""
|
||||||
|
|
||||||
roll = self._rng.d20()
|
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
|
total = roll + bonus
|
||||||
# Natural 1/20 are not auto-fail/success for skills
|
# Natural 1/20 are not auto-fail/success for skills
|
||||||
success = total >= dc
|
success = total >= dc
|
||||||
return SaveResult(success=success, roll=roll, total=total, dc=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."""
|
"""Return a skill's static bonus (no d20). Shared by resolve_skill."""
|
||||||
skill_spec = PREDEFINED_SKILLS.get(skill)
|
skill_spec = PREDEFINED_SKILLS.get(skill)
|
||||||
if skill_spec is None:
|
if skill_spec is None:
|
||||||
@@ -3545,6 +3664,7 @@ class CombatEngine:
|
|||||||
mods = self._stat_modifiers(state, skill)
|
mods = self._stat_modifiers(state, skill)
|
||||||
if skill in _DEX_BASED_SKILLS:
|
if skill in _DEX_BASED_SKILLS:
|
||||||
mods.extend(self._stat_modifiers(state, "dex_skills"))
|
mods.extend(self._stat_modifiers(state, "dex_skills"))
|
||||||
|
mods.extend(extra_mods)
|
||||||
mod_bonus = resolve_modifiers(mods)
|
mod_bonus = resolve_modifiers(mods)
|
||||||
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
|
return rank + class_bonus + ability_mod + armor_penalty + mod_bonus
|
||||||
|
|
||||||
@@ -7007,7 +7127,10 @@ class CombatEngine:
|
|||||||
|
|
||||||
def _apply_targeted_spell_effect(
|
def _apply_targeted_spell_effect(
|
||||||
self,
|
self,
|
||||||
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect,
|
effect: (
|
||||||
|
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||||
|
| PenaltyEffect | OneShotEffect
|
||||||
|
),
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
caster_live: _LiveStats,
|
caster_live: _LiveStats,
|
||||||
target_live: _LiveStats,
|
target_live: _LiveStats,
|
||||||
@@ -7044,11 +7167,40 @@ class CombatEngine:
|
|||||||
effect, target, caster=caster, saved=saved,
|
effect, target, caster=caster, saved=saved,
|
||||||
empower=empower, maximize=maximize, spell_name=spell_name,
|
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(
|
return self._apply_buff_effect(
|
||||||
effect, target, spell_name=spell_name,
|
effect, target, spell_name=spell_name,
|
||||||
caster_level=caster.combatant.level,
|
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(
|
def _apply_damage_effect(
|
||||||
self,
|
self,
|
||||||
effect: DamageEffect,
|
effect: DamageEffect,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ engine collects and sums via resolve_modifiers.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
@@ -73,6 +73,17 @@ StatTarget = Literal[
|
|||||||
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
|
_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)
|
@dataclass(frozen=True)
|
||||||
class StatModifier:
|
class StatModifier:
|
||||||
"""+X to a stat, optionally with a bonus type for stacking and a condition.
|
"""+X to a stat, optionally with a bonus type for stacking and a condition.
|
||||||
@@ -126,6 +137,25 @@ class ActiveCondition:
|
|||||||
source: str
|
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:
|
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
|
||||||
"""Sum stat modifiers applying PF1e stacking rules.
|
"""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)
|
- ConditionEffect: applies a condition (looked up in CONDITIONS_BY_NAME)
|
||||||
- HealEffect: restores HP (capped at hp_max)
|
- HealEffect: restores HP (capped at hp_max)
|
||||||
- BuffEffect: grants StatModifiers as transient effects
|
- 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.
|
Range categories: personal, touch, close, medium, long.
|
||||||
Save types: fort, ref, will, none.
|
Save types: fort, ref, will, none.
|
||||||
@@ -20,7 +21,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
|
from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
|
||||||
|
|
||||||
from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -268,6 +269,23 @@ class BuffEffect:
|
|||||||
scaling_flat: DamageFlatScaling | None = None
|
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)
|
@dataclass(frozen=True)
|
||||||
class PenaltyEffect:
|
class PenaltyEffect:
|
||||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||||
@@ -349,7 +367,7 @@ class SummonEffect:
|
|||||||
|
|
||||||
SpellEffect = (
|
SpellEffect = (
|
||||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
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:
|
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
|
||||||
flat_raw = effect.get("scaling_flat")
|
flat_raw = effect.get("scaling_flat")
|
||||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
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,
|
"condition": _parse_condition_effect,
|
||||||
"heal": _parse_heal_effect,
|
"heal": _parse_heal_effect,
|
||||||
"buff": _parse_buff_effect,
|
"buff": _parse_buff_effect,
|
||||||
|
"one_shot": _parse_one_shot_effect,
|
||||||
"penalty": _parse_penalty_effect,
|
"penalty": _parse_penalty_effect,
|
||||||
"area": _parse_area_effect,
|
"area": _parse_area_effect,
|
||||||
"summon": _parse_summon_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,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
|
OneShotEffect,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
SpellSpec,
|
SpellSpec,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
@@ -78,6 +79,7 @@ _EFFECT_TYPES = (
|
|||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
BuffEffect,
|
BuffEffect,
|
||||||
|
OneShotEffect,
|
||||||
AreaEffect,
|
AreaEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user