diff --git a/data/spells/shield_of_faith.json b/data/spells/shield_of_faith.json index c41440c..3e6196d 100644 --- a/data/spells/shield_of_faith.json +++ b/data/spells/shield_of_faith.json @@ -6,6 +6,15 @@ "save": "will", "base_dc": 10, "cast_time": "standard", - "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." + "effects": [ + { + "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)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index fdee901..45a0a20 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6461,7 +6461,10 @@ class CombatEngine: effect, target, caster=caster, saved=saved, 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( self, @@ -6678,16 +6681,42 @@ class CombatEngine: return f"+{actual} HP" def _apply_buff_effect( - self, effect: BuffEffect, target: CombatantState, *, spell_name: str + self, + effect: BuffEffect, + target: CombatantState, + *, + spell_name: str, + caster_level: int, ) -> 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( ActiveBuff( - modifiers=effect.modifiers, + modifiers=modifiers, rounds_left=effect.duration_rounds, 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( self, @@ -6912,7 +6941,11 @@ class CombatEngine: ) if isinstance(effect, BuffEffect): 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): duration = ( effect.duration_rounds_per_level * 2 diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 5043371..6da654c 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -213,8 +213,17 @@ class HealEffect: @dataclass(frozen=True) 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, ...] duration_rounds: int + scaling_flat: DamageFlatScaling | None = None @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: + flat_raw = effect.get("scaling_flat") + scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw) return BuffEffect( modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])), duration_rounds=_as_int(effect.get("duration_rounds", 0)), + scaling_flat=scaling_flat, ) diff --git a/tests/test_pc_combat_golden.py b/tests/test_pc_combat_golden.py index 554f5d2..b4b4983 100644 --- a/tests/test_pc_combat_golden.py +++ b/tests/test_pc_combat_golden.py @@ -61,33 +61,66 @@ class TestGoldenCombats: Golden updated by the Weapon Focus conformance fix: the feat filter "Pistol" now matches her pistol names ("... (pistolet ?)"), so every - pistol attack gains the +1 feat bonus. Line-by-line: - - rounds 1-3: unchanged (Bull's Strength casts, goblin misses). - - round 4 tammara: total 15 -> 16 (the +1 Weapon Focus), AC 16 is now - met: MISS -> HIT 17 damage, goblin 6 -> -11 (below its -CON death - threshold). - - "goblin down" + "players win in 4 rounds": the goblin dies on round - 4, so the old rounds 4-10 goblin shots and the draw are gone. + pistol attack gains the +1 feat bonus. + + Updated again by the SRD shield-of-faith fix (data/spells/ + shield_of_faith.json now carries its real +2 deflection AC buff): + _find_buff_spell returns the FIRST buff spell in her known-spell + order, and Shield of Faith (list index 30) precedes Bull's Strength + (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") - assert result.winner == "players" - assert result.rounds == 4 + assert result.winner is None + assert result.rounds == 10 assert result.transcript == ( "initiative: tammara d20=15+7=22", "initiative: goblin d20=10+6=16", - ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " - "(36->36)"), - "round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 2 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " - "(36->36)"), - "round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 3 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " - "(36->36)"), - "round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 20 -> MISS", - ("round 4 tammara: Braises d'Automne (pistolet ?) vs goblin d20=10+9-6=16 " - "AC 16 -> HIT 17 damage (6->-11)"), - "round 4 tammara: goblin down", - "battle over: players win in 4 rounds", + ("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED " + "-> buff 1 mods (10r) (36->36)"), + "round 1 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS", + ("round 2 tammara: cast Shield of Faith vs tammara DC14 SAVED " + "-> buff 1 mods (10r) (36->36)"), + "round 2 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS", + ("round 3 tammara: cast Shield of Faith vs tammara DC14 SAVED " + "-> buff 1 mods (10r) (36->36)"), + "round 3 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS", + ("round 4 tammara: cast Shield of Faith vs tammara DC14 SAVED " + "-> buff 1 mods (10r) (36->36)"), + "round 4 goblin: short bow vs tammara d20=15+4-2=17 AC 22 -> MISS", + ("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): @@ -98,6 +131,13 @@ class TestGoldenCombats: 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 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") assert result.winner == "monsters" @@ -105,8 +145,8 @@ class TestGoldenCombats: assert result.transcript == ( "initiative: tammara d20=15+7=22", "initiative: lich d20=10+6=16", - ("round 1 tammara: cast Bull's Strength vs tammara DC15 SAVED -> buff 3 mods (60r) " - "(36->36)"), + ("round 1 tammara: cast Shield of Faith vs tammara DC14 SAVED " + "-> buff 1 mods (10r) (36->36)"), "round 1 lich: cast Magic Missile vs tammara DC16 NO SAVE -> 43 damage (36->-7)", "battle over: monsters win in 1 rounds", ) diff --git a/tests/test_spells.py b/tests/test_spells.py index cb41770..391c5f7 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -636,6 +636,35 @@ class TestBullsStrength: 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: """Given: a caster casting Bless (SRD: 50-ft burst, "the caster and all allies")