fix(rules): shield of faith grants its SRD deflection AC buff (+2, +1 per six caster levels, max +5) via a new BuffEffect flat scaling
This commit is contained in:
@@ -6,6 +6,15 @@
|
|||||||
"save": "will",
|
"save": "will",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [
|
||||||
"description": "A shimmering magical field grants the target a +2 deflection bonus to AC (+1 per six caster levels, max +5) for 1 min./level. Will negates (harmless). Simplified: the engine has no self-buff encoding that preserves the pinned default-policy casting order, so no mechanical effect is applied."
|
{
|
||||||
|
"type": "buff",
|
||||||
|
"modifiers": [
|
||||||
|
{ "target": "ac", "value": 2, "bonus_type": "deflection" }
|
||||||
|
],
|
||||||
|
"duration_rounds": 10,
|
||||||
|
"scaling_flat": { "amount": 1, "per_levels": 6, "max": 3 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "SRD spells/shield-of-faith.md: abjuration, level 1 (cleric/inquisitor/oracle/warpriest), range touch, duration 1 min./level, Will negates (harmless), spell resistance yes (harmless). The subject gains a +2 deflection bonus to AC, +1 per six caster levels (maximum +5 at 18th). Duration 1 min/level approximated as 10 rounds (the engine has no per-level buff duration; cf. mirror image)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6461,7 +6461,10 @@ 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,
|
||||||
)
|
)
|
||||||
return self._apply_buff_effect(effect, target, spell_name=spell_name)
|
return self._apply_buff_effect(
|
||||||
|
effect, target, spell_name=spell_name,
|
||||||
|
caster_level=caster.combatant.level,
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_damage_effect(
|
def _apply_damage_effect(
|
||||||
self,
|
self,
|
||||||
@@ -6678,16 +6681,42 @@ class CombatEngine:
|
|||||||
return f"+{actual} HP"
|
return f"+{actual} HP"
|
||||||
|
|
||||||
def _apply_buff_effect(
|
def _apply_buff_effect(
|
||||||
self, effect: BuffEffect, target: CombatantState, *, spell_name: str
|
self,
|
||||||
|
effect: BuffEffect,
|
||||||
|
target: CombatantState,
|
||||||
|
*,
|
||||||
|
spell_name: str,
|
||||||
|
caster_level: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
# PF1e caster-level scaling on buff values (SRD shield of faith
|
||||||
|
# "+2 deflection, +1 per six caster levels (maximum +5)"): the flat
|
||||||
|
# extra is added to EVERY modifier of the buff.
|
||||||
|
modifiers = effect.modifiers
|
||||||
|
if effect.scaling_flat is not None:
|
||||||
|
extra = min(
|
||||||
|
effect.scaling_flat.max_amount,
|
||||||
|
(caster_level // effect.scaling_flat.per_levels)
|
||||||
|
* effect.scaling_flat.amount,
|
||||||
|
)
|
||||||
|
if extra > 0:
|
||||||
|
modifiers = tuple(
|
||||||
|
StatModifier(
|
||||||
|
target=mod.target,
|
||||||
|
value=mod.value + extra,
|
||||||
|
bonus_type=mod.bonus_type,
|
||||||
|
condition=mod.condition,
|
||||||
|
weapon_filter=mod.weapon_filter,
|
||||||
|
)
|
||||||
|
for mod in modifiers
|
||||||
|
)
|
||||||
target.active_buffs.append(
|
target.active_buffs.append(
|
||||||
ActiveBuff(
|
ActiveBuff(
|
||||||
modifiers=effect.modifiers,
|
modifiers=modifiers,
|
||||||
rounds_left=effect.duration_rounds,
|
rounds_left=effect.duration_rounds,
|
||||||
source=f"spell: {spell_name}",
|
source=f"spell: {spell_name}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)"
|
return f"buff {len(modifiers)} mods ({effect.duration_rounds}r)"
|
||||||
|
|
||||||
def _apply_penalty_effect(
|
def _apply_penalty_effect(
|
||||||
self,
|
self,
|
||||||
@@ -6912,7 +6941,11 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
if isinstance(effect, BuffEffect):
|
if isinstance(effect, BuffEffect):
|
||||||
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||||
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
|
return BuffEffect(
|
||||||
|
modifiers=effect.modifiers,
|
||||||
|
duration_rounds=duration,
|
||||||
|
scaling_flat=effect.scaling_flat,
|
||||||
|
)
|
||||||
if isinstance(effect, PenaltyEffect):
|
if isinstance(effect, PenaltyEffect):
|
||||||
duration = (
|
duration = (
|
||||||
effect.duration_rounds_per_level * 2
|
effect.duration_rounds_per_level * 2
|
||||||
|
|||||||
@@ -213,8 +213,17 @@ class HealEffect:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BuffEffect:
|
class BuffEffect:
|
||||||
|
"""Transient stat modifiers (PF1e buff spells).
|
||||||
|
|
||||||
|
``scaling_flat`` (SRD shield of faith "+2 deflection, +1 per six caster
|
||||||
|
levels (maximum +5)", divine favor "+1 per three caster levels (maximum
|
||||||
|
+3)") adds the resolved flat bonus to EVERY modifier's value at cast
|
||||||
|
time, mirroring ``DamageEffect.effective_formula``'s flat scaling.
|
||||||
|
"""
|
||||||
|
|
||||||
modifiers: tuple[StatModifier, ...]
|
modifiers: tuple[StatModifier, ...]
|
||||||
duration_rounds: int
|
duration_rounds: int
|
||||||
|
scaling_flat: DamageFlatScaling | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -611,9 +620,12 @@ def _parse_heal_effect(effect: dict[str, Json]) -> HealEffect:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
|
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
|
||||||
|
flat_raw = effect.get("scaling_flat")
|
||||||
|
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||||
return BuffEffect(
|
return BuffEffect(
|
||||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||||
|
scaling_flat=scaling_flat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,33 +61,66 @@ class TestGoldenCombats:
|
|||||||
|
|
||||||
Golden updated by the Weapon Focus conformance fix: the feat filter
|
Golden updated by the Weapon Focus conformance fix: the feat filter
|
||||||
"Pistol" now matches her pistol names ("... (pistolet ?)"), so every
|
"Pistol" now matches her pistol names ("... (pistolet ?)"), so every
|
||||||
pistol attack gains the +1 feat bonus. Line-by-line:
|
pistol attack gains the +1 feat bonus.
|
||||||
- rounds 1-3: unchanged (Bull's Strength casts, goblin misses).
|
|
||||||
- round 4 tammara: total 15 -> 16 (the +1 Weapon Focus), AC 16 is now
|
Updated again by the SRD shield-of-faith fix (data/spells/
|
||||||
met: MISS -> HIT 17 damage, goblin 6 -> -11 (below its -CON death
|
shield_of_faith.json now carries its real +2 deflection AC buff):
|
||||||
threshold).
|
_find_buff_spell returns the FIRST buff spell in her known-spell
|
||||||
- "goblin down" + "players win in 4 rounds": the goblin dies on round
|
order, and Shield of Faith (list index 30) precedes Bull's Strength
|
||||||
4, so the old rounds 4-10 goblin shots and the draw are gone.
|
(index 46) — so the policy's buff pick is now Shield of Faith.
|
||||||
|
Line-by-line:
|
||||||
|
- rounds 1-4 tammara: "cast Shield of Faith ... DC14 SAVED -> buff 1
|
||||||
|
mods (10r)" — DC 14 = 10 + spell level 1 + her +3 Wis; the deflection
|
||||||
|
buff does not stack with itself, so the re-casts just refresh it. She
|
||||||
|
has four level-1 slots, so the buff phase now runs rounds 1-4 (the
|
||||||
|
old Bull's Strength phase ran rounds 1-3 on three level-2 slots).
|
||||||
|
- rounds 1-10 goblin: "AC 22" (was 20) — the +2 deflection raises her
|
||||||
|
AC; the goblin's d20=15+4-2=17 shot still misses.
|
||||||
|
- rounds 5-10 tammara: "d20=10+9-6=14 AC 16 -> MISS" — out of level-1
|
||||||
|
slots she shoots; without Bull's Strength's +2 enhancement attack
|
||||||
|
bonus the scripted d20=10 shot totals 14, below the goblin's AC 16.
|
||||||
|
- "battle over: draw after 10 rounds": the goblin is never dropped
|
||||||
|
within the round cap. This is a default_policy first-match heuristic
|
||||||
|
artifact (Shield of Faith is a legitimate SRD buff, just a
|
||||||
|
defensively-picked one), not an SRD regression.
|
||||||
"""
|
"""
|
||||||
result, _ = run_golden("tammara")
|
result, _ = run_golden("tammara")
|
||||||
assert result.winner == "players"
|
assert result.winner is None
|
||||||
assert result.rounds == 4
|
assert result.rounds == 10
|
||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: tammara d20=15+7=22",
|
"initiative: tammara d20=15+7=22",
|
||||||
"initiative: goblin d20=10+6=16",
|
"initiative: goblin d20=10+6=16",
|
||||||
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) "
|
("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED "
|
||||||
"(36->36)"),
|
"-> buff 1 mods (10r) (36->36)"),
|
||||||
"round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) "
|
("round 2 tammara: cast Shield of Faith vs tammara DC14 SAVED "
|
||||||
"(36->36)"),
|
"-> buff 1 mods (10r) (36->36)"),
|
||||||
"round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) "
|
("round 3 tammara: cast Shield of Faith vs tammara DC14 SAVED "
|
||||||
"(36->36)"),
|
"-> buff 1 mods (10r) (36->36)"),
|
||||||
"round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS",
|
"round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
("round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=16 "
|
("round 4 tammara: cast Shield of Faith vs tammara DC14 SAVED "
|
||||||
"AC 16 -> HIT 17 damage (6->-11)"),
|
"-> buff 1 mods (10r) (36->36)"),
|
||||||
"round 4 tammara: goblin down",
|
"round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
"battle over: players win in 4 rounds",
|
("round 5 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 5 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
("round 6 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 6 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
("round 7 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 7 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
("round 8 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 8 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
("round 9 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 9 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
("round 10 tammara: Braises d'Automne (pistolet ?) vs goblin "
|
||||||
|
"d20=10+9-6=14 AC 16 -> MISS"),
|
||||||
|
"round 10 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS",
|
||||||
|
"battle over: draw after 10 rounds",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tammara_vs_lich(self):
|
def test_tammara_vs_lich(self):
|
||||||
@@ -98,6 +131,13 @@ class TestGoldenCombats:
|
|||||||
missiles (16 + 11 + 16 = 43) drop Tammara below 0 hp on round 1; the
|
missiles (16 + 11 + 16 = 43) drop Tammara below 0 hp on round 1; the
|
||||||
remaining two are lost (the engine's target-drops-mid-sequence
|
remaining two are lost (the engine's target-drops-mid-sequence
|
||||||
convention), so the lich wins in 1 round.
|
convention), so the lich wins in 1 round.
|
||||||
|
|
||||||
|
Updated by the SRD shield-of-faith fix: her round-1 buff pick is now
|
||||||
|
Shield of Faith (first buff spell in her known-spell order, index 30
|
||||||
|
before Bull's Strength's 46) — "cast Shield of Faith ... DC14 SAVED ->
|
||||||
|
buff 1 mods (10r)" (DC 14 = 10 + level 1 + her +3 Wis). Magic Missile
|
||||||
|
auto-hits and ignores the +2 deflection it would otherwise grant, so
|
||||||
|
the lich's line and the outcome are unchanged.
|
||||||
"""
|
"""
|
||||||
result, _ = run_golden("tammara", "lich")
|
result, _ = run_golden("tammara", "lich")
|
||||||
assert result.winner == "monsters"
|
assert result.winner == "monsters"
|
||||||
@@ -105,8 +145,8 @@ class TestGoldenCombats:
|
|||||||
assert result.transcript == (
|
assert result.transcript == (
|
||||||
"initiative: tammara d20=15+7=22",
|
"initiative: tammara d20=15+7=22",
|
||||||
"initiative: lich d20=10+6=16",
|
"initiative: lich d20=10+6=16",
|
||||||
("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) "
|
("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED "
|
||||||
"(36->36)"),
|
"-> buff 1 mods (10r) (36->36)"),
|
||||||
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)",
|
"round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)",
|
||||||
"battle over: monsters win in 1 rounds",
|
"battle over: monsters win in 1 rounds",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -636,6 +636,35 @@ class TestBullsStrength:
|
|||||||
assert any("buff" in line for line in engine._transcript)
|
assert any("buff" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
class TestShieldOfFaithScaling:
|
||||||
|
"""Given: SRD shield-of-faith "+2 deflection bonus to AC, +1 per six
|
||||||
|
caster levels (maximum +5)"
|
||||||
|
When: casters of different levels cast it
|
||||||
|
Then: the AC modifier is 2 + min(3, level // 6) — +2 at 5th, +3 at 6th,
|
||||||
|
+5 at 18th."""
|
||||||
|
|
||||||
|
def _ac_value(self, level: int) -> int:
|
||||||
|
caster = _make_caster(level=level)
|
||||||
|
target = _make_target()
|
||||||
|
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
|
||||||
|
target_side="players")
|
||||||
|
engine._cast_spell(c, t, "Shield of Faith")
|
||||||
|
ac_mods = [m for b in t.active_buffs for m in b.modifiers if m.target == "ac"]
|
||||||
|
assert len(ac_mods) == 1
|
||||||
|
assert ac_mods[0].bonus_type == "deflection"
|
||||||
|
return ac_mods[0].value
|
||||||
|
|
||||||
|
def test_base_bonus_below_sixth_level(self) -> None:
|
||||||
|
assert self._ac_value(5) == 2
|
||||||
|
|
||||||
|
def test_first_step_at_sixth_level(self) -> None:
|
||||||
|
assert self._ac_value(6) == 3
|
||||||
|
|
||||||
|
def test_capped_at_five(self) -> None:
|
||||||
|
assert self._ac_value(18) == 5
|
||||||
|
assert self._ac_value(20) == 5
|
||||||
|
|
||||||
|
|
||||||
class TestBlessAlliesOnly:
|
class TestBlessAlliesOnly:
|
||||||
"""Given: a caster casting Bless (SRD: 50-ft burst, "the caster and all
|
"""Given: a caster casting Bless (SRD: 50-ft burst, "the caster and all
|
||||||
allies")
|
allies")
|
||||||
|
|||||||
Reference in New Issue
Block a user