From 596e17dc73e8be0619eac1d0e353054b177b1ef2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 10:42:35 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 10 SUB2b: Mage subclasses (Evocation/Abjuration/Necromancy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evocation L7 Empowered Evocation adds INT mod (min +1) to Mage evocation spell damage. Abjuration L5 Arcane Ward grants a 2×level HP buffer absorbed before player HP, +prof at L7. Necromancy L5 Grim Harvest heals 2× spell level on spell-kill (3× necrotic) via post-combat hook. Sculpt Spells / Potent Cantrip / Inured to Undeath skipped — no allies, save-half is already uniform, and no necrotic enemy damage to resist. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/combat_bridge.go | 4 +- internal/plugin/combat_engine.go | 34 +++ internal/plugin/dnd_spell_combat.go | 41 ++++ internal/plugin/dnd_subclass_combat.go | 72 +++++- internal/plugin/dnd_subclass_combat_test.go | 6 +- internal/plugin/dnd_subclass_mage_test.go | 240 ++++++++++++++++++++ 6 files changed, 385 insertions(+), 12 deletions(-) create mode 100644 internal/plugin/dnd_subclass_mage_test.go diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index e12a8f4..f686e8a 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -90,7 +90,7 @@ func (p *AdventurePlugin) runArenaCombat( p.grantCombatAchievements(userID, result) persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) - if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage); err != nil { + if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { slog.Error("dnd: post-combat subclass persist (arena)", "user", userID, "err", err) } @@ -220,7 +220,7 @@ func (p *AdventurePlugin) runDungeonCombat( p.grantCombatAchievements(userID, result) persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) - if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage); err != nil { + if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err) } diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index e485f38..d825cab 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -82,6 +82,17 @@ type CombatModifiers struct { AssassinateAdvantage bool AssassinateBonusDmg int + // Phase 10 SUB2b — Mage subclasses. + // ArcaneWardHP: flat HP buffer absorbed before player HP. Refilled at the + // start of each combat by Abjuration L5+ (2× Mage level, +prof at L7). + // Persists across rounds within a single combat; not refunded between fights. + // GrimHarvestSlot/Necrotic: snapshot of the queued spell stashed by + // applyPendingCast for the post-combat Grim Harvest hook (Necromancy L5+). + // Heal fires only if the spell event is what dropped the enemy to 0. + ArcaneWardHP int + GrimHarvestSlot int + GrimHarvestNecrotic bool + // Phase 9 — pending spell resolution. Set by applyPendingCast in // dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is // dealt as a pre-combat event with SpellPreDamageDesc as the narrative @@ -204,6 +215,9 @@ type combatState struct { assassinateRerollUsed bool assassinateBonusUsed bool + // Phase 10 SUB2b — Abjuration Arcane Ward HP buffer. + arcaneWardHP int + round int events []CombatEvent } @@ -217,6 +231,7 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult reflectFrac: player.Mods.ReflectNext, autoCrit: player.Mods.AutoCritFirst, enemySkipFirst: player.Mods.SpellEnemySkipFirst, + arcaneWardHP: player.Mods.ArcaneWardHP, } result := CombatResult{ @@ -723,6 +738,25 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat action = "block" } + // Phase 10 SUB2b — Abjuration Arcane Ward absorbs incoming damage before + // it hits player HP. Only wired into the standard enemy attack path; tick + // effects (poison, environment, lifesteal, cleave) bypass the ward in this + // model — those represent damage-over-time / tactical hits where the + // abstraction "magical aegis" reads thinner narratively. + if st.arcaneWardHP > 0 && dmg > 0 { + absorbed := dmg + if absorbed > st.arcaneWardHP { + absorbed = st.arcaneWardHP + } + st.arcaneWardHP -= absorbed + dmg -= absorbed + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "player", Action: "arcane_ward", + Damage: absorbed, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Desc: "Arcane Ward", + }) + } + st.playerHP = max(0, st.playerHP-dmg) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: phaseName, Actor: "enemy", Action: action, diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 189e620..44bf4b4 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -52,6 +52,8 @@ func applyPendingCast( dc := spellSaveDC(c) atk := spellAttackBonus(c) + preDmgBefore := playerMods.SpellPreDamage + switch spell.Effect { case EffectDamageAttack: applySpellDamageAttack(spell, atk, playerMods, enemyStats, pc.SlotLevel, c.Level) @@ -65,12 +67,51 @@ func applyPendingCast( applySpellBuff(spell, c, playerStats, playerMods, pc.SlotLevel) } + if playerMods.SpellPreDamage > preDmgBefore { + applyMageSubclassSpellHooks(c, spell, pc.SlotLevel, playerMods) + } + c.PendingCast = "" if err := SaveDnDCharacter(c); err != nil { slog.Error("dnd: clear pending_cast failed", "user", userID, "err", err) } } +// applyMageSubclassSpellHooks layers the Mage subclass damage/healing +// adjustments on top of a freshly-resolved spell cast. Caller passes mods +// only after the spell actually dealt damage so misses don't trigger +// Empowered Evocation or Grim Harvest stash. +func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLevel int, mods *CombatModifiers) { + if c == nil || c.Class != ClassMage { + return + } + switch c.Subclass { + case SubclassEvocation: + // L7 Empowered Evocation: +INT mod (min 1) to one Mage evocation + // spell's damage per turn. We're one-shot, so "per turn" collapses + // to "this cast". + if c.Level >= 7 && spell.School == "evocation" { + bonus := abilityModifier(c.INT) + if bonus < 1 { + bonus = 1 + } + mods.SpellPreDamage += bonus + } + case SubclassNecromancy: + // L5 Grim Harvest stash — heal applied post-combat iff this spell + // landed the killing blow. Cantrip kills count as L1 for the heal + // formula; necrotic damage triples the multiplier (per design doc). + if c.Level >= 5 { + slot := slotLevel + if spell.Level == 0 { + slot = 1 + } + mods.GrimHarvestSlot = slot + mods.GrimHarvestNecrotic = spell.DamageType == "necrotic" + } + } +} + // applySpellDamageAttack — Fire Bolt, Inflict Wounds, Chill Touch, etc. // Roll d20 + spell attack vs enemy AC; nat 20 doubles dice damage. func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifiers, enemy *CombatStats, slot, charLevel int) { diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index b8c3e19..acc8922 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -29,6 +29,23 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar if c.Level >= 7 { stats.AttackBonus++ } + case SubclassAbjuration: + // L5 Arcane Ward: HP buffer = 2× Mage level. 5e fluffs this as + // recharged when casting an abjuration spell of L1+; we approximate + // "always-on" by refilling at the start of every combat — the player + // can refresh by simply casting any abjuration spell pre-combat + // (mage_armor, shield_of_faith, etc.), which our combat already + // handles via pending_cast. L7 Improved Abjuration: + proficiency + // bonus to ward HP (5e: + prof to Counterspell/Dispel checks; we + // fold the static piece into the durability buff since Counterspell + // is reaction-deferred to Phase 11). + if c.Level >= 5 { + ward := 2 * c.Level + if c.Level >= 7 { + ward += proficiencyBonus(c.Level) + } + mods.ArcaneWardHP = ward + } case SubclassAssassin: // L5 Assassinate: advantage on the opening strike + bonus damage // stacked on top of the Rogue's existing Sneak Attack auto-crit @@ -126,15 +143,56 @@ func init() { } // persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping. -// Currently: increment Exhaustion if Berserker rage fired. Called from -// combat_bridge after the combat result is computed. +// Bumps Exhaustion if Berserker rage fired and applies Grim Harvest healing +// if a Necromancy Mage's queued spell delivered the killing blow. Called +// from combat_bridge after SimulateCombat returns. // -// raged is computed as (mods.BerserkerRage at combat-start time) — pass it -// through from the caller where the mods value is still in scope. -func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool) error { - if c == nil || !raged { +// raged is captured before combat (mods.BerserkerRage at combat-start time); +// result + mods are read here to detect the spell-kill and look up the +// stashed slot level / damage type. +func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers) error { + if c == nil { + return nil + } + dirty := false + if raged { + c.Exhaustion++ + dirty = true + } + if heal := grimHarvestHeal(c, result, mods); heal > 0 { + c.HPCurrent = min(c.HPMax, c.HPCurrent+heal) + dirty = true + } + if !dirty { return nil } - c.Exhaustion++ return SaveDnDCharacter(c) } + +// grimHarvestHeal returns the HP to restore when a Necromancy Mage's queued +// spell delivered the killing blow. 5e: heal 2× spell level on kill (3× if +// the spell is necrotic). Slot=0 = nothing was stashed → no heal. Returns 0 +// if the player lost or the killing blow wasn't the pre-combat spell. +func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers) int { + if c == nil || c.Class != ClassMage || c.Subclass != SubclassNecromancy || c.Level < 5 { + return 0 + } + if !result.PlayerWon || mods.GrimHarvestSlot <= 0 { + return 0 + } + // The pre-combat spell killed the enemy iff the spell_cast event itself + // dropped EnemyHP to 0. If a later round-event finished the kill, no heal. + for _, ev := range result.Events { + if ev.Action == "spell_cast" { + if ev.EnemyHP > 0 { + return 0 + } + break + } + } + heal := 2 * mods.GrimHarvestSlot + if mods.GrimHarvestNecrotic { + heal = 3 * mods.GrimHarvestSlot + } + return heal +} diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 4e5a927..4e6d144 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -89,7 +89,7 @@ func TestPersistDnDPostCombatSubclass_RagedIncrementsExhaustion(t *testing.T) { if err := SaveDnDCharacter(c); err != nil { t.Fatal(err) } - if err := persistDnDPostCombatSubclass(c, true); err != nil { + if err := persistDnDPostCombatSubclass(c, true, CombatResult{}, CombatModifiers{}); err != nil { t.Fatal(err) } got, _ := LoadDnDCharacter(uid) @@ -97,7 +97,7 @@ func TestPersistDnDPostCombatSubclass_RagedIncrementsExhaustion(t *testing.T) { t.Errorf("Exhaustion after raged combat = %d, want 1", got.Exhaustion) } // Second raged combat → 2. - if err := persistDnDPostCombatSubclass(got, true); err != nil { + if err := persistDnDPostCombatSubclass(got, true, CombatResult{}, CombatModifiers{}); err != nil { t.Fatal(err) } got2, _ := LoadDnDCharacter(uid) @@ -120,7 +120,7 @@ func TestPersistDnDPostCombatSubclass_NoRageNoIncrement(t *testing.T) { if err := SaveDnDCharacter(c); err != nil { t.Fatal(err) } - if err := persistDnDPostCombatSubclass(c, false); err != nil { + if err := persistDnDPostCombatSubclass(c, false, CombatResult{}, CombatModifiers{}); err != nil { t.Fatal(err) } got, _ := LoadDnDCharacter(uid) diff --git a/internal/plugin/dnd_subclass_mage_test.go b/internal/plugin/dnd_subclass_mage_test.go new file mode 100644 index 0000000..889a77c --- /dev/null +++ b/internal/plugin/dnd_subclass_mage_test.go @@ -0,0 +1,240 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// Phase 10 SUB2b — Evocation, Abjuration, Necromancy. + +// ── Evocation ─────────────────────────────────────────────────────────── + +func TestEvocation_EmpoweredEvocationAddsINTMod(t *testing.T) { + spell, _ := lookupSpell("fire_bolt") // school=evocation + c := &DnDCharacter{ + Class: ClassMage, Subclass: SubclassEvocation, Level: 7, INT: 18, // +4 mod + } + mods := &CombatModifiers{SpellPreDamage: 5} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.SpellPreDamage != 9 { + t.Errorf("Empowered Evocation L7 INT 18: SpellPreDamage = %d, want 9 (5+4)", mods.SpellPreDamage) + } +} + +func TestEvocation_EmpoweredEvocationMinBonusOne(t *testing.T) { + spell, _ := lookupSpell("fire_bolt") + c := &DnDCharacter{ + Class: ClassMage, Subclass: SubclassEvocation, Level: 7, INT: 8, // -1 mod + } + mods := &CombatModifiers{SpellPreDamage: 5} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.SpellPreDamage != 6 { + t.Errorf("Empowered Evocation INT 8 (negative mod): SpellPreDamage = %d, want 6 (min +1)", mods.SpellPreDamage) + } +} + +func TestEvocation_EmpoweredEvocationPreL7Skipped(t *testing.T) { + spell, _ := lookupSpell("fire_bolt") + c := &DnDCharacter{ + Class: ClassMage, Subclass: SubclassEvocation, Level: 6, INT: 18, + } + mods := &CombatModifiers{SpellPreDamage: 5} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.SpellPreDamage != 5 { + t.Errorf("Empowered Evocation should not fire pre-L7: SpellPreDamage = %d, want 5", mods.SpellPreDamage) + } +} + +func TestEvocation_EmpoweredEvocationOnlyForEvocationSpells(t *testing.T) { + spell, _ := lookupSpell("chill_touch") // school=necromancy + c := &DnDCharacter{ + Class: ClassMage, Subclass: SubclassEvocation, Level: 7, INT: 18, + } + mods := &CombatModifiers{SpellPreDamage: 5} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.SpellPreDamage != 5 { + t.Errorf("Empowered Evocation should not fire on non-evocation school: SpellPreDamage = %d, want 5", mods.SpellPreDamage) + } +} + +// ── Abjuration ────────────────────────────────────────────────────────── + +func TestAbjuration_ArcaneWardL5(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 5} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.ArcaneWardHP != 10 { + t.Errorf("Abjuration L5 ArcaneWardHP = %d, want 10 (2× level)", mods.ArcaneWardHP) + } +} + +func TestAbjuration_ArcaneWardL7AddsProf(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 7} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + // 2*7 + prof(L7=3) = 17. + if mods.ArcaneWardHP != 17 { + t.Errorf("Abjuration L7 ArcaneWardHP = %d, want 17 (14 + prof 3)", mods.ArcaneWardHP) + } +} + +func TestAbjuration_PreL5NoWard(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 4} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.ArcaneWardHP != 0 { + t.Errorf("Abjuration pre-L5 ArcaneWardHP = %d, want 0", mods.ArcaneWardHP) + } +} + +// End-to-end: a sturdy Arcane Ward should reduce damage taken when the +// engine resolves enemy attacks. +func TestSimulateCombat_ArcaneWardAbsorbsDamage(t *testing.T) { + const trials = 600 + build := func(ward int) Combatant { + return Combatant{ + Name: "Mage", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 60, Attack: 10, Defense: 8, Speed: 10, AttackBonus: 4, AC: 14, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageReduct: 1.0, ArcaneWardHP: ward}, + } + } + enemy := Combatant{ + Name: "Brute", + Stats: CombatStats{ + MaxHP: 80, Attack: 12, Defense: 6, Speed: 8, AC: 13, AttackBonus: 4, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageReduct: 1.0}, + } + plainEndHP, wardEndHP := 0, 0 + for i := 0; i < trials; i++ { + plainEndHP += SimulateCombat(build(0), enemy, defaultCombatPhases).PlayerEndHP + wardEndHP += SimulateCombat(build(40), enemy, defaultCombatPhases).PlayerEndHP + } + if wardEndHP <= plainEndHP { + t.Errorf("Arcane Ward should improve survivability: plain=%d ward=%d (sum HP)", plainEndHP, wardEndHP) + } +} + +// ── Necromancy ────────────────────────────────────────────────────────── + +func TestNecromancy_GrimHarvestStashOnDamage(t *testing.T) { + spell, _ := lookupSpell("chill_touch") // necrotic, school necromancy + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16} + mods := &CombatModifiers{SpellPreDamage: 8} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.GrimHarvestSlot != 1 { + t.Errorf("cantrip Grim Harvest slot = %d, want 1 (cantrip floor)", mods.GrimHarvestSlot) + } + if !mods.GrimHarvestNecrotic { + t.Error("chill_touch should mark GrimHarvestNecrotic") + } +} + +func TestNecromancy_GrimHarvestStashLeveledSpell(t *testing.T) { + spell, _ := lookupSpell("magic_missile") // force damage; school evocation + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, INT: 16} + mods := &CombatModifiers{SpellPreDamage: 12} + applyMageSubclassSpellHooks(c, spell, 3, mods) + if mods.GrimHarvestSlot != 3 { + t.Errorf("magic_missile L3 Grim Harvest slot = %d, want 3", mods.GrimHarvestSlot) + } + if mods.GrimHarvestNecrotic { + t.Error("magic_missile is force, not necrotic — should not set Necrotic flag") + } +} + +func TestNecromancy_GrimHarvestPreL5NoStash(t *testing.T) { + spell, _ := lookupSpell("chill_touch") + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 4, INT: 16} + mods := &CombatModifiers{SpellPreDamage: 8} + applyMageSubclassSpellHooks(c, spell, 0, mods) + if mods.GrimHarvestSlot != 0 { + t.Errorf("pre-L5 Necromancer should not stash; slot = %d, want 0", mods.GrimHarvestSlot) + } +} + +func TestGrimHarvestHeal_NecroticTriple(t *testing.T) { + c := &DnDCharacter{ + Class: ClassMage, Subclass: SubclassNecromancy, Level: 5, + } + result := CombatResult{ + PlayerWon: true, + Events: []CombatEvent{ + {Action: "spell_cast", EnemyHP: 0}, + }, + } + mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true} + if got := grimHarvestHeal(c, result, mods); got != 6 { + t.Errorf("necrotic Grim Harvest L2 = %d, want 6 (3× slot)", got) + } +} + +func TestGrimHarvestHeal_NonNecroticDouble(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5} + result := CombatResult{ + PlayerWon: true, + Events: []CombatEvent{{Action: "spell_cast", EnemyHP: 0}}, + } + mods := CombatModifiers{GrimHarvestSlot: 3} + if got := grimHarvestHeal(c, result, mods); got != 6 { + t.Errorf("non-necrotic Grim Harvest L3 = %d, want 6 (2× slot)", got) + } +} + +func TestGrimHarvestHeal_OnlyOnSpellKill(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5} + // Spell hit but didn't kill — combat finished with regular attack. + result := CombatResult{ + PlayerWon: true, + Events: []CombatEvent{{Action: "spell_cast", EnemyHP: 5}}, + } + mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true} + if got := grimHarvestHeal(c, result, mods); got != 0 { + t.Errorf("Grim Harvest fires on non-spell-kill: got %d, want 0", got) + } +} + +func TestGrimHarvestHeal_LossNoHeal(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 5} + result := CombatResult{ + PlayerWon: false, + Events: []CombatEvent{{Action: "spell_cast", EnemyHP: 0}}, + } + mods := CombatModifiers{GrimHarvestSlot: 2} + if got := grimHarvestHeal(c, result, mods); got != 0 { + t.Errorf("Grim Harvest fires on loss: got %d, want 0", got) + } +} + +func TestPersistDnDPostCombatSubclass_GrimHarvestApplies(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@grim_heal:example") + if err := createAdvCharacter(uid, "grim"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassMage, Subclass: SubclassNecromancy, + Level: 5, STR: 8, DEX: 12, CON: 12, INT: 16, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 10, ArmorClass: 12, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + result := CombatResult{ + PlayerWon: true, + Events: []CombatEvent{{Action: "spell_cast", EnemyHP: 0}}, + } + mods := CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true} + if err := persistDnDPostCombatSubclass(c, false, result, mods); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.HPCurrent != 16 { + t.Errorf("HP after Grim Harvest = %d, want 16 (10 + 6)", got.HPCurrent) + } +}