Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multischool fix/refactor #449

Merged
merged 10 commits into from
Mar 27, 2024
6 changes: 3 additions & 3 deletions sim/core/debuffs.go
Original file line number Diff line number Diff line change
Expand Up @@ -588,11 +588,11 @@ func WintersChillAura(target *Unit, startingStacks int32) *Aura {
aura.SetStacks(sim, startingStacks)
},
OnStacksChange: func(aura *Aura, sim *Simulation, oldStacks, newStacks int32) {
aura.Unit.PseudoStats.SchoolCritTakenMultiplier[stats.SchoolIndexFrost] /= 1 + 0.2*float64(oldStacks)
aura.Unit.PseudoStats.SchoolCritTakenMultiplier[stats.SchoolIndexFrost] *= 1 + 0.2*float64(newStacks)
aura.Unit.PseudoStats.SchoolCritTakenChance[stats.SchoolIndexFrost] -= 0.02 * float64(oldStacks)
aura.Unit.PseudoStats.SchoolCritTakenChance[stats.SchoolIndexFrost] += 0.02 * float64(newStacks)
},
OnExpire: func(aura *Aura, sim *Simulation) {
aura.Unit.PseudoStats.SchoolCritTakenMultiplier[stats.SchoolIndexFrost] /= 1 + 0.2*float64(aura.stacks)
aura.Unit.PseudoStats.SchoolCritTakenChance[stats.SchoolIndexFrost] -= 0.02 * float64(aura.stacks)
},
})

Expand Down
13 changes: 7 additions & 6 deletions sim/core/spell.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,10 @@ type Spell struct {
// The unit who will perform this spell.
Unit *Unit

SpellSchool SpellSchool // Schoolmask of all schools this spell uses. Use Spell.SetSchool() to change this!
SpellSchool SpellSchool // Schoolmask of all schools this spell uses. Do not change this! Whatever you try to do is a hack and probably wrong.
SchoolIndex stats.SchoolIndex // Do not change this! Whatever you try to do is a hack and probably wrong.
SchoolBaseIndices []stats.SchoolIndex // Base school indices for multi schools. Do not change this! Whatever you try to do is a hack and probably wrong.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice comment 😂

DefenseType DefenseType
SchoolIndex stats.SchoolIndex // Use Spell.SetSchool() to change this!
SchoolBaseIndices []stats.SchoolIndex // Base school indices for multi schools. Use Spell.SetSchool() to change this!
IsMultischool bool // True if school is composed of multiple base schools. Use Spell.SetSchool() to change this!

// Controls which effects can proc from this spell.
ProcMask ProcMask
Expand Down Expand Up @@ -222,6 +221,10 @@ func (unit *Unit) RegisterSpell(config SpellConfig) *Spell {
CastType: config.CastType,
MissileSpeed: config.MissileSpeed,

SpellSchool: config.SpellSchool,
SchoolIndex: config.SpellSchool.GetSchoolIndex(),
SchoolBaseIndices: config.SpellSchool.GetBaseIndices(),

DefaultCast: config.Cast.DefaultCast,
CD: config.Cast.CD,
SharedCD: config.Cast.SharedCD,
Expand Down Expand Up @@ -255,8 +258,6 @@ func (unit *Unit) RegisterSpell(config SpellConfig) *Spell {
RelatedAuras: config.RelatedAuras,
}

spell.SetSchool(config.SpellSchool.GetSchoolIndex())

spell.CdSpell = spell

// newXXXCost() all update spell.DefaultCast.Cost
Expand Down
106 changes: 61 additions & 45 deletions sim/core/spell_resistances.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ func (spell *Spell) ResistanceMultiplier(sim *Simulation, isPeriodic bool, attac
}

if spell.SpellSchool.Matches(SpellSchoolPhysical) {
// All physical dots (Bleeds) ignore armor.
if isPeriodic && !spell.Flags.Matches(SpellFlagApplyArmorReduction) {
return 1, OutcomeEmpty
}
if spell.SchoolIndex == stats.SchoolIndexPhysical || MultiSchoolShouldUseArmor(spell, attackTable.Defender) {
// All physical dots (Bleeds) ignore armor.
if isPeriodic && !spell.Flags.Matches(SpellFlagApplyArmorReduction) {
return 1, OutcomeEmpty
}

if spell.SchoolIndex == stats.SchoolIndexPhysical || MultiSchoolShouldUseArmor(spell.SchoolIndex, attackTable.Defender) {
// Physical resistance (armor).
return attackTable.GetArmorDamageModifier(spell), OutcomeEmpty
}
Expand All @@ -39,7 +39,7 @@ func (spell *Spell) ResistanceMultiplier(sim *Simulation, isPeriodic bool, attac

resistanceRoll := sim.RandomFloat("Partial Resist")

threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell.SchoolIndex, spell.Flags.Matches(SpellFlagPureDot))
threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell, spell.Flags.Matches(SpellFlagPureDot))
//if sim.Log != nil {
// sim.Log("Resist thresholds: %0.04f, %0.04f, %0.04f", threshold00, threshold25, threshold50)
//}
Expand All @@ -56,22 +56,29 @@ func (spell *Spell) ResistanceMultiplier(sim *Simulation, isPeriodic bool, attac
}
}

// Decide whether to use armor for physical multi school spells
// Decide whether to use armor for physical multi school spells.
//
// TODO: This is most likely not accurate for the case: armor near resistance but not 0
//
// TODO: This is most likely not accurate. A short test showed that it seems
// to not simply use armor if it's lower, but the breakpoint appeared to be pretty
// close to the resistance value.
func MultiSchoolShouldUseArmor(schoolIndex stats.SchoolIndex, target *Unit) bool {
// A short test showed that the game uses armor if it's far enough below resistance,
// but not simply if it's lower.
// 49 (and above) armor vs 57 res => used resistance
// 7 (and below) armor vs 57 res => used armor/no partials anymore
// If level based resist is used in this decission process is also not known as it was tested PvP.
//
// For most purposes this should work fine for now, but should be properly tested and fixed if
// spells using it become important and boss armor can actually go below (level based) resistance values.
func MultiSchoolShouldUseArmor(spell *Spell, target *Unit) bool {
resistance := 100000.0
lowestStat := stats.Armor
for _, resiStat := range GetSchoolResistanceStats(schoolIndex) {
resiVal := target.GetStat(resiStat)
lowestIsArmor := true
for _, baseSchoolIndex := range spell.SchoolBaseIndices {
resiVal := target.GetResistanceForSchool(baseSchoolIndex)
if resiVal < resistance {
resistance = resiVal
lowestStat = resiStat
lowestIsArmor = baseSchoolIndex == stats.SchoolIndexPhysical
}
}
return lowestStat == stats.Armor
return lowestIsArmor
}

func (at *AttackTable) GetArmorDamageModifier(spell *Spell) float64 {
Expand All @@ -80,50 +87,59 @@ func (at *AttackTable) GetArmorDamageModifier(spell *Spell) float64 {
return 1 - defenderArmor/(defenderArmor+400+85*float64(at.Attacker.Level))
}

func (at *AttackTable) GetPartialResistThresholds(si stats.SchoolIndex, pureDot bool) (float64, float64, float64) {
return at.Defender.partialResistRollThresholds(si, at.Attacker, pureDot)
func (at *AttackTable) GetPartialResistThresholds(spell *Spell, pureDot bool) (float64, float64, float64) {
return at.Defender.partialResistRollThresholds(spell, at.Attacker, pureDot)
}

func (at *AttackTable) GetBinaryHitChance(si stats.SchoolIndex) float64 {
return at.Defender.binaryHitChance(si, at.Attacker)
func (at *AttackTable) GetBinaryHitChance(spell *Spell) float64 {
return at.Defender.binaryHitChance(spell, at.Attacker)
}

// All of the following calculations are based on this guide:
// https://royalgiraffe.github.io/resist-guide

func (unit *Unit) resistCoeff(schoolIndex stats.SchoolIndex, attacker *Unit, binary bool, pureDot bool) float64 {
var resistance float64

// Only for base schools!
func (unit *Unit) GetResistanceForSchool(schoolIndex stats.SchoolIndex) float64 {
switch schoolIndex {
case stats.SchoolIndexNone:
return 0
case stats.SchoolIndexPhysical:
return 0
return unit.GetStat(stats.Armor)
case stats.SchoolIndexArcane:
resistance = unit.GetStat(stats.ArcaneResistance)
return unit.GetStat(stats.ArcaneResistance)
case stats.SchoolIndexFire:
resistance = unit.GetStat(stats.FireResistance)
return unit.GetStat(stats.FireResistance)
case stats.SchoolIndexFrost:
resistance = unit.GetStat(stats.FrostResistance)
return unit.GetStat(stats.FrostResistance)
case stats.SchoolIndexHoly:
resistance = 0 // Holy resistance doesn't exist.
return 0 // Holy resistance doesn't exist.
case stats.SchoolIndexNature:
resistance = unit.GetStat(stats.NatureResistance)
return unit.GetStat(stats.NatureResistance)
case stats.SchoolIndexShadow:
resistance = unit.GetStat(stats.ShadowResistance)
return unit.GetStat(stats.ShadowResistance)
default:
return 0
}
}

// All of the following calculations are based on this guide:
// https://royalgiraffe.github.io/resist-guide

func (unit *Unit) resistCoeff(spell *Spell, attacker *Unit, binary bool, pureDot bool) float64 {
if spell.SchoolIndex <= stats.SchoolIndexPhysical {
return 0
}

var resistance float64

if spell.SchoolIndex.IsMultiSchool() {
// Multi school: Choose lowest resistance available.
resistance = 1000.0
if !SpellSchoolFromIndex(schoolIndex).Matches(SpellSchoolHoly) {
for _, resiStat := range GetSchoolResistanceStats(schoolIndex) {
resiVal := unit.GetStat(resiStat)
if resiVal < resistance {
resistance = resiVal
}
for _, baseSchoolIndex := range spell.SchoolBaseIndices {
resiVal := unit.GetResistanceForSchool(baseSchoolIndex)
if resiVal < resistance {
resistance = resiVal
}
} else {
resistance = 0.0
}
} else {
resistance = unit.GetResistanceForSchool(spell.SchoolIndex)
}

resistance = max(0, resistance-attacker.stats[stats.SpellPenetration])
Expand All @@ -146,14 +162,14 @@ func (unit *Unit) resistCoeff(schoolIndex stats.SchoolIndex, attacker *Unit, bin
return min(1, resistanceCoef)
}

func (unit *Unit) binaryHitChance(schoolIndex stats.SchoolIndex, attacker *Unit) float64 {
resistCoeff := unit.resistCoeff(schoolIndex, attacker, true, false)
func (unit *Unit) binaryHitChance(spell *Spell, attacker *Unit) float64 {
resistCoeff := unit.resistCoeff(spell, attacker, true, false)
return 1 - 0.75*resistCoeff
}

// Roll threshold for each type of partial resist.
func (unit *Unit) partialResistRollThresholds(schoolIndex stats.SchoolIndex, attacker *Unit, pureDot bool) (float64, float64, float64) {
resistCoeff := unit.resistCoeff(schoolIndex, attacker, false, pureDot)
func (unit *Unit) partialResistRollThresholds(spell *Spell, attacker *Unit, pureDot bool) (float64, float64, float64) {
resistCoeff := unit.resistCoeff(spell, attacker, false, pureDot)

// Based on the piecewise linear regression estimates at https://royalgiraffe.github.io/partial-resist-table.
//partialResistChance00 := piecewiseLinear3(resistCoeff, 1, 0.24, 0.00, 0.00)
Expand Down
31 changes: 19 additions & 12 deletions sim/core/spell_resistances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func Test_PartialResistsVsPlayer(t *testing.T) {
for resist := 0; resist < 5_000; resist += 1 {
defender.stats[stats.FireResistance] = float64(resist)

threshold00, threshold25, threshold50 := attackTable.Defender.partialResistRollThresholds(stats.SchoolIndexFire, attackTable.Attacker, false)
threshold00, threshold25, threshold50 := attackTable.Defender.partialResistRollThresholds(spell, attackTable.Attacker, false)
thresholds := [4]float64{threshold00, threshold25, threshold50, 0.0}

var cumulativeChance float64
Expand All @@ -49,7 +49,7 @@ func Test_PartialResistsVsPlayer(t *testing.T) {
}
}

resistanceScore := attackTable.Defender.resistCoeff(stats.SchoolIndexFire, attackTable.Attacker, false, false)
resistanceScore := attackTable.Defender.resistCoeff(spell, attackTable.Attacker, false, false)
expectedAr := 0.75*resistanceScore - 3.0/16.0*max(0.0, resistanceScore-2.0/3.0)

if math.Abs(resultingAr-expectedAr) > 1e-2 {
Expand Down Expand Up @@ -106,8 +106,12 @@ func ResistanceCheck(t *testing.T, isDoT bool) {

attackTable := NewAttackTable(attacker, defender, nil)

spell := &Spell{}
spell.SetSchool(stats.SchoolIndexNature)
schoolMask := SpellSchoolFromIndex(stats.SchoolIndexNature)
spell := &Spell{
SpellSchool: schoolMask,
SchoolIndex: schoolMask.GetSchoolIndex(),
SchoolBaseIndices: schoolMask.GetBaseIndices(),
}

if isDoT {
spell.Flags |= SpellFlagPureDot
Expand All @@ -117,7 +121,7 @@ func ResistanceCheck(t *testing.T, isDoT bool) {

// Check if coef is 0.08 (from +3 level based resist) at 0 res
defender.stats[stats.NatureResistance] = 0
coef := defender.resistCoeff(spell.SchoolIndex, attacker, false, isDoT)
coef := defender.resistCoeff(spell, attacker, false, isDoT)
if coef != 0.08 {
t.Errorf("Resist coef is %.3f at 0 resistance, but should be 0.08!", coef)
return
Expand All @@ -131,7 +135,7 @@ func ResistanceCheck(t *testing.T, isDoT bool) {
expectedMitigation = 0.11
expectedChances = []float64{0.67, 0.24, 0.08, 0.01}
}
threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell.SchoolIndex, spell.Flags.Matches(SpellFlagPureDot))
threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell, spell.Flags.Matches(SpellFlagPureDot))
avgResist, chance0, chance25, chance50, chance75 := GetChancesAndMitFromThresholds(threshold00, threshold25, threshold50)
if !CloseEnough(avgResist, expectedMitigation, 0.01) {
t.Errorf("Avg mitigation %.3f at 200 resistance, but should be %.3f!", avgResist, expectedMitigation)
Expand Down Expand Up @@ -161,14 +165,14 @@ func ResistanceCheck(t *testing.T, isDoT bool) {
expectedAvgMitigation := expectedCoef*0.75 - 3.0/16.0*max(0, expectedCoef-2.0/3.0)

// Check if coef is correct to begin with
resistCoef := defender.resistCoeff(spell.SchoolIndex, attacker, false, isDoT)
resistCoef := defender.resistCoeff(spell, attacker, false, isDoT)
if math.Abs(resistCoef-expectedCoef) > 0.001 {
t.Errorf("Resist coef is %.3f but expected %.3f at resistance %f", resistCoef, expectedCoef, resistanceUsed)
return
}

// Check breakpoints
threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell.SchoolIndex, spell.Flags.Matches(SpellFlagPureDot))
threshold00, threshold25, threshold50 := attackTable.GetPartialResistThresholds(spell, spell.Flags.Matches(SpellFlagPureDot))
avgResist, _, _, _, _ := GetChancesAndMitFromThresholds(threshold00, threshold25, threshold50)
if math.Abs(avgResist-expectedAvgMitigation) > 0.005 {
t.Errorf("resist = %.2f, thresholds = %f, resultingAr = %.2f%%, expectedAr = %.2f%%", resistanceUsed, threshold00, avgResist, expectedAvgMitigation)
Expand Down Expand Up @@ -196,14 +200,17 @@ func Test_ResistBinary(t *testing.T) {

attackTable := NewAttackTable(attacker, defender, nil)

schoolMask := SpellSchoolFromIndex(stats.SchoolIndexNature)
spell := &Spell{
Flags: SpellFlagBinary,
Flags: SpellFlagBinary,
SpellSchool: schoolMask,
SchoolIndex: schoolMask.GetSchoolIndex(),
SchoolBaseIndices: schoolMask.GetBaseIndices(),
}
spell.SetSchool(stats.SchoolIndexNature)

// Check if coef is 0.0 at 0 resistance, binary spells do not get level based resistance!
defender.stats[stats.NatureResistance] = 0
coef := defender.resistCoeff(spell.SchoolIndex, attacker, true, false)
coef := defender.resistCoeff(spell, attacker, true, false)
if coef != 0.0 {
t.Errorf("Resist coef is %.3f at 0 resistance for binary spell, but should be 0.0!", coef)
return
Expand All @@ -227,7 +234,7 @@ func Test_ResistBinary(t *testing.T) {
resistance := test[0]
defender.stats[stats.NatureResistance] = resistance
expectedResult := test[1]
result := attackTable.GetBinaryHitChance(spell.SchoolIndex)
result := attackTable.GetBinaryHitChance(spell)
if !CloseEnough(result, expectedResult, 0.000001) {
t.Errorf("Binary hit chance result at %.0f resistance was %.3f, expected %.3f!", resistance, result, expectedResult)
return
Expand Down
13 changes: 6 additions & 7 deletions sim/core/spell_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,7 @@ func (spell *Spell) PhysicalHitChance(attackTable *AttackTable) float64 {

func (spell *Spell) PhysicalCritChance(attackTable *AttackTable) float64 {
critRating := spell.Unit.stats[stats.MeleeCrit] +
spell.BonusCritRating +
attackTable.Defender.PseudoStats.BonusCritRatingTaken
spell.BonusCritRating
return critRating/(CritRatingPerCritChance*100) - attackTable.MeleeCritSuppression
}
func (spell *Spell) PhysicalCritCheck(sim *Simulation, attackTable *AttackTable) bool {
Expand Down Expand Up @@ -178,7 +177,7 @@ func (spell *Spell) SpellChanceToMiss(attackTable *AttackTable) float64 {
missChance := 0.01

if spell.Flags.Matches(SpellFlagBinary) {
baseHitChance := (1 - attackTable.BaseSpellMissChance) * attackTable.GetBinaryHitChance(spell.SchoolIndex)
baseHitChance := (1 - attackTable.BaseSpellMissChance) * attackTable.GetBinaryHitChance(spell)
missChance = 1 - baseHitChance - spell.SpellHitChance(attackTable.Defender)
} else {
missChance = attackTable.BaseSpellMissChance - spell.SpellHitChance(attackTable.Defender)
Expand All @@ -193,13 +192,13 @@ func (spell *Spell) MagicHitCheck(sim *Simulation, attackTable *AttackTable) boo

func (spell *Spell) spellCritRating(target *Unit) float64 {
return spell.Unit.stats[stats.SpellCrit] +
spell.BonusCritRating +
target.PseudoStats.BonusCritRatingTaken +
target.PseudoStats.BonusSpellCritRatingTaken
spell.BonusCritRating
}
func (spell *Spell) SpellCritChance(target *Unit) float64 {
// TODO: Classic verify crit suppression
return spell.spellCritRating(target) / (SpellCritRatingPerCritChance * 100) // - spell.Unit.AttackTables[target.UnitIndex][spell.CastType].SpellCritSuppression
return spell.spellCritRating(target)/(SpellCritRatingPerCritChance*100) +
target.GetSchoolCritTakenChance(spell)
// - spell.Unit.AttackTables[target.UnitIndex][spell.CastType].SpellCritSuppression
}
func (spell *Spell) MagicCritCheck(sim *Simulation, target *Unit) bool {
critChance := spell.SpellCritChance(target)
Expand Down
Loading
Loading