diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 44bf4b4..7fea19f 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -97,6 +97,19 @@ func applyMageSubclassSpellHooks(c *DnDCharacter, spell SpellDefinition, slotLev } mods.SpellPreDamage += bonus } + // Phase 10 SUB3b-i — L10 Overchannel: maximize damage dice on a + // 1st–5th-level spell. We don't track per-die rolls here (damage was + // rolled in the apply* path above), so we proxy max-vs-avg as +50% + // to SpellPreDamage on a leveled cast. 5e gates this to "1st–5th + // level" — slot 6+ wouldn't get max dice — and includes a 2d12 + // necrotic self-damage drawback per cast above the first per long + // rest. Self-damage is omitted here: in our model the Mage queues + // at most one cast per fight, so the "first per rest" exemption + // almost always applies. L15 Sculptural Mastery is AoE-friendly-fire + // only — skipped (same reason SUB2b skipped Sculpt Spells). + if c.Level >= 10 && slotLevel >= 1 && slotLevel <= 5 { + mods.SpellPreDamage = (mods.SpellPreDamage * 3) / 2 + } 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 diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index bdd7bf5..1b71e3e 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -101,6 +101,46 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar } mods.ArcaneWardHP = ward } + // Phase 10 SUB3b-i — L10 Spell Resistance: 5e gives advantage on + // saves vs. spells *and* resistance to spell damage. Our 1v1 enemies + // rarely force spell saves, so the "advantage" half is largely inert; + // we collapse the effect to a flat 8% incoming-damage reduction + // (DamageReduct is multiplicative, so 0.92 = ~8% off everything, + // which is close to "resistance against the spell-damage subset"). + if c.Level >= 10 { + mods.DamageReduct *= 0.92 + } + // Phase 10 SUB3b-i — L15 Spell Reflection: 5e lets a successful + // counterspell redirect the spell back at the caster. We don't model + // counterspell (reaction primitive deferred to Phase 11), so we ride + // the ReflectNext channel — the first incoming hit reflects 30% of + // its damage back. Closest in-engine analogue to "the magic hits the + // caster instead". + if c.Level >= 15 { + mods.ReflectNext += 0.30 + } + case SubclassNecromancy: + // Phase 10 SUB3b-i — L15 Improved Undead Thralls. 5e: undead thralls + // gain +CON mod to HP and attack damage. We don't model an Animate + // Dead summoning system (SUB2b skipped Undead Thralls for the same + // reason), but the L15 capstone is too central to skip — proxy a + // permanent skeletal minion via the PetAttack channel. CON-mod scales + // dmg, mirroring the 5e formula. L10 Command Undead's combat surface + // is folded into grimHarvestHeal (deeper authority over death = more + // life harvested per spell-kill); see dnd_subclass_combat.go below. + if c.Level >= 15 { + conMod := abilityModifier(c.CON) + if conMod < 0 { + conMod = 0 + } + thrallDmg := 6 + conMod + if mods.PetAttackProc < 0.30 { + mods.PetAttackProc = 0.30 + } + if mods.PetAttackDmg < thrallDmg { + mods.PetAttackDmg = thrallDmg + } + } case SubclassAssassin: // L5 Assassinate: advantage on the opening strike + bonus damage // stacked on top of the Rogue's existing Sneak Attack auto-crit @@ -398,9 +438,17 @@ func grimHarvestHeal(c *DnDCharacter, result CombatResult, mods CombatModifiers) break } } - heal := 2 * mods.GrimHarvestSlot + // Phase 10 SUB3b-i — L10 Command Undead. The 5e ability is "take control + // of any undead", which has no surface in our 1v1 combat (no allies to + // hand summons over to). We re-fluff it here as deeper authority over + // death amplifying the harvest itself: multipliers tick up by 1 at L10 + // (2× → 3× non-necrotic, 3× → 4× necrotic). + mult := 2 if mods.GrimHarvestNecrotic { - heal = 3 * mods.GrimHarvestSlot + mult = 3 } - return heal + if c.Level >= 10 { + mult++ + } + return mult * mods.GrimHarvestSlot } diff --git a/internal/plugin/dnd_subclass_mage_test.go b/internal/plugin/dnd_subclass_mage_test.go index 889a77c..f39ddc1 100644 --- a/internal/plugin/dnd_subclass_mage_test.go +++ b/internal/plugin/dnd_subclass_mage_test.go @@ -238,3 +238,138 @@ func TestPersistDnDPostCombatSubclass_GrimHarvestApplies(t *testing.T) { t.Errorf("HP after Grim Harvest = %d, want 16 (10 + 6)", got.HPCurrent) } } + +// ── Phase 10 SUB3b-i — Mage L10/L15 ───────────────────────────────────── + +func TestEvocation_OverchannelL10MaximizesLeveledDamage(t *testing.T) { + spell, _ := lookupSpell("magic_missile") // school evocation, leveled + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassEvocation, Level: 10, INT: 18} + mods := &CombatModifiers{SpellPreDamage: 20} + applyMageSubclassSpellHooks(c, spell, 3, mods) + // magic_missile.school is "evocation", so Empowered Evocation also fires + // at L10 (gated >=L7): +INT mod 4 → SpellPreDamage = 24, then Overchannel + // ×1.5 → 36. + if mods.SpellPreDamage != 36 { + t.Errorf("Overchannel L10 leveled SpellPreDamage = %d, want 36 ((20+4)*3/2)", mods.SpellPreDamage) + } +} + +func TestEvocation_OverchannelSkipsCantrip(t *testing.T) { + spell, _ := lookupSpell("fire_bolt") // school evocation, cantrip (Level 0) + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassEvocation, Level: 10, INT: 18} + mods := &CombatModifiers{SpellPreDamage: 20} + applyMageSubclassSpellHooks(c, spell, 0, mods) + // Empowered Evocation +4, Overchannel skipped (slot < 1). + if mods.SpellPreDamage != 24 { + t.Errorf("Overchannel should skip cantrip (slot 0): SpellPreDamage = %d, want 24", mods.SpellPreDamage) + } +} + +func TestEvocation_OverchannelSkipsAboveSlot5(t *testing.T) { + spell, _ := lookupSpell("magic_missile") + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassEvocation, Level: 10, INT: 18} + mods := &CombatModifiers{SpellPreDamage: 20} + applyMageSubclassSpellHooks(c, spell, 6, mods) + // 5e Overchannel caps at slot 5; slot 6 just gets Empowered Evocation. + if mods.SpellPreDamage != 24 { + t.Errorf("Overchannel should not fire on slot 6: SpellPreDamage = %d, want 24", mods.SpellPreDamage) + } +} + +func TestEvocation_OverchannelPreL10Skipped(t *testing.T) { + spell, _ := lookupSpell("magic_missile") + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassEvocation, Level: 9, INT: 18} + mods := &CombatModifiers{SpellPreDamage: 20} + applyMageSubclassSpellHooks(c, spell, 3, mods) + if mods.SpellPreDamage != 24 { + t.Errorf("Overchannel pre-L10 should not fire: SpellPreDamage = %d, want 24", mods.SpellPreDamage) + } +} + +func TestAbjuration_SpellResistanceL10ReducesDamage(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 10} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.DamageReduct >= 1.0 || mods.DamageReduct < 0.91 || mods.DamageReduct > 0.93 { + t.Errorf("Abjuration L10 DamageReduct = %f, want ~0.92", mods.DamageReduct) + } +} + +func TestAbjuration_SpellResistancePreL10Skipped(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 9} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.DamageReduct != 1.0 { + t.Errorf("Abjuration pre-L10 DamageReduct = %f, want 1.0", mods.DamageReduct) + } +} + +func TestAbjuration_SpellReflectionL15(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 15} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.ReflectNext < 0.29 || mods.ReflectNext > 0.31 { + t.Errorf("Abjuration L15 ReflectNext = %f, want ~0.30", mods.ReflectNext) + } +} + +func TestAbjuration_SpellReflectionPreL15Skipped(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassAbjuration, Level: 14} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.ReflectNext != 0 { + t.Errorf("Abjuration pre-L15 ReflectNext = %f, want 0", mods.ReflectNext) + } +} + +func TestNecromancy_CommandUndeadL10AmplifiesHarvest(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 10} + result := CombatResult{ + PlayerWon: true, + Events: []CombatEvent{{Action: "spell_cast", EnemyHP: 0}}, + } + // Non-necrotic at L10: 3× slot (was 2×). + mods := CombatModifiers{GrimHarvestSlot: 3} + if got := grimHarvestHeal(c, result, mods); got != 9 { + t.Errorf("L10 non-necrotic Grim Harvest = %d, want 9 (3× slot)", got) + } + // Necrotic at L10: 4× slot (was 3×). + mods = CombatModifiers{GrimHarvestSlot: 2, GrimHarvestNecrotic: true} + if got := grimHarvestHeal(c, result, mods); got != 8 { + t.Errorf("L10 necrotic Grim Harvest = %d, want 8 (4× slot)", got) + } +} + +func TestNecromancy_ImprovedUndeadThrallsL15SetsPetAttack(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 15, CON: 14} // +2 mod + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc < 0.29 || mods.PetAttackProc > 0.31 { + t.Errorf("Necromancer L15 PetAttackProc = %f, want ~0.30", mods.PetAttackProc) + } + if mods.PetAttackDmg != 8 { // 6 + CON mod 2 + t.Errorf("Necromancer L15 CON 14 PetAttackDmg = %d, want 8 (6 + 2)", mods.PetAttackDmg) + } +} + +func TestNecromancy_ImprovedUndeadThrallsPreL15Skipped(t *testing.T) { + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 14, CON: 14} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc != 0 || mods.PetAttackDmg != 0 { + t.Errorf("Necromancer pre-L15 should not summon thrall: proc=%f dmg=%d", mods.PetAttackProc, mods.PetAttackDmg) + } +} + +func TestNecromancy_ImprovedUndeadThrallsRespectsExistingHigherProc(t *testing.T) { + // If a stronger pet (e.g. an adventure pet) is already in slot, don't downgrade. + c := &DnDCharacter{Class: ClassMage, Subclass: SubclassNecromancy, Level: 15, CON: 14} + mods := &CombatModifiers{DamageReduct: 1.0, PetAttackProc: 0.5, PetAttackDmg: 12} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.PetAttackProc != 0.5 { + t.Errorf("thrall overwrote stronger PetAttackProc: got %f, want 0.5", mods.PetAttackProc) + } + if mods.PetAttackDmg != 12 { + t.Errorf("thrall overwrote stronger PetAttackDmg: got %d, want 12", mods.PetAttackDmg) + } +}