mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
240
internal/plugin/dnd_subclass_mage_test.go
Normal file
240
internal/plugin/dnd_subclass_mage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user