Adv 2.0 D&D Phase 10 SUB3b-i: Mage L10/L15

Evocation L10 Overchannel maximizes leveled-spell dice (slot 1-5),
proxied as +50% SpellPreDamage on the queued cast. Self-damage drawback
omitted — our model queues at most one cast/fight, so the "first per
rest" exemption applies. L15 Sculptural Mastery skipped: AoE-only, no
allies in 1v1 (same shape as SUB2b skips).

Abjuration L10 Spell Resistance → DamageReduct *= 0.92 (8% generic DR
proxy; the saves-advantage half is inert since enemies rarely force
saves). L15 Spell Reflection → ReflectNext += 0.30, riding the existing
reflect channel since Counterspell is reaction-deferred to Phase 11.

Necromancy L10 Command Undead has no surface in 1v1 (no thrall/ally
system), re-fluffed as deeper authority over death amplifying the
harvest itself: GrimHarvest multipliers tick up by 1 (2x→3x non-necrotic,
3x→4x necrotic). L15 Improved Undead Thralls proxied as a permanent
skeletal minion via PetAttack channel (proc 0.30, dmg 6 + CON mod);
respects existing higher-pet dmg/proc.

11 new tests; full plugin suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 11:30:20 -07:00
parent a9ee7d0f70
commit fcd067b3c4
3 changed files with 199 additions and 3 deletions

View File

@@ -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
// 1st5th-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 "1st5th
// 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

View File

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

View File

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