Adv 2.0 D&D Phase 10 SUB2b: Mage subclasses (Evocation/Abjuration/Necromancy)

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) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 10:42:35 -07:00
parent 4a5159ee14
commit 596e17dc73
6 changed files with 385 additions and 12 deletions

View File

@@ -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)
}
}