mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
The doc's item M claimed all three mage subclass spell hooks were dead on the
turn path because applyMageSubclassSpellHooks had one caller. It has three.
resolveTurnSpell has called it since 5cd343a, so Empowered Evocation and
Overchannel always worked -- they only move mods.SpellPreDamage, which
resolveTurnSpell returns as EnemyDamage.
Grim Harvest was the real defect, with a narrower cause: the hook wrote
mods.GrimHarvestSlot into a local CombatModifiers that resolveTurnSpell
discarded, because turnSpellOutcome had no field to carry it out. A Necromancy
Mage who killed with a spell in a manual fight never healed.
The stash can't ride on fight-start mods the way auto-resolve's does -- the
spell is cast mid-fight and the turn engine rebuilds combatants every round --
so it rides on the casting seat's ActorStatuses, like ArmedAbility. Each
damaging cast overwrites it; snapshotActor carries it across commit().
grimHarvestHeal also scanned for the *first* spell_cast event to ask whether
the spell landed the killing blow. Auto-resolve casts once, pre-combat, so
first == last there. A turn-based mage casts every round, so a non-lethal
opening cantrip vetoed the heal the killing spell had earned. Now scans for the
last spell_cast -- provably identical on the auto-resolve path, so the golden
corpus does not move.
Balance: a caster buff on the manual surface only, and the one the subclass was
written to have. Auto-resolve already paid it out.
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
495 lines
17 KiB
Go
495 lines
17 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 9 SP3 — combat-time resolution of pending spell casts.
|
||
//
|
||
// applyPendingCast consumes c.PendingCast (queued by !cast in dnd_cast.go)
|
||
// and folds the spell's effect into playerStats / playerMods / enemyStats
|
||
// before SimulateCombat begins. Damage spells emit a "spell_cast" pre-combat
|
||
// event via Mods.SpellPreDamage{,Desc}; control spells set
|
||
// Mods.SpellEnemySkipFirst (and AutoCritFirst on Hold Person family); buffs
|
||
// mutate stats directly.
|
||
//
|
||
// Concentration is left untouched — it persists across fights until the
|
||
// player drops it manually or casts a replacing concentration spell. The
|
||
// concentration-on-damage-break check is deferred to Phase 11 (turn-based
|
||
// boss combat).
|
||
//
|
||
// Pending cast is consumed regardless of outcome (a wasted cantrip miss is
|
||
// still a cantrip; a wasted slot-spell miss already debited the slot at
|
||
// !cast time).
|
||
|
||
func applyPendingCast(
|
||
userID id.UserID,
|
||
c *DnDCharacter,
|
||
playerStats *CombatStats,
|
||
playerMods *CombatModifiers,
|
||
enemyStats *CombatStats,
|
||
) {
|
||
if c == nil || c.PendingCast == "" {
|
||
return
|
||
}
|
||
pc, ok := decodePendingCast(c.PendingCast)
|
||
if !ok {
|
||
c.PendingCast = ""
|
||
_ = SaveDnDCharacter(c)
|
||
return
|
||
}
|
||
spell, ok := lookupSpell(pc.SpellID)
|
||
if !ok {
|
||
c.PendingCast = ""
|
||
_ = SaveDnDCharacter(c)
|
||
return
|
||
}
|
||
|
||
dc := spellSaveDC(c)
|
||
atk := spellAttackBonus(c)
|
||
|
||
preDmgBefore := playerMods.SpellPreDamage
|
||
|
||
switch spell.Effect {
|
||
case EffectDamageAttack:
|
||
applySpellDamageAttack(spell, atk, playerMods, enemyStats, pc.SlotLevel, c.Level)
|
||
case EffectDamageSave:
|
||
applySpellDamageSave(spell, dc, c, playerMods, enemyStats, pc.SlotLevel)
|
||
case EffectDamageAuto:
|
||
applySpellDamageAuto(spell, playerMods, pc.SlotLevel, c.Level)
|
||
case EffectControl:
|
||
applySpellControl(spell, dc, playerMods, enemyStats, pc.SlotLevel)
|
||
case EffectBuffSelf, EffectBuffAlly:
|
||
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
|
||
}
|
||
// 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
|
||
// 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) {
|
||
roll := 1 + rand.IntN(20)
|
||
isCrit := roll == 20
|
||
isFumble := roll == 1
|
||
if isFumble || (!isCrit && roll+atk < enemy.AC) {
|
||
mods.SpellPreDamageDesc = spell.Name + " — missed"
|
||
return
|
||
}
|
||
dmg := rollSpellDamageDice(spell, slot, charLevel)
|
||
if isCrit {
|
||
dmg *= 2
|
||
}
|
||
mods.SpellPreDamage += dmg
|
||
if isCrit {
|
||
mods.SpellPreDamageDesc = spell.Name + " — crit!"
|
||
} else {
|
||
mods.SpellPreDamageDesc = spell.Name
|
||
}
|
||
}
|
||
|
||
// applySpellDamageSave — Burning Hands, Fireball, Sacred Flame, etc.
|
||
// Enemy rolls a save (heuristic mod = enemy.AttackBonus / 2) vs spell DC.
|
||
// Half damage on success, full on fail.
|
||
//
|
||
// Single-target limitation: Phase 9 combat features one enemy, so AoE-flagged
|
||
// spells (Fireball, Burning Hands, Cone of Cold, Flame Strike, etc.) collapse
|
||
// to a single-target damage roll. The damage formula is the same as the
|
||
// single-target case — the AoE flag is preserved on SpellDefinition for
|
||
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
||
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||
saveMod := enemySpellSaveMod(enemy)
|
||
saveRoll := 1 + rand.IntN(20)
|
||
saved := saveRoll+saveMod >= dc
|
||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||
if saved {
|
||
dmg = halveSavedDamage(dmg)
|
||
}
|
||
mods.SpellPreDamage += dmg
|
||
if saved {
|
||
mods.SpellPreDamageDesc = fmt.Sprintf("%s — saved (half, %d dmg)", spell.Name, dmg)
|
||
} else {
|
||
mods.SpellPreDamageDesc = fmt.Sprintf("%s — %d dmg", spell.Name, dmg)
|
||
}
|
||
}
|
||
|
||
// applySpellDamageAuto — Magic Missile and other no-roll damage.
|
||
// Magic Missile: 3 darts × 1d4+1, +1 dart per slot above 1st.
|
||
func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, charLevel int) {
|
||
if spell.ID == "magic_missile" {
|
||
darts := 3
|
||
if slot > 1 {
|
||
darts += slot - 1
|
||
}
|
||
total := 0
|
||
for i := 0; i < darts; i++ {
|
||
total += 1 + rand.IntN(4) + 1
|
||
}
|
||
mods.SpellPreDamage += total
|
||
mods.SpellPreDamageDesc = fmt.Sprintf("Magic Missile (%d darts, %d dmg)", darts, total)
|
||
return
|
||
}
|
||
// Fallback: roll listed dice + ability mod isn't standard for auto spells.
|
||
dmg := rollSpellDamageDice(spell, slot, charLevel)
|
||
mods.SpellPreDamage += dmg
|
||
mods.SpellPreDamageDesc = fmt.Sprintf("%s — %d dmg", spell.Name, dmg)
|
||
}
|
||
|
||
// halveSavedDamage applies the save-half rule. Integer division floors,
|
||
// then a 1-damage floor ensures even a successful save against any spell
|
||
// chips at least 1 HP. This matches the original applySpellDamageSave
|
||
// inline branch behavior; pulled out so rounding is unit-testable.
|
||
func halveSavedDamage(dmg int) int {
|
||
halved := dmg / 2
|
||
if halved < 1 {
|
||
return 1
|
||
}
|
||
return halved
|
||
}
|
||
|
||
// enemySpellSaveMod is the heuristic save modifier for an arena/dungeon
|
||
// enemy. We don't track per-stat saves on monsters, so we approximate with
|
||
// half their attack bonus — that scales linearly with threat tier (T1 ≈ +2,
|
||
// T5 ≈ +5..7) and keeps the save vs. caster DC math meaningful at both ends.
|
||
func enemySpellSaveMod(enemy *CombatStats) int {
|
||
if enemy == nil {
|
||
return 0
|
||
}
|
||
return enemy.AttackBonus / 2
|
||
}
|
||
|
||
// applySpellControl — Hold Person, Sleep, Command, Hypnotic Pattern.
|
||
// Enemy save vs DC; failure → skip first attack. Hold-family also primes
|
||
// the engine's auto-crit-on-first-hit path so melee strikes connect for
|
||
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||
saveMod := enemySpellSaveMod(enemy)
|
||
saveRoll := 1 + rand.IntN(20)
|
||
if saveRoll+saveMod >= dc {
|
||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||
return
|
||
}
|
||
mods.SpellEnemySkipFirst = true
|
||
switch spell.ID {
|
||
case "hold_person", "hold_monster":
|
||
mods.AutoCritFirst = true
|
||
mods.SpellPreDamageDesc = spell.Name + " — paralyzed!"
|
||
case "sleep":
|
||
mods.SpellPreDamageDesc = spell.Name + " — asleep"
|
||
default:
|
||
mods.SpellPreDamageDesc = spell.Name + " — controlled"
|
||
}
|
||
}
|
||
|
||
// applySpellBuff — folds known buff spells into stats/mods. Unknown buff
|
||
// IDs still emit a "spell active" beat so the cast registers in narrative.
|
||
func applySpellBuff(spell SpellDefinition, c *DnDCharacter, stats *CombatStats, mods *CombatModifiers, slot int) {
|
||
switch spell.ID {
|
||
case "mage_armor":
|
||
// AC = 13 + DEX, take whichever is higher.
|
||
newAC := 13 + abilityModifier(c.DEX)
|
||
if newAC > stats.AC {
|
||
stats.AC = newAC
|
||
}
|
||
case "shield_of_faith":
|
||
stats.AC += 2
|
||
case "barkskin":
|
||
if stats.AC < 16 {
|
||
stats.AC = 16
|
||
}
|
||
case "bless":
|
||
stats.AttackBonus += 2 // 1d4 average ≈ 2.5
|
||
case "guiding_bolt":
|
||
stats.AttackBonus += 2 // proxy for next-attack advantage
|
||
case "hunters_mark":
|
||
mods.DamageBonus += 0.15 // ~+1d6 per hit on a typical baseline
|
||
case "aid":
|
||
stats.MaxHP += 5
|
||
case "shillelagh":
|
||
stats.AttackBonus += 1
|
||
mods.DamageBonus += 0.05
|
||
case "spiritual_weapon":
|
||
// Spectral bonus-action attack each round on its own channel so the
|
||
// narration doesn't borrow pet flavor (the cleric may have no pet).
|
||
// 1d8 + spell mod base, +1d8 per 2 slot levels above 2nd; the engine
|
||
// rolls the d5 variance, so Dmg carries the average + upcast bump.
|
||
base := 4 + spellAttackBonus(c)
|
||
if slot > 2 {
|
||
base += 4 * ((slot - 2) / 2)
|
||
}
|
||
if base < 4 {
|
||
base = 4
|
||
}
|
||
if mods.SpiritWeaponProc < 0.5 {
|
||
mods.SpiritWeaponProc = 0.5
|
||
}
|
||
if mods.SpiritWeaponDmg < base {
|
||
mods.SpiritWeaponDmg = base
|
||
}
|
||
case "mirror_image":
|
||
mods.WardCharges += 2
|
||
case "blur":
|
||
mods.SporeCloud += 3 // proxy: enemy miss chance per round
|
||
case "greater_invisibility":
|
||
mods.SporeCloud += 5
|
||
stats.AttackBonus += 2
|
||
case "ensnaring_strike":
|
||
mods.SpellEnemySkipFirst = true
|
||
}
|
||
if mods.SpellPreDamageDesc == "" {
|
||
mods.SpellPreDamageDesc = spell.Name + " — active"
|
||
}
|
||
}
|
||
|
||
// ── Phase 13 — per-round turn-based casting ──────────────────────────────────
|
||
|
||
// turnSpellOutcome is the resolved effect of a spell cast as a player turn in a
|
||
// turn-based fight. Damage, healing, and the one-round enemy skip all land
|
||
// within the casting round, so no cross-round persistence is needed.
|
||
type turnSpellOutcome struct {
|
||
EnemyDamage int
|
||
PlayerHeal int
|
||
EnemySkip bool
|
||
Desc string
|
||
|
||
// GrimHarvestSlot / GrimHarvestNecrotic are the Necromancy Mage's kill-heal
|
||
// stash for this cast, zero for everyone else. Unlike the fields above they
|
||
// outlive the casting round: the caller parks them on the seat's
|
||
// ActorStatuses and the close-out decides whether the cast was lethal.
|
||
GrimHarvestSlot int
|
||
GrimHarvestNecrotic bool
|
||
}
|
||
|
||
// resolveTurnSpell resolves a spell as a turn-based player action, reusing the
|
||
// auto-resolve spell math against a throwaway modifier set. supported is false
|
||
// for buff and utility spells: those mutate stats for the rest of the fight,
|
||
// which the turn engine can't carry yet (it rebuilds combatants each round) —
|
||
// the caller should refuse them without spending the slot. Reaction spells are
|
||
// likewise out of scope here and should be filtered by the caller.
|
||
func resolveTurnSpell(c *DnDCharacter, spell SpellDefinition, slotLevel int, enemy *CombatStats) (out turnSpellOutcome, supported bool) {
|
||
switch spell.Effect {
|
||
case EffectSpellHeal:
|
||
out.PlayerHeal = rollTurnSpellHeal(c, spell, slotLevel)
|
||
out.Desc = fmt.Sprintf("%s — +%d HP", spell.Name, out.PlayerHeal)
|
||
return out, true
|
||
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto, EffectControl:
|
||
var mods CombatModifiers
|
||
dc := spellSaveDC(c)
|
||
atk := spellAttackBonus(c)
|
||
switch spell.Effect {
|
||
case EffectDamageAttack:
|
||
applySpellDamageAttack(spell, atk, &mods, enemy, slotLevel, c.Level)
|
||
case EffectDamageSave:
|
||
applySpellDamageSave(spell, dc, c, &mods, enemy, slotLevel)
|
||
case EffectDamageAuto:
|
||
applySpellDamageAuto(spell, &mods, slotLevel, c.Level)
|
||
case EffectControl:
|
||
applySpellControl(spell, dc, &mods, enemy, slotLevel)
|
||
}
|
||
if mods.SpellPreDamage > 0 {
|
||
applyMageSubclassSpellHooks(c, spell, slotLevel, &mods)
|
||
}
|
||
out.EnemyDamage = mods.SpellPreDamage
|
||
out.EnemySkip = mods.SpellEnemySkipFirst
|
||
out.Desc = mods.SpellPreDamageDesc
|
||
out.GrimHarvestSlot = mods.GrimHarvestSlot
|
||
out.GrimHarvestNecrotic = mods.GrimHarvestNecrotic
|
||
if out.Desc == "" {
|
||
out.Desc = spell.Name
|
||
}
|
||
return out, true
|
||
default:
|
||
// EffectBuffSelf / EffectBuffAlly / EffectUtility — deferred.
|
||
return out, false
|
||
}
|
||
}
|
||
|
||
// rollTurnSpellHeal rolls an in-combat healing cast. Mirrors
|
||
// resolveHealOutOfCombat's formula but returns the amount instead of writing
|
||
// to the character sheet — in a turn-based fight, HP lives on the session row.
|
||
func rollTurnSpellHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||
if dice == 0 {
|
||
dice, faces = 1, 8
|
||
}
|
||
extra := slotLevel - spell.Level
|
||
if extra < 0 {
|
||
extra = 0
|
||
}
|
||
totalDice := dice + extra
|
||
supreme := lifeDomainSupremeHealing(c)
|
||
heal := 0
|
||
for i := 0; i < totalDice; i++ {
|
||
if supreme {
|
||
heal += faces
|
||
} else {
|
||
heal += 1 + rand.IntN(faces)
|
||
}
|
||
}
|
||
heal += abilityModifier(c.WIS)
|
||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||
if heal < 1 {
|
||
heal = 1
|
||
}
|
||
return heal
|
||
}
|
||
|
||
// rollSpellDamageDice rolls a spell's damage dice with cantrip and upcast
|
||
// scaling. Returns at least 1 if the spell has any dice; 0 if no dice are
|
||
// listed (utility/buff spells).
|
||
func rollSpellDamageDice(spell SpellDefinition, slot, charLevel int) int {
|
||
dice, faces, flat := parseDamageDice(spell.DamageDice)
|
||
if dice == 0 || faces == 0 {
|
||
return 0
|
||
}
|
||
if spell.Level == 0 {
|
||
// Cantrip scaling: 5e tiered at L5, L11, L17.
|
||
switch {
|
||
case charLevel >= 17:
|
||
dice *= 4
|
||
case charLevel >= 11:
|
||
dice *= 3
|
||
case charLevel >= 5:
|
||
dice *= 2
|
||
}
|
||
} else if extra := slot - spell.Level; extra > 0 {
|
||
// Upcast: +1 die per slot above base. Approximation for the common
|
||
// "+1d6 per slot above 3rd" pattern; close enough for engine balance.
|
||
dice += extra
|
||
}
|
||
total := flat
|
||
for i := 0; i < dice; i++ {
|
||
total += 1 + rand.IntN(faces)
|
||
}
|
||
if total < 1 {
|
||
total = 1
|
||
}
|
||
return total
|
||
}
|
||
|
||
// ── Phase 13 — turn-based buff resolution ────────────────────────────────────
|
||
|
||
// turnBuffDelta is the marginal effect of one buff spell or consumable applied
|
||
// as a turn-based player action, diffed against the player's already-buffed
|
||
// combatant. Stat components (dAC, dDmgBonus, …) accumulate as session deltas;
|
||
// depleting resources (ward, spore, …) add to their running counters; heal and
|
||
// enemySkip land within the casting round only.
|
||
type turnBuffDelta struct {
|
||
dAC, dAtk, dSpeed, dPetDmg int
|
||
dCrit, dDmgBonus, dPetProc float64
|
||
dReductMul float64 // multiplicative; 1 = no change
|
||
ward, spore, arcaneWard int
|
||
reflect float64
|
||
autoCrit, enemySkip bool
|
||
heal int // a MaxHP-raise (Aid) collapses to an immediate heal
|
||
dSpiritProc float64
|
||
dSpiritDmg int
|
||
}
|
||
|
||
// statComponent reports whether the buff has a re-applicable persistent stat
|
||
// effect — the part applySessionBuffs folds back onto the player every round.
|
||
func (d turnBuffDelta) statComponent() bool {
|
||
return d.dAC != 0 || d.dAtk != 0 || d.dSpeed != 0 || d.dPetDmg != 0 ||
|
||
d.dCrit != 0 || d.dDmgBonus != 0 || d.dPetProc != 0 ||
|
||
d.dSpiritProc != 0 || d.dSpiritDmg != 0 ||
|
||
(d.dReductMul > 0 && d.dReductMul != 1)
|
||
}
|
||
|
||
// any reports whether the buff produced any applicable effect at all. A buff
|
||
// the turn engine can't represent yet (pure utility) diffs to nothing, and the
|
||
// caller refuses it before a slot or item is spent.
|
||
func (d turnBuffDelta) any() bool {
|
||
return d.statComponent() || d.ward != 0 || d.spore != 0 || d.reflect != 0 ||
|
||
d.autoCrit || d.arcaneWard != 0 || d.enemySkip || d.heal != 0
|
||
}
|
||
|
||
// diffTurnBuff computes the marginal effect of a buff: (bs,bm) is the player's
|
||
// state before the buff, (as,am) the throwaway result of applying it. Reusing
|
||
// applySpellBuff / ApplyConsumableMods against a copy keeps turn-based buffs
|
||
// numerically identical to the auto-resolve engine.
|
||
func diffTurnBuff(bs, as CombatStats, bm, am CombatModifiers) turnBuffDelta {
|
||
d := turnBuffDelta{
|
||
dAC: as.AC - bs.AC,
|
||
dAtk: as.AttackBonus - bs.AttackBonus,
|
||
dSpeed: as.Speed - bs.Speed,
|
||
dPetDmg: am.PetAttackDmg - bm.PetAttackDmg,
|
||
dCrit: as.CritRate - bs.CritRate,
|
||
dDmgBonus: am.DamageBonus - bm.DamageBonus,
|
||
dPetProc: am.PetAttackProc - bm.PetAttackProc,
|
||
ward: am.WardCharges - bm.WardCharges,
|
||
spore: am.SporeCloud - bm.SporeCloud,
|
||
reflect: am.ReflectNext - bm.ReflectNext,
|
||
arcaneWard: am.ArcaneWardHP - bm.ArcaneWardHP,
|
||
autoCrit: am.AutoCritFirst && !bm.AutoCritFirst,
|
||
enemySkip: am.SpellEnemySkipFirst && !bm.SpellEnemySkipFirst,
|
||
heal: as.MaxHP - bs.MaxHP,
|
||
dSpiritProc: am.SpiritWeaponProc - bm.SpiritWeaponProc,
|
||
dSpiritDmg: am.SpiritWeaponDmg - bm.SpiritWeaponDmg,
|
||
}
|
||
if bm.DamageReduct > 0 {
|
||
d.dReductMul = am.DamageReduct / bm.DamageReduct
|
||
} else {
|
||
d.dReductMul = 1
|
||
}
|
||
return d
|
||
}
|