diff --git a/sim/druid/balance/balance.go b/sim/druid/balance/balance.go index e4238e2d6..fb09442d5 100644 --- a/sim/druid/balance/balance.go +++ b/sim/druid/balance/balance.go @@ -1,8 +1,6 @@ package balance import ( - "time" - "github.com/wowsims/tbc/sim/common" "github.com/wowsims/tbc/sim/core" "github.com/wowsims/tbc/sim/core/proto" @@ -65,143 +63,9 @@ func (moonkin *BalanceDruid) GetDruid() *druid.Druid { return moonkin.Druid } -func (moonkin *BalanceDruid) GetPresimOptions() *core.PresimOptions { - // If not adaptive, just use the primary rotation directly. - if moonkin.primaryRotation.PrimarySpell != proto.BalanceDruid_Rotation_Adaptive { - return nil - } - - rotations := moonkin.GetDpsRotationHierarchy(moonkin.primaryRotation) - rotationIdx := 0 - - return &core.PresimOptions{ - SetPresimPlayerOptions: func(player *proto.Player) { - *player.Spec.(*proto.Player_BalanceDruid).BalanceDruid.Rotation = rotations[rotationIdx] - }, - - OnPresimResult: func(presimResult proto.UnitMetrics, iterations int32, duration time.Duration) bool { - if float64(presimResult.SecondsOomAvg) <= 0.03*duration.Seconds() { - moonkin.primaryRotation = rotations[rotationIdx] - - // If the highest dps rotation is fine, we dont need any adaptive logic. - if rotationIdx == 0 { - return true - } - - moonkin.useSurplusRotation = true - moonkin.surplusRotation = rotations[rotationIdx-1] - moonkin.manaTracker = common.NewManaSpendingRateTracker() - - return true - } - - rotationIdx++ - if rotationIdx == len(rotations) { - // If we are here than all of the rotations went oom. No adaptive logic needed, just use the lowest one. - moonkin.primaryRotation = rotations[len(rotations)-1] - return true - } - - return false - }, - } -} - func (moonkin *BalanceDruid) Reset(sim *core.Simulation) { if moonkin.useSurplusRotation { moonkin.manaTracker.Reset() } moonkin.Druid.Reset(sim) } - -func (moonkin *BalanceDruid) OnGCDReady(sim *core.Simulation) { - moonkin.tryUseGCD(sim) -} - -func (moonkin *BalanceDruid) OnManaTick(sim *core.Simulation) { - if moonkin.FinishedWaitingForManaAndGCDReady(sim) { - moonkin.tryUseGCD(sim) - } -} - -func (moonkin *BalanceDruid) tryUseGCD(sim *core.Simulation) { - if moonkin.useSurplusRotation { - moonkin.manaTracker.Update(sim, moonkin.GetCharacter()) - - // If we have enough mana to burn, use the surplus rotation. - if moonkin.manaTracker.ProjectedManaSurplus(sim, moonkin.GetCharacter()) { - moonkin.actRotation(sim, moonkin.surplusRotation) - } else { - moonkin.actRotation(sim, moonkin.primaryRotation) - } - } else { - moonkin.actRotation(sim, moonkin.primaryRotation) - } -} - -func (moonkin *BalanceDruid) actRotation(sim *core.Simulation, rotation proto.BalanceDruid_Rotation) { - // Activate shared druid behaviors - // Use Rebirth at the beginning of the fight if flagged in rotation settings - // Potentially allow options for "Time of cast" in future or default cast like 1 min into fight - // Currently just casts at the beginning of encounter (with all CDs popped) - if moonkin.useBattleRes && moonkin.TryRebirth(sim) { - return - } - - target := sim.GetPrimaryTarget() - - var spell *core.Spell - - if moonkin.ShouldCastFaerieFire(sim, target, rotation) { - spell = moonkin.FaerieFire - } else if moonkin.ShouldCastHurricane(sim, rotation) { - spell = moonkin.Hurricane - } else if moonkin.ShouldCastInsectSwarm(sim, target, rotation) { - spell = moonkin.InsectSwarm - } else if moonkin.ShouldCastMoonfire(sim, target, rotation) { - spell = moonkin.Moonfire - } else { - switch rotation.PrimarySpell { - case proto.BalanceDruid_Rotation_Starfire: - spell = moonkin.Starfire8 - case proto.BalanceDruid_Rotation_Starfire6: - spell = moonkin.Starfire6 - case proto.BalanceDruid_Rotation_Wrath: - spell = moonkin.Wrath - } - } - - if success := spell.Cast(sim, target); !success { - moonkin.WaitForMana(sim, spell.CurCast.Cost) - } -} - -// Returns the order of DPS rotations to try, from highest to lowest dps. The -// lower DPS rotations are more mana efficient. -// -// Rotation tiers, from highest dps to lowest: -// - SF8 + MF -// - SF6 + MF -// - SF6, or SF6 + IS if 4p T5 is worn. -func (moonkin *BalanceDruid) GetDpsRotationHierarchy(baseRotation proto.BalanceDruid_Rotation) []proto.BalanceDruid_Rotation { - rotations := []proto.BalanceDruid_Rotation{} - - currentRotation := baseRotation - currentRotation.PrimarySpell = proto.BalanceDruid_Rotation_Starfire - currentRotation.Moonfire = true - rotations = append(rotations, currentRotation) - - currentRotation.PrimarySpell = proto.BalanceDruid_Rotation_Starfire6 - rotations = append(rotations, currentRotation) - - if druid.ItemSetNordrassil.CharacterHasSetBonus(&moonkin.Character, 4) { - currentRotation.Moonfire = false - currentRotation.InsectSwarm = true - rotations = append(rotations, currentRotation) - } else { - currentRotation.Moonfire = false - rotations = append(rotations, currentRotation) - } - - return rotations -} diff --git a/sim/druid/balance/rotation.go b/sim/druid/balance/rotation.go new file mode 100644 index 000000000..5957d43eb --- /dev/null +++ b/sim/druid/balance/rotation.go @@ -0,0 +1,144 @@ +package balance + +import ( + "time" + + "github.com/wowsims/tbc/sim/common" + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" + "github.com/wowsims/tbc/sim/druid" +) + +func (moonkin *BalanceDruid) OnGCDReady(sim *core.Simulation) { + moonkin.tryUseGCD(sim) +} + +func (moonkin *BalanceDruid) OnManaTick(sim *core.Simulation) { + if moonkin.FinishedWaitingForManaAndGCDReady(sim) { + moonkin.tryUseGCD(sim) + } +} + +func (moonkin *BalanceDruid) tryUseGCD(sim *core.Simulation) { + if moonkin.useSurplusRotation { + moonkin.manaTracker.Update(sim, moonkin.GetCharacter()) + + // If we have enough mana to burn, use the surplus rotation. + if moonkin.manaTracker.ProjectedManaSurplus(sim, moonkin.GetCharacter()) { + moonkin.actRotation(sim, moonkin.surplusRotation) + } else { + moonkin.actRotation(sim, moonkin.primaryRotation) + } + } else { + moonkin.actRotation(sim, moonkin.primaryRotation) + } +} + +func (moonkin *BalanceDruid) actRotation(sim *core.Simulation, rotation proto.BalanceDruid_Rotation) { + // Activate shared druid behaviors + // Use Rebirth at the beginning of the fight if flagged in rotation settings + // Potentially allow options for "Time of cast" in future or default cast like 1 min into fight + // Currently just casts at the beginning of encounter (with all CDs popped) + if moonkin.useBattleRes && moonkin.TryRebirth(sim) { + return + } + + target := sim.GetPrimaryTarget() + + var spell *core.Spell + + if moonkin.ShouldCastFaerieFire(sim, target, rotation) { + spell = moonkin.FaerieFire + } else if moonkin.ShouldCastHurricane(sim, rotation) { + spell = moonkin.Hurricane + } else if moonkin.ShouldCastInsectSwarm(sim, target, rotation) { + spell = moonkin.InsectSwarm + } else if moonkin.ShouldCastMoonfire(sim, target, rotation) { + spell = moonkin.Moonfire + } else { + switch rotation.PrimarySpell { + case proto.BalanceDruid_Rotation_Starfire: + spell = moonkin.Starfire8 + case proto.BalanceDruid_Rotation_Starfire6: + spell = moonkin.Starfire6 + case proto.BalanceDruid_Rotation_Wrath: + spell = moonkin.Wrath + } + } + + if success := spell.Cast(sim, target); !success { + moonkin.WaitForMana(sim, spell.CurCast.Cost) + } +} + +// Returns the order of DPS rotations to try, from highest to lowest dps. The +// lower DPS rotations are more mana efficient. +// +// Rotation tiers, from highest dps to lowest: +// - SF8 + MF +// - SF6 + MF +// - SF6, or SF6 + IS if 4p T5 is worn. +func (moonkin *BalanceDruid) GetDpsRotationHierarchy(baseRotation proto.BalanceDruid_Rotation) []proto.BalanceDruid_Rotation { + rotations := []proto.BalanceDruid_Rotation{} + + currentRotation := baseRotation + currentRotation.PrimarySpell = proto.BalanceDruid_Rotation_Starfire + currentRotation.Moonfire = true + rotations = append(rotations, currentRotation) + + currentRotation.PrimarySpell = proto.BalanceDruid_Rotation_Starfire6 + rotations = append(rotations, currentRotation) + + if druid.ItemSetNordrassil.CharacterHasSetBonus(&moonkin.Character, 4) { + currentRotation.Moonfire = false + currentRotation.InsectSwarm = true + rotations = append(rotations, currentRotation) + } else { + currentRotation.Moonfire = false + rotations = append(rotations, currentRotation) + } + + return rotations +} + +func (moonkin *BalanceDruid) GetPresimOptions() *core.PresimOptions { + // If not adaptive, just use the primary rotation directly. + if moonkin.primaryRotation.PrimarySpell != proto.BalanceDruid_Rotation_Adaptive { + return nil + } + + rotations := moonkin.GetDpsRotationHierarchy(moonkin.primaryRotation) + rotationIdx := 0 + + return &core.PresimOptions{ + SetPresimPlayerOptions: func(player *proto.Player) { + *player.Spec.(*proto.Player_BalanceDruid).BalanceDruid.Rotation = rotations[rotationIdx] + }, + + OnPresimResult: func(presimResult proto.UnitMetrics, iterations int32, duration time.Duration) bool { + if float64(presimResult.SecondsOomAvg) <= 0.03*duration.Seconds() { + moonkin.primaryRotation = rotations[rotationIdx] + + // If the highest dps rotation is fine, we dont need any adaptive logic. + if rotationIdx == 0 { + return true + } + + moonkin.useSurplusRotation = true + moonkin.surplusRotation = rotations[rotationIdx-1] + moonkin.manaTracker = common.NewManaSpendingRateTracker() + + return true + } + + rotationIdx++ + if rotationIdx == len(rotations) { + // If we are here than all of the rotations went oom. No adaptive logic needed, just use the lowest one. + moonkin.primaryRotation = rotations[len(rotations)-1] + return true + } + + return false + }, + } +} diff --git a/sim/paladin/retribution/retribution.go b/sim/paladin/retribution/retribution.go index f9b16652e..b754bae63 100644 --- a/sim/paladin/retribution/retribution.go +++ b/sim/paladin/retribution/retribution.go @@ -104,221 +104,3 @@ func (ret *RetributionPaladin) Reset(sim *core.Simulation) { ret.AutoAttacks.CancelAutoSwing(sim) ret.openerCompleted = false } - -func (ret *RetributionPaladin) OnGCDReady(sim *core.Simulation) { - ret.tryUseGCD(sim) -} - -func (ret *RetributionPaladin) OnManaTick(sim *core.Simulation) { - if ret.FinishedWaitingForManaAndGCDReady(sim) { - ret.tryUseGCD(sim) - } -} - -func (ret *RetributionPaladin) tryUseGCD(sim *core.Simulation) { - if !ret.openerCompleted { - ret.openingRotation(sim) - return - } - ret.mainRotation(sim) -} - -func (ret *RetributionPaladin) openingRotation(sim *core.Simulation) { - target := sim.GetPrimaryTarget() - - // Cast selected judgement to keep on the boss - if ret.JudgementOfWisdom.IsReady(sim) && - ret.judgement != proto.RetributionPaladin_Options_None { - var judge *core.Spell - switch ret.judgement { - case proto.RetributionPaladin_Options_Wisdom: - judge = ret.JudgementOfWisdom - case proto.RetributionPaladin_Options_Crusader: - judge = ret.JudgementOfTheCrusader - } - if judge != nil { - judge.Cast(sim, target) - } - } - - // Cast Seal of Command - if !ret.SealOfCommandAura.IsActive() { - ret.SealOfCommand.Cast(sim, nil) - return - } - - // Cast Seal of Blood and enable attacks to twist - if !ret.SealOfBloodAura.IsActive() { - ret.SealOfBlood.Cast(sim, nil) - ret.AutoAttacks.EnableAutoSwing(sim) - ret.openerCompleted = true - } -} - -func (ret *RetributionPaladin) mainRotation(sim *core.Simulation) { - // Need to check for SoC early - socActive := ret.SealOfCommandAura.IsActive() - - // If mana is low, do the low mana rotation instead - // Don't do the low mana rotation in the middle of a twist - if ret.CurrentMana() <= 1000 && !socActive { - ret.lowManaRotation(sim) - return - } - - // Setup - target := sim.GetPrimaryTarget() - - gcdCD := ret.GCD.TimeToReady(sim) - crusaderStrikeCD := ret.CrusaderStrike.TimeToReady(sim) - nextCrusaderStrikeCD := ret.CrusaderStrike.CD.ReadyAt() - judgementCD := ret.JudgementOfWisdom.TimeToReady(sim) - - sobActive := ret.SealOfBloodAura.IsActive() - - nextSwingAt := ret.AutoAttacks.NextAttackAt() - timeTilNextSwing := nextSwingAt - sim.CurrentTime - weaponSpeed := ret.AutoAttacks.MainhandSwingSpeed() - - spellGCD := ret.SpellGCD() - - inTwistWindow := (sim.CurrentTime >= nextSwingAt-twistWindow) && (sim.CurrentTime < ret.AutoAttacks.NextAttackAt()) - latestTwistStart := nextSwingAt - spellGCD - possibleTwist := timeTilNextSwing > spellGCD+gcdCD - willTwist := possibleTwist && (nextSwingAt+spellGCD <= nextCrusaderStrikeCD+ret.crusaderStrikeDelay) - - // Use Judgement if we will prep Seal of Command - // TO-DO: Add more aggressive judgment logic - // Should judge on crusader strike swings as well if we have enough time to refresh seal - if judgementCD == 0 && sobActive && willTwist { - ret.JudgementOfBlood.Cast(sim, target) - sobActive = false - } - - // Judgement can affect active seals and CDs - nextJudgementCD := ret.JudgementOfWisdom.CD.ReadyAt() - - if gcdCD == 0 { - if socActive && inTwistWindow { - // If Seal of Command is Active, complete the twist - ret.SealOfBlood.Cast(sim, nil) - } else if crusaderStrikeCD == 0 && !willTwist && - (sobActive || spellGCD < timeTilNextSwing) { - // Cast Crusader Strike if we won't swing naked and we aren't twisting - ret.CrusaderStrike.Cast(sim, target) - } else if willTwist && !socActive && (nextJudgementCD > latestTwistStart) { - // Prep seal of command - ret.SealOfCommand.Cast(sim, nil) - } else if !sobActive && !socActive && !willTwist { - // If no seal is active, cast Seal of Blood - ret.SealOfBlood.Cast(sim, nil) - } else if !willTwist && !socActive && - timeTilNextSwing+weaponSpeed > spellGCD*2 && - spellGCD < crusaderStrikeCD { - // If there is literally nothing else to-do, cast fillers - // Only if it won't clip crusader strike or seal twist - ret.useFillers(sim, target) - } - } - - // All possible next events - events := []time.Duration{ - nextSwingAt, - nextSwingAt - twistWindow, - ret.GCD.ReadyAt(), - ret.JudgementOfWisdom.CD.ReadyAt(), - ret.CrusaderStrike.CD.ReadyAt(), - } - - ret.waitUntilNextEvent(sim, events) -} - -// -func (ret *RetributionPaladin) useFillers(sim *core.Simulation, target *core.Target) { - - // If the target is a demon and exorcism is up, cast exorcism - // Only cast exorcism when above 40% mana - if ret.Rotation.UseExorcism && - target.MobType == proto.MobType_MobTypeDemon && - ret.Exorcism.IsReady(sim) && - ret.CurrentMana() > ret.MaxMana()*0.4 { - - ret.Exorcism.Cast(sim, target) - return - } - - // If we can't exorcise, try to consecrate - // Only cast consecration when above 60% mana - if ret.Rotation.ConsecrationRank != proto.RetributionPaladin_Rotation_None && - ret.Consecration.IsReady(sim) && - ret.CurrentMana() > ret.MaxMana()*0.6 { - ret.Consecration.Cast(sim, target) - return - } -} - -// Just roll seal of blood and cast crusader strike on CD to conserve mana -func (ret *RetributionPaladin) lowManaRotation(sim *core.Simulation) { - target := sim.GetPrimaryTarget() - - sobExpiration := ret.SealOfBloodAura.ExpiresAt() - nextSwingAt := ret.AutoAttacks.NextAttackAt() - - manaRegenAt := sim.Duration + 1 - // Roll seal of blood - if sim.CurrentTime+time.Second >= sobExpiration { - sobAndJudgementCost := ret.JudgementOfBlood.DefaultCast.Cost + ret.SealOfBlood.DefaultCast.Cost - if ret.CanJudgementOfBlood(sim) && ret.CurrentMana() >= sobAndJudgementCost { - ret.JudgementOfBlood.Cast(sim, target) - } - if ret.GCD.IsReady(sim) { - if success := ret.SealOfBlood.Cast(sim, target); !success { - // This should only happen in VERY BAD mana situations. - manaRegenAt = ret.TimeUntilManaRegen(ret.SealOfBlood.CurCast.Cost) - } - } - } else if ret.GCD.IsReady(sim) && ret.CrusaderStrike.CD.IsReady(sim) { - spellGCD := ret.SpellGCD() - sobAndCSCost := ret.CrusaderStrike.DefaultCast.Cost + ret.SealOfBlood.DefaultCast.Cost - - if !(spellGCD+sim.CurrentTime > nextSwingAt && sobExpiration < nextSwingAt) && - (ret.CurrentMana() >= sobAndCSCost) { - // Crusader strike unless it will cause seal of blood to drop - // Or we won't have enough mana to reseal - ret.CrusaderStrike.Cast(sim, target) - } - } - - events := []time.Duration{ - ret.GCD.ReadyAt(), - ret.CrusaderStrike.CD.ReadyAt(), - manaRegenAt, - sobExpiration - time.Second, - } - - ret.waitUntilNextEvent(sim, events) -} - -// Helper function for finding the next event -func (ret *RetributionPaladin) waitUntilNextEvent(sim *core.Simulation, events []time.Duration) { - // Find the minimum possible next event that is greater than the current time - nextEventAt := sim.Duration + 1 // setting this to sim.Duration will result in an infinite loop where we keep putting actions and it never advances. - for _, elem := range events { - if elem > sim.CurrentTime && elem < nextEventAt { - nextEventAt = elem - } - } - // If the next action is the GCD, just return - if nextEventAt == ret.GCD.ReadyAt() { - return - } - - // Otherwise add a pending action for the next time - pa := &core.PendingAction{ - Priority: core.ActionPriorityLow, - OnAction: ret.mainRotation, - NextActionAt: nextEventAt, - } - - sim.AddPendingAction(pa) -} diff --git a/sim/paladin/retribution/rotation.go b/sim/paladin/retribution/rotation.go new file mode 100644 index 000000000..01696dc24 --- /dev/null +++ b/sim/paladin/retribution/rotation.go @@ -0,0 +1,226 @@ +package retribution + +import ( + "time" + + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" +) + +func (ret *RetributionPaladin) OnGCDReady(sim *core.Simulation) { + ret.tryUseGCD(sim) +} + +func (ret *RetributionPaladin) OnManaTick(sim *core.Simulation) { + if ret.FinishedWaitingForManaAndGCDReady(sim) { + ret.tryUseGCD(sim) + } +} + +func (ret *RetributionPaladin) tryUseGCD(sim *core.Simulation) { + if !ret.openerCompleted { + ret.openingRotation(sim) + return + } + ret.mainRotation(sim) +} + +func (ret *RetributionPaladin) openingRotation(sim *core.Simulation) { + target := sim.GetPrimaryTarget() + + // Cast selected judgement to keep on the boss + if ret.JudgementOfWisdom.IsReady(sim) && + ret.judgement != proto.RetributionPaladin_Options_None { + var judge *core.Spell + switch ret.judgement { + case proto.RetributionPaladin_Options_Wisdom: + judge = ret.JudgementOfWisdom + case proto.RetributionPaladin_Options_Crusader: + judge = ret.JudgementOfTheCrusader + } + if judge != nil { + judge.Cast(sim, target) + } + } + + // Cast Seal of Command + if !ret.SealOfCommandAura.IsActive() { + ret.SealOfCommand.Cast(sim, nil) + return + } + + // Cast Seal of Blood and enable attacks to twist + if !ret.SealOfBloodAura.IsActive() { + ret.SealOfBlood.Cast(sim, nil) + ret.AutoAttacks.EnableAutoSwing(sim) + ret.openerCompleted = true + } +} + +func (ret *RetributionPaladin) mainRotation(sim *core.Simulation) { + // Need to check for SoC early + socActive := ret.SealOfCommandAura.IsActive() + + // If mana is low, do the low mana rotation instead + // Don't do the low mana rotation in the middle of a twist + if ret.CurrentMana() <= 1000 && !socActive { + ret.lowManaRotation(sim) + return + } + + // Setup + target := sim.GetPrimaryTarget() + + gcdCD := ret.GCD.TimeToReady(sim) + crusaderStrikeCD := ret.CrusaderStrike.TimeToReady(sim) + nextCrusaderStrikeCD := ret.CrusaderStrike.CD.ReadyAt() + judgementCD := ret.JudgementOfWisdom.TimeToReady(sim) + + sobActive := ret.SealOfBloodAura.IsActive() + + nextSwingAt := ret.AutoAttacks.NextAttackAt() + timeTilNextSwing := nextSwingAt - sim.CurrentTime + weaponSpeed := ret.AutoAttacks.MainhandSwingSpeed() + + spellGCD := ret.SpellGCD() + + inTwistWindow := (sim.CurrentTime >= nextSwingAt-twistWindow) && (sim.CurrentTime < ret.AutoAttacks.NextAttackAt()) + latestTwistStart := nextSwingAt - spellGCD + possibleTwist := timeTilNextSwing > spellGCD+gcdCD + willTwist := possibleTwist && (nextSwingAt+spellGCD <= nextCrusaderStrikeCD+ret.crusaderStrikeDelay) + + // Use Judgement if we will prep Seal of Command + // TO-DO: Add more aggressive judgment logic + // Should judge on crusader strike swings as well if we have enough time to refresh seal + if judgementCD == 0 && sobActive && willTwist { + ret.JudgementOfBlood.Cast(sim, target) + sobActive = false + } + + // Judgement can affect active seals and CDs + nextJudgementCD := ret.JudgementOfWisdom.CD.ReadyAt() + + if gcdCD == 0 { + if socActive && inTwistWindow { + // If Seal of Command is Active, complete the twist + ret.SealOfBlood.Cast(sim, nil) + } else if crusaderStrikeCD == 0 && !willTwist && + (sobActive || spellGCD < timeTilNextSwing) { + // Cast Crusader Strike if we won't swing naked and we aren't twisting + ret.CrusaderStrike.Cast(sim, target) + } else if willTwist && !socActive && (nextJudgementCD > latestTwistStart) { + // Prep seal of command + ret.SealOfCommand.Cast(sim, nil) + } else if !sobActive && !socActive && !willTwist { + // If no seal is active, cast Seal of Blood + ret.SealOfBlood.Cast(sim, nil) + } else if !willTwist && !socActive && + timeTilNextSwing+weaponSpeed > spellGCD*2 && + spellGCD < crusaderStrikeCD { + // If there is literally nothing else to-do, cast fillers + // Only if it won't clip crusader strike or seal twist + ret.useFillers(sim, target) + } + } + + // All possible next events + events := []time.Duration{ + nextSwingAt, + nextSwingAt - twistWindow, + ret.GCD.ReadyAt(), + ret.JudgementOfWisdom.CD.ReadyAt(), + ret.CrusaderStrike.CD.ReadyAt(), + } + + ret.waitUntilNextEvent(sim, events) +} + +// +func (ret *RetributionPaladin) useFillers(sim *core.Simulation, target *core.Target) { + + // If the target is a demon and exorcism is up, cast exorcism + // Only cast exorcism when above 40% mana + if ret.Rotation.UseExorcism && + target.MobType == proto.MobType_MobTypeDemon && + ret.Exorcism.IsReady(sim) && + ret.CurrentMana() > ret.MaxMana()*0.4 { + + ret.Exorcism.Cast(sim, target) + return + } + + // If we can't exorcise, try to consecrate + // Only cast consecration when above 60% mana + if ret.Rotation.ConsecrationRank != proto.RetributionPaladin_Rotation_None && + ret.Consecration.IsReady(sim) && + ret.CurrentMana() > ret.MaxMana()*0.6 { + ret.Consecration.Cast(sim, target) + return + } +} + +// Just roll seal of blood and cast crusader strike on CD to conserve mana +func (ret *RetributionPaladin) lowManaRotation(sim *core.Simulation) { + target := sim.GetPrimaryTarget() + + sobExpiration := ret.SealOfBloodAura.ExpiresAt() + nextSwingAt := ret.AutoAttacks.NextAttackAt() + + manaRegenAt := sim.Duration + 1 + // Roll seal of blood + if sim.CurrentTime+time.Second >= sobExpiration { + sobAndJudgementCost := ret.JudgementOfBlood.DefaultCast.Cost + ret.SealOfBlood.DefaultCast.Cost + if ret.CanJudgementOfBlood(sim) && ret.CurrentMana() >= sobAndJudgementCost { + ret.JudgementOfBlood.Cast(sim, target) + } + if ret.GCD.IsReady(sim) { + if success := ret.SealOfBlood.Cast(sim, target); !success { + // This should only happen in VERY BAD mana situations. + manaRegenAt = ret.TimeUntilManaRegen(ret.SealOfBlood.CurCast.Cost) + } + } + } else if ret.GCD.IsReady(sim) && ret.CrusaderStrike.CD.IsReady(sim) { + spellGCD := ret.SpellGCD() + sobAndCSCost := ret.CrusaderStrike.DefaultCast.Cost + ret.SealOfBlood.DefaultCast.Cost + + if !(spellGCD+sim.CurrentTime > nextSwingAt && sobExpiration < nextSwingAt) && + (ret.CurrentMana() >= sobAndCSCost) { + // Crusader strike unless it will cause seal of blood to drop + // Or we won't have enough mana to reseal + ret.CrusaderStrike.Cast(sim, target) + } + } + + events := []time.Duration{ + ret.GCD.ReadyAt(), + ret.CrusaderStrike.CD.ReadyAt(), + manaRegenAt, + sobExpiration - time.Second, + } + + ret.waitUntilNextEvent(sim, events) +} + +// Helper function for finding the next event +func (ret *RetributionPaladin) waitUntilNextEvent(sim *core.Simulation, events []time.Duration) { + // Find the minimum possible next event that is greater than the current time + nextEventAt := sim.Duration + 1 // setting this to sim.Duration will result in an infinite loop where we keep putting actions and it never advances. + for _, elem := range events { + if elem > sim.CurrentTime && elem < nextEventAt { + nextEventAt = elem + } + } + // If the next action is the GCD, just return + if nextEventAt == ret.GCD.ReadyAt() { + return + } + + // Otherwise add a pending action for the next time + pa := &core.PendingAction{ + Priority: core.ActionPriorityLow, + OnAction: ret.mainRotation, + NextActionAt: nextEventAt, + } + + sim.AddPendingAction(pa) +} diff --git a/sim/priest/shadow/rotation.go b/sim/priest/shadow/rotation.go new file mode 100644 index 000000000..ed6533fa7 --- /dev/null +++ b/sim/priest/shadow/rotation.go @@ -0,0 +1,363 @@ +package shadow + +import ( + "time" + + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" + "github.com/wowsims/tbc/sim/core/stats" +) + +// TODO: probably do something different instead of making it global? +const ( + mbidx int = iota + swdidx + vtidx + swpidx +) + +func (spriest *ShadowPriest) OnGCDReady(sim *core.Simulation) { + spriest.tryUseGCD(sim) +} + +func (spriest *ShadowPriest) OnManaTick(sim *core.Simulation) { + if spriest.FinishedWaitingForManaAndGCDReady(sim) { + spriest.tryUseGCD(sim) + } +} + +func (spriest *ShadowPriest) tryUseGCD(sim *core.Simulation) { + if spriest.rotation.PrecastVt && sim.CurrentTime == 0 { + spriest.SpendMana(sim, spriest.VampiricTouch.DefaultCast.Cost, spriest.VampiricTouch.ActionID) + spriest.VampiricTouch.SkipCastAndApplyEffects(sim, sim.GetPrimaryTarget()) + } + + // Activate shared behaviors + var spell *core.Spell + var wait1 time.Duration + var wait2 time.Duration + var wait time.Duration + + // calculate how much time a VT cast would take so we can possibly start casting right before the dot is up. + castSpeed := spriest.CastSpeed() + vtCastTime := time.Duration(float64(time.Millisecond*1500) / castSpeed) + + // timeForDots := sim.Duration-sim.CurrentTime > time.Second*12 + // TODO: stop casting dots near the end? + + if spriest.Talents.VampiricTouch && spriest.VampiricTouchDot.RemainingDuration(sim) <= vtCastTime { + spell = spriest.VampiricTouch + } else if !spriest.ShadowWordPainDot.IsActive() { + spell = spriest.ShadowWordPain + } else if spriest.rotation.UseStarshards && spriest.Starshards.IsReady(sim) { + spell = spriest.Starshards + } else if spriest.rotation.UseDevPlague && spriest.DevouringPlague.IsReady(sim) { + spell = spriest.DevouringPlague + } else if spriest.Talents.MindFlay { + + allCDs := []time.Duration{ + mbidx: spriest.MindBlast.TimeToReady(sim), + swdidx: spriest.ShadowWordDeath.TimeToReady(sim), + vtidx: spriest.VampiricTouchDot.RemainingDuration(sim) - vtCastTime, + swpidx: spriest.ShadowWordPainDot.RemainingDuration(sim), + } + + if allCDs[mbidx] == 0 { + if spriest.InnerFocus != nil && spriest.InnerFocus.IsReady(sim) { + spriest.InnerFocus.Cast(sim, nil) + } + spell = spriest.MindBlast + } else if allCDs[swdidx] == 0 { + spell = spriest.ShadowWordDeath + } else { + gcd := core.MinDuration(core.GCDMin, time.Duration(float64(core.GCDDefault)/castSpeed)) + tickLength := time.Duration(float64(time.Second) / castSpeed) + + var numTicks int + switch spriest.rotation.RotationType { + case proto.ShadowPriest_Rotation_Basic: + numTicks = spriest.BasicMindflayRotation(sim, allCDs, gcd, tickLength) + case proto.ShadowPriest_Rotation_Clipping: + numTicks = spriest.ClippingMindflayRotation(sim, allCDs, gcd, tickLength) + case proto.ShadowPriest_Rotation_Ideal: + numTicks = spriest.IdealMindflayRotation(sim, allCDs, gcd, tickLength) + } + + if numTicks == 0 { + // Means we'd rather wait for next CD (swp, vt, etc) than start a MF cast. + nextCD := core.NeverExpires + for _, v := range allCDs { + if v < nextCD { + nextCD = v + } + } + spriest.WaitUntil(sim, sim.CurrentTime+nextCD) + return + } + + spell = spriest.MindFlay[numTicks] + } + } else { + // what do you even do... i guess just sit around + mbcd := spriest.MindBlast.TimeToReady(sim) + swdcd := spriest.ShadowWordDeath.TimeToReady(sim) + vtidx := spriest.VampiricTouchDot.RemainingDuration(sim) - vtCastTime + swpidx := spriest.ShadowWordPainDot.RemainingDuration(sim) + wait1 = core.MinDuration(mbcd, swdcd) + wait2 = core.MinDuration(vtidx, swpidx) + wait = core.MinDuration(wait1, wait2) + spriest.WaitUntil(sim, sim.CurrentTime+wait) + return + } + + if success := spell.Cast(sim, sim.GetPrimaryTarget()); !success { + spriest.WaitForMana(sim, spell.CurCast.Cost) + } +} + +// Returns the number of MF ticks to use, or 0 to wait for next CD. +func (spriest *ShadowPriest) BasicMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { + // just do MF3, never clipping + nextCD := core.NeverExpires + for _, v := range allCDs { + if v < nextCD { + nextCD = v + } + } + // But don't start a MF if we can't get a single tick off. + if nextCD < gcd { + return 0 + } else { + return 3 + } +} + +// Returns the number of MF ticks to use, or 0 to wait for next CD. +func (spriest *ShadowPriest) IdealMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { + nextCD := core.NeverExpires + nextIdx := -1 + for i, v := range allCDs { + if v < nextCD { + nextCD = v + nextIdx = i + } + } + + var numTicks int + if nextCD < gcd { + numTicks = 0 + } else { + numTicks = int(nextCD / tickLength) + } + + critChance := (spriest.GetStat(stats.SpellCrit) / (core.SpellCritRatingPerCritChance * 100)) + (float64(spriest.Talents.ShadowPower) * 0.03) + averageCritMultiplier := 1 + 0.5*critChance + mfDamage := (528 + 0.57*(spriest.GetStat(stats.SpellPower))) * 0.3333 + + if numTicks == 0 { + // calculate the dps gain from casting vs waiting. + var Major_dmg float64 + if nextIdx == 0 { + Major_dmg = (731.5 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + nextCD).Seconds() * averageCritMultiplier + } else if nextIdx == 1 { + Major_dmg = (618 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + nextCD).Seconds() * averageCritMultiplier + } else if nextIdx == 2 { + Major_dmg = spriest.VampiricTouch.CurDamagePerCast() / (gcd + nextCD).Seconds() + } else if nextIdx == 3 { + Major_dmg = spriest.ShadowWordPain.CurDamagePerCast() / (gcd + nextCD).Seconds() + } + + dpsPossibleshort := []float64{ + (Major_dmg * float64(nextCD+gcd)) / float64(gcd+nextCD), // dps with no tick and just wait + (Major_dmg*(nextCD+gcd).Seconds() + mfDamage) / (gcd + gcd).Seconds(), // new damage for 1 extra tick + (Major_dmg*(nextCD+gcd).Seconds() + 2*mfDamage) / (2*tickLength + gcd).Seconds(), // new damage for 2 extra tick + (Major_dmg*(nextCD+gcd).Seconds() + 3*mfDamage) / (3*tickLength + gcd).Seconds(), // new damage for 3 extra tick + } + + // Find the highest possible dps and its index + highestPossibleIdx := 0 + highestPossibleDmg := 0.0 + if highestPossibleIdx == 0 { + for i, v := range dpsPossibleshort { + if v >= highestPossibleDmg { + highestPossibleIdx = i + highestPossibleDmg = v + } + } + } + if highestPossibleIdx == 0 { + return 0 + } + numTicks = highestPossibleIdx + + // Now that the number of optimal ticks has been determined to optimize dps + // Now optimize mf2s and mf3s + if numTicks == 1 { + return 1 + } else if numTicks == 2 || numTicks == 4 { + return 2 + } else { + return 3 + } + } + + // TODO: Should spriest latency be added to the second option here? + mfTime := core.MaxDuration(gcd, time.Duration(numTicks)*tickLength) + + // Amount of gap time after casting mind flay, but before each CD is available. + cdDiffs := []time.Duration{ + allCDs[0] - mfTime, + allCDs[1] - mfTime, + allCDs[2] - mfTime, + allCDs[3] - mfTime, + } + + spellDamages := []float64{ + mbidx: (731.5 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + cdDiffs[mbidx]).Seconds() * averageCritMultiplier, + swdidx: (618 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + cdDiffs[swdidx]).Seconds() * averageCritMultiplier, + vtidx: spriest.VampiricTouch.CurDamagePerCast() / (gcd + cdDiffs[vtidx]).Seconds(), + swpidx: spriest.ShadowWordPain.CurDamagePerCast() / (gcd + cdDiffs[swpidx]).Seconds(), + } + + bestIdx := 0 + bestDmg := 0.0 + for i, v := range spellDamages { + if sim.Log != nil { + //spriest.Log(sim, "\tSpellDamages[%d]: %01.f", i, v) + //spriest.Log(sim, "\tcdDiffs[%d]: %0.1f", i, cdDiffs[i].Seconds()) + } + if v > bestDmg { + bestIdx = i + bestDmg = v + } + } + + if nextIdx != bestIdx && cdDiffs[bestIdx] < time.Millisecond*1490 { + numTicks = int(allCDs[bestIdx] / tickLength) + } + + chosenWait := cdDiffs[bestIdx] + if chosenWait > cdDiffs[nextIdx] && cdDiffs[nextIdx] < time.Millisecond*100 { + chosenWait = cdDiffs[nextIdx] + } + + finalMFStart := numTicks // Base ticks before adding additional + + //spriest.Log(sim, "CW %d", chosenWait) + dpsPossible := []float64{ + bestDmg, // dps with no tick and just wait + 0, + 0, + 0, + } + dpsDuration := float64((chosenWait + gcd).Seconds()) + + highestPossibleIdx := 0 + // TODO: Modified this slightly to expand time window, but it still doesn't change dps for any tests. + // Probably can remove this entirely (and then also the if highestPossibleIdx == 0 right after) + if (finalMFStart == 2) && (chosenWait <= tickLength && chosenWait > (tickLength-time.Millisecond*15)) { + highestPossibleIdx = 1 // if the wait time is equal to an extra mf tick, and there are already 2 ticks, then just add 1 + } + + if highestPossibleIdx == 0 { + switch finalMFStart { + case 0: + // this means that the extra ticks will be relative to starting a new mf cast entirely + dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / float64(gcd+gcd) // new damage for 1 extra tick + dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(2*tickLength+gcd) // new damage for 2 extra tick + dpsPossible[3] = (bestDmg*dpsDuration + 3*mfDamage) / float64(3*tickLength+gcd) // new damage for 3 extra tick + case 1: + total_check_time := 2 * tickLength + + if total_check_time < gcd { + newDuration := float64((gcd + gcd).Seconds()) + dpsPossible[1] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+1))) / newDuration + } else { + newDuration := float64(((total_check_time - gcd) + gcd).Seconds()) + dpsPossible[1] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+1))) / newDuration + } + // % check add 2 + total_check_time2 := 2 * tickLength + if total_check_time2 < gcd { + dpsPossible[2] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+2))) / float64(gcd+gcd) + } else { + dpsPossible[2] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+2))) / float64(total_check_time2+gcd) + } + case 2: + // % check add 1 + total_check_time := tickLength + newDuration := float64((total_check_time + gcd).Seconds()) + dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / newDuration + + default: + dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / float64(gcd+gcd) + if tickLength*2 > gcd { + dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(2*tickLength+gcd) + } else { + dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(gcd+gcd) + } + dpsPossible[3] = (bestDmg*dpsDuration + 3*mfDamage) / float64(3*tickLength+gcd) + } + } + + // Find the highest possible dps and its index + // highestPossibleIdx := 0 + highestPossibleDmg := 0.0 + if highestPossibleIdx == 0 { + for i, v := range dpsPossible { + if sim.Log != nil { + //spriest.Log(sim, "\tdpsPossible[%d]: %01.f", i, v) + } + if v >= highestPossibleDmg { + highestPossibleIdx = i + highestPossibleDmg = v + } + } + } + + numTicks += highestPossibleIdx + + // Now that the number of optimal ticks has been determined to optimize dps + // Now optimize mf2s and mf3s + if numTicks == 1 { + return 1 + } else if numTicks == 2 || numTicks == 4 { + return 2 + } else { + return 3 + } + + // ONE BIG CAVEAT THAT STILL NEEDS WORK.. THIS NEEDS TO BE UPDATED TO INCLUDE HASTE PROCS THAT CAN OCCUR/DROP OFF MID MF SEQUENCE +} + +// ClippingMindflayRotation is to be a 'sweaty but not perfect' rotation. +// It will prioritize casting MB / SWD by clipping. +// If there is 4s until the next CD it will use a 2xMF2 instead of 3+1. +// Returns the number of MF ticks to use, or 0 to wait for next CD. +func (spriest *ShadowPriest) ClippingMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { + nextCD := core.NeverExpires + for _, v := range allCDs[:2] { + if v < nextCD { + nextCD = v + } + } + + if sim.Log != nil { + spriest.Log(sim, " NextCD: %0.2f", nextCD.Seconds()) + } + // This means a CD is coming up before we could cast a single MF + if nextCD < gcd { + return 0 + } + + // How many ticks we have time for. + numTicks := int((nextCD - time.Duration(spriest.rotation.Latency)) / tickLength) + + if numTicks == 1 { + return 1 + } else if numTicks == 2 || numTicks == 4 { + return 2 + } else { + return 3 + } +} diff --git a/sim/priest/shadow/shadow_priest.go b/sim/priest/shadow/shadow_priest.go index a7c22bfce..1387161e0 100644 --- a/sim/priest/shadow/shadow_priest.go +++ b/sim/priest/shadow/shadow_priest.go @@ -1,11 +1,8 @@ package shadow import ( - "time" - "github.com/wowsims/tbc/sim/core" "github.com/wowsims/tbc/sim/core/proto" - "github.com/wowsims/tbc/sim/core/stats" "github.com/wowsims/tbc/sim/priest" ) @@ -67,357 +64,3 @@ func (spriest *ShadowPriest) GetPriest() *priest.Priest { func (spriest *ShadowPriest) Reset(sim *core.Simulation) { spriest.Priest.Reset(sim) } - -// TODO: probably do something different instead of making it global? -const ( - mbidx int = iota - swdidx - vtidx - swpidx -) - -func (spriest *ShadowPriest) OnGCDReady(sim *core.Simulation) { - spriest.tryUseGCD(sim) -} - -func (spriest *ShadowPriest) OnManaTick(sim *core.Simulation) { - if spriest.FinishedWaitingForManaAndGCDReady(sim) { - spriest.tryUseGCD(sim) - } -} - -func (spriest *ShadowPriest) tryUseGCD(sim *core.Simulation) { - if spriest.rotation.PrecastVt && sim.CurrentTime == 0 { - spriest.SpendMana(sim, spriest.VampiricTouch.DefaultCast.Cost, spriest.VampiricTouch.ActionID) - spriest.VampiricTouch.SkipCastAndApplyEffects(sim, sim.GetPrimaryTarget()) - } - - // Activate shared behaviors - var spell *core.Spell - var wait1 time.Duration - var wait2 time.Duration - var wait time.Duration - - // calculate how much time a VT cast would take so we can possibly start casting right before the dot is up. - castSpeed := spriest.CastSpeed() - vtCastTime := time.Duration(float64(time.Millisecond*1500) / castSpeed) - - // timeForDots := sim.Duration-sim.CurrentTime > time.Second*12 - // TODO: stop casting dots near the end? - - if spriest.Talents.VampiricTouch && spriest.VampiricTouchDot.RemainingDuration(sim) <= vtCastTime { - spell = spriest.VampiricTouch - } else if !spriest.ShadowWordPainDot.IsActive() { - spell = spriest.ShadowWordPain - } else if spriest.rotation.UseStarshards && spriest.Starshards.IsReady(sim) { - spell = spriest.Starshards - } else if spriest.rotation.UseDevPlague && spriest.DevouringPlague.IsReady(sim) { - spell = spriest.DevouringPlague - } else if spriest.Talents.MindFlay { - - allCDs := []time.Duration{ - mbidx: spriest.MindBlast.TimeToReady(sim), - swdidx: spriest.ShadowWordDeath.TimeToReady(sim), - vtidx: spriest.VampiricTouchDot.RemainingDuration(sim) - vtCastTime, - swpidx: spriest.ShadowWordPainDot.RemainingDuration(sim), - } - - if allCDs[mbidx] == 0 { - if spriest.InnerFocus != nil && spriest.InnerFocus.IsReady(sim) { - spriest.InnerFocus.Cast(sim, nil) - } - spell = spriest.MindBlast - } else if allCDs[swdidx] == 0 { - spell = spriest.ShadowWordDeath - } else { - gcd := core.MinDuration(core.GCDMin, time.Duration(float64(core.GCDDefault)/castSpeed)) - tickLength := time.Duration(float64(time.Second) / castSpeed) - - var numTicks int - switch spriest.rotation.RotationType { - case proto.ShadowPriest_Rotation_Basic: - numTicks = spriest.BasicMindflayRotation(sim, allCDs, gcd, tickLength) - case proto.ShadowPriest_Rotation_Clipping: - numTicks = spriest.ClippingMindflayRotation(sim, allCDs, gcd, tickLength) - case proto.ShadowPriest_Rotation_Ideal: - numTicks = spriest.IdealMindflayRotation(sim, allCDs, gcd, tickLength) - } - - if numTicks == 0 { - // Means we'd rather wait for next CD (swp, vt, etc) than start a MF cast. - nextCD := core.NeverExpires - for _, v := range allCDs { - if v < nextCD { - nextCD = v - } - } - spriest.WaitUntil(sim, sim.CurrentTime+nextCD) - return - } - - spell = spriest.MindFlay[numTicks] - } - } else { - // what do you even do... i guess just sit around - mbcd := spriest.MindBlast.TimeToReady(sim) - swdcd := spriest.ShadowWordDeath.TimeToReady(sim) - vtidx := spriest.VampiricTouchDot.RemainingDuration(sim) - vtCastTime - swpidx := spriest.ShadowWordPainDot.RemainingDuration(sim) - wait1 = core.MinDuration(mbcd, swdcd) - wait2 = core.MinDuration(vtidx, swpidx) - wait = core.MinDuration(wait1, wait2) - spriest.WaitUntil(sim, sim.CurrentTime+wait) - return - } - - if success := spell.Cast(sim, sim.GetPrimaryTarget()); !success { - spriest.WaitForMana(sim, spell.CurCast.Cost) - } -} - -// Returns the number of MF ticks to use, or 0 to wait for next CD. -func (spriest *ShadowPriest) BasicMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { - // just do MF3, never clipping - nextCD := core.NeverExpires - for _, v := range allCDs { - if v < nextCD { - nextCD = v - } - } - // But don't start a MF if we can't get a single tick off. - if nextCD < gcd { - return 0 - } else { - return 3 - } -} - -// Returns the number of MF ticks to use, or 0 to wait for next CD. -func (spriest *ShadowPriest) IdealMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { - nextCD := core.NeverExpires - nextIdx := -1 - for i, v := range allCDs { - if v < nextCD { - nextCD = v - nextIdx = i - } - } - - var numTicks int - if nextCD < gcd { - numTicks = 0 - } else { - numTicks = int(nextCD / tickLength) - } - - critChance := (spriest.GetStat(stats.SpellCrit) / (core.SpellCritRatingPerCritChance * 100)) + (float64(spriest.Talents.ShadowPower) * 0.03) - averageCritMultiplier := 1 + 0.5*critChance - mfDamage := (528 + 0.57*(spriest.GetStat(stats.SpellPower))) * 0.3333 - - if numTicks == 0 { - // calculate the dps gain from casting vs waiting. - var Major_dmg float64 - if nextIdx == 0 { - Major_dmg = (731.5 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + nextCD).Seconds() * averageCritMultiplier - } else if nextIdx == 1 { - Major_dmg = (618 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + nextCD).Seconds() * averageCritMultiplier - } else if nextIdx == 2 { - Major_dmg = spriest.VampiricTouch.CurDamagePerCast() / (gcd + nextCD).Seconds() - } else if nextIdx == 3 { - Major_dmg = spriest.ShadowWordPain.CurDamagePerCast() / (gcd + nextCD).Seconds() - } - - dpsPossibleshort := []float64{ - (Major_dmg * float64(nextCD+gcd)) / float64(gcd+nextCD), // dps with no tick and just wait - (Major_dmg*(nextCD+gcd).Seconds() + mfDamage) / (gcd + gcd).Seconds(), // new damage for 1 extra tick - (Major_dmg*(nextCD+gcd).Seconds() + 2*mfDamage) / (2*tickLength + gcd).Seconds(), // new damage for 2 extra tick - (Major_dmg*(nextCD+gcd).Seconds() + 3*mfDamage) / (3*tickLength + gcd).Seconds(), // new damage for 3 extra tick - } - - // Find the highest possible dps and its index - highestPossibleIdx := 0 - highestPossibleDmg := 0.0 - if highestPossibleIdx == 0 { - for i, v := range dpsPossibleshort { - if v >= highestPossibleDmg { - highestPossibleIdx = i - highestPossibleDmg = v - } - } - } - if highestPossibleIdx == 0 { - return 0 - } - numTicks = highestPossibleIdx - - // Now that the number of optimal ticks has been determined to optimize dps - // Now optimize mf2s and mf3s - if numTicks == 1 { - return 1 - } else if numTicks == 2 || numTicks == 4 { - return 2 - } else { - return 3 - } - } - - // TODO: Should spriest latency be added to the second option here? - mfTime := core.MaxDuration(gcd, time.Duration(numTicks)*tickLength) - - // Amount of gap time after casting mind flay, but before each CD is available. - cdDiffs := []time.Duration{ - allCDs[0] - mfTime, - allCDs[1] - mfTime, - allCDs[2] - mfTime, - allCDs[3] - mfTime, - } - - spellDamages := []float64{ - mbidx: (731.5 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + cdDiffs[mbidx]).Seconds() * averageCritMultiplier, - swdidx: (618 + spriest.GetStat(stats.SpellPower)*0.429) / (gcd + cdDiffs[swdidx]).Seconds() * averageCritMultiplier, - vtidx: spriest.VampiricTouch.CurDamagePerCast() / (gcd + cdDiffs[vtidx]).Seconds(), - swpidx: spriest.ShadowWordPain.CurDamagePerCast() / (gcd + cdDiffs[swpidx]).Seconds(), - } - - bestIdx := 0 - bestDmg := 0.0 - for i, v := range spellDamages { - if sim.Log != nil { - //spriest.Log(sim, "\tSpellDamages[%d]: %01.f", i, v) - //spriest.Log(sim, "\tcdDiffs[%d]: %0.1f", i, cdDiffs[i].Seconds()) - } - if v > bestDmg { - bestIdx = i - bestDmg = v - } - } - - if nextIdx != bestIdx && cdDiffs[bestIdx] < time.Millisecond*1490 { - numTicks = int(allCDs[bestIdx] / tickLength) - } - - chosenWait := cdDiffs[bestIdx] - if chosenWait > cdDiffs[nextIdx] && cdDiffs[nextIdx] < time.Millisecond*100 { - chosenWait = cdDiffs[nextIdx] - } - - finalMFStart := numTicks // Base ticks before adding additional - - //spriest.Log(sim, "CW %d", chosenWait) - dpsPossible := []float64{ - bestDmg, // dps with no tick and just wait - 0, - 0, - 0, - } - dpsDuration := float64((chosenWait + gcd).Seconds()) - - highestPossibleIdx := 0 - // TODO: Modified this slightly to expand time window, but it still doesn't change dps for any tests. - // Probably can remove this entirely (and then also the if highestPossibleIdx == 0 right after) - if (finalMFStart == 2) && (chosenWait <= tickLength && chosenWait > (tickLength-time.Millisecond*15)) { - highestPossibleIdx = 1 // if the wait time is equal to an extra mf tick, and there are already 2 ticks, then just add 1 - } - - if highestPossibleIdx == 0 { - switch finalMFStart { - case 0: - // this means that the extra ticks will be relative to starting a new mf cast entirely - dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / float64(gcd+gcd) // new damage for 1 extra tick - dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(2*tickLength+gcd) // new damage for 2 extra tick - dpsPossible[3] = (bestDmg*dpsDuration + 3*mfDamage) / float64(3*tickLength+gcd) // new damage for 3 extra tick - case 1: - total_check_time := 2 * tickLength - - if total_check_time < gcd { - newDuration := float64((gcd + gcd).Seconds()) - dpsPossible[1] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+1))) / newDuration - } else { - newDuration := float64(((total_check_time - gcd) + gcd).Seconds()) - dpsPossible[1] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+1))) / newDuration - } - // % check add 2 - total_check_time2 := 2 * tickLength - if total_check_time2 < gcd { - dpsPossible[2] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+2))) / float64(gcd+gcd) - } else { - dpsPossible[2] = (bestDmg*dpsDuration + (mfDamage * float64(finalMFStart+2))) / float64(total_check_time2+gcd) - } - case 2: - // % check add 1 - total_check_time := tickLength - newDuration := float64((total_check_time + gcd).Seconds()) - dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / newDuration - - default: - dpsPossible[1] = (bestDmg*dpsDuration + mfDamage) / float64(gcd+gcd) - if tickLength*2 > gcd { - dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(2*tickLength+gcd) - } else { - dpsPossible[2] = (bestDmg*dpsDuration + 2*mfDamage) / float64(gcd+gcd) - } - dpsPossible[3] = (bestDmg*dpsDuration + 3*mfDamage) / float64(3*tickLength+gcd) - } - } - - // Find the highest possible dps and its index - // highestPossibleIdx := 0 - highestPossibleDmg := 0.0 - if highestPossibleIdx == 0 { - for i, v := range dpsPossible { - if sim.Log != nil { - //spriest.Log(sim, "\tdpsPossible[%d]: %01.f", i, v) - } - if v >= highestPossibleDmg { - highestPossibleIdx = i - highestPossibleDmg = v - } - } - } - - numTicks += highestPossibleIdx - - // Now that the number of optimal ticks has been determined to optimize dps - // Now optimize mf2s and mf3s - if numTicks == 1 { - return 1 - } else if numTicks == 2 || numTicks == 4 { - return 2 - } else { - return 3 - } - - // ONE BIG CAVEAT THAT STILL NEEDS WORK.. THIS NEEDS TO BE UPDATED TO INCLUDE HASTE PROCS THAT CAN OCCUR/DROP OFF MID MF SEQUENCE -} - -// ClippingMindflayRotation is to be a 'sweaty but not perfect' rotation. -// It will prioritize casting MB / SWD by clipping. -// If there is 4s until the next CD it will use a 2xMF2 instead of 3+1. -// Returns the number of MF ticks to use, or 0 to wait for next CD. -func (spriest *ShadowPriest) ClippingMindflayRotation(sim *core.Simulation, allCDs []time.Duration, gcd time.Duration, tickLength time.Duration) int { - nextCD := core.NeverExpires - for _, v := range allCDs[:2] { - if v < nextCD { - nextCD = v - } - } - - if sim.Log != nil { - spriest.Log(sim, " NextCD: %0.2f", nextCD.Seconds()) - } - // This means a CD is coming up before we could cast a single MF - if nextCD < gcd { - return 0 - } - - // How many ticks we have time for. - numTicks := int((nextCD - time.Duration(spriest.rotation.Latency)) / tickLength) - - if numTicks == 1 { - return 1 - } else if numTicks == 2 || numTicks == 4 { - return 2 - } else { - return 3 - } -} diff --git a/sim/priest/smite/rotation.go b/sim/priest/smite/rotation.go new file mode 100644 index 000000000..c0ae44af9 --- /dev/null +++ b/sim/priest/smite/rotation.go @@ -0,0 +1,69 @@ +package smite + +import ( + "time" + + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" +) + +// TODO: probably do something different instead of making it global? +const ( + mbidx int = iota + swdidx + vtidx + swpidx +) + +func (spriest *SmitePriest) OnGCDReady(sim *core.Simulation) { + spriest.tryUseGCD(sim) +} + +func (spriest *SmitePriest) OnManaTick(sim *core.Simulation) { + if spriest.FinishedWaitingForManaAndGCDReady(sim) { + spriest.tryUseGCD(sim) + } +} + +func (spriest *SmitePriest) tryUseGCD(sim *core.Simulation) { + + // Calculate higher SW:P uptime if using HF + swpRemaining := spriest.ShadowWordPainDot.RemainingDuration(sim) + + castSpeed := spriest.CastSpeed() + + // smite cast time, talent assumed + smiteCastTime := time.Duration(float64(time.Millisecond*2000) / castSpeed) + + // holy fire cast time + hfCastTime := time.Duration(float64(time.Millisecond*3000) / castSpeed) + + var spell *core.Spell + // Always attempt to keep SW:P up if its down + if !spriest.ShadowWordPainDot.IsActive() { + spell = spriest.ShadowWordPain + // Favor star shards for NE if off cooldown first + } else if spriest.rotation.UseStarshards && spriest.Starshards.IsReady(sim) { + spell = spriest.Starshards + // Allow for undead to use devouring plague off CD + } else if spriest.rotation.UseDevPlague && spriest.DevouringPlague.IsReady(sim) { + spell = spriest.DevouringPlague + // If setting enabled, throw mind blast into our rotation off CD + } else if spriest.rotation.UseMindBlast && spriest.MindBlast.IsReady(sim) { + spell = spriest.MindBlast + // If setting enabled, cast Shadow Word: Death on cooldown + } else if spriest.rotation.UseShadowWordDeath && spriest.ShadowWordDeath.IsReady(sim) { + spell = spriest.ShadowWordDeath + // Consider HF if SWP will fall off after 1 smite but before 2 smites from now finishes + // and swp falls off after hf finishes (assumption never worth clipping) + } else if spriest.rotation.RotationType == proto.SmitePriest_Rotation_HolyFireWeave && swpRemaining > smiteCastTime && swpRemaining < hfCastTime { + spell = spriest.HolyFire + // Base filler spell is smite + } else { + spell = spriest.Smite + } + + if success := spell.Cast(sim, sim.GetPrimaryTarget()); !success { + spriest.WaitForMana(sim, spell.CurCast.Cost) + } +} diff --git a/sim/priest/smite/smite_priest.go b/sim/priest/smite/smite_priest.go index 986e43fec..a7d578ef5 100644 --- a/sim/priest/smite/smite_priest.go +++ b/sim/priest/smite/smite_priest.go @@ -4,7 +4,6 @@ import ( "github.com/wowsims/tbc/sim/core" "github.com/wowsims/tbc/sim/core/proto" "github.com/wowsims/tbc/sim/priest" - "time" ) func RegisterSmitePriest() { @@ -69,64 +68,3 @@ func (spriest *SmitePriest) GetPriest() *priest.Priest { func (spriest *SmitePriest) Reset(sim *core.Simulation) { spriest.Priest.Reset(sim) } - -// TODO: probably do something different instead of making it global? -const ( - mbidx int = iota - swdidx - vtidx - swpidx -) - -func (spriest *SmitePriest) OnGCDReady(sim *core.Simulation) { - spriest.tryUseGCD(sim) -} - -func (spriest *SmitePriest) OnManaTick(sim *core.Simulation) { - if spriest.FinishedWaitingForManaAndGCDReady(sim) { - spriest.tryUseGCD(sim) - } -} - -func (spriest *SmitePriest) tryUseGCD(sim *core.Simulation) { - - // Calculate higher SW:P uptime if using HF - swpRemaining := spriest.ShadowWordPainDot.RemainingDuration(sim) - - castSpeed := spriest.CastSpeed() - - // smite cast time, talent assumed - smiteCastTime := time.Duration(float64(time.Millisecond*2000) / castSpeed) - - // holy fire cast time - hfCastTime := time.Duration(float64(time.Millisecond*3000) / castSpeed) - - var spell *core.Spell - // Always attempt to keep SW:P up if its down - if !spriest.ShadowWordPainDot.IsActive() { - spell = spriest.ShadowWordPain - // Favor star shards for NE if off cooldown first - } else if spriest.rotation.UseStarshards && spriest.Starshards.IsReady(sim) { - spell = spriest.Starshards - // Allow for undead to use devouring plague off CD - } else if spriest.rotation.UseDevPlague && spriest.DevouringPlague.IsReady(sim) { - spell = spriest.DevouringPlague - // If setting enabled, throw mind blast into our rotation off CD - } else if spriest.rotation.UseMindBlast && spriest.MindBlast.IsReady(sim) { - spell = spriest.MindBlast - // If setting enabled, cast Shadow Word: Death on cooldown - } else if spriest.rotation.UseShadowWordDeath && spriest.ShadowWordDeath.IsReady(sim) { - spell = spriest.ShadowWordDeath - // Consider HF if SWP will fall off after 1 smite but before 2 smites from now finishes - // and swp falls off after hf finishes (assumption never worth clipping) - } else if spriest.rotation.RotationType == proto.SmitePriest_Rotation_HolyFireWeave && swpRemaining > smiteCastTime && swpRemaining < hfCastTime { - spell = spriest.HolyFire - // Base filler spell is smite - } else { - spell = spriest.Smite - } - - if success := spell.Cast(sim, sim.GetPrimaryTarget()); !success { - spriest.WaitForMana(sim, spell.CurCast.Cost) - } -} diff --git a/sim/shaman/elemental/elemental.go b/sim/shaman/elemental/elemental.go index c5b088ce3..190a53183 100644 --- a/sim/shaman/elemental/elemental.go +++ b/sim/shaman/elemental/elemental.go @@ -1,12 +1,8 @@ package elemental import ( - "time" - - "github.com/wowsims/tbc/sim/common" "github.com/wowsims/tbc/sim/core" "github.com/wowsims/tbc/sim/core/proto" - "github.com/wowsims/tbc/sim/core/stats" "github.com/wowsims/tbc/sim/shaman" ) @@ -75,252 +71,7 @@ func (eleShaman *ElementalShaman) GetShaman() *shaman.Shaman { return eleShaman.Shaman } -func (eleShaman *ElementalShaman) GetPresimOptions() *core.PresimOptions { - return eleShaman.rotation.GetPresimOptions() -} - func (eleShaman *ElementalShaman) Reset(sim *core.Simulation) { eleShaman.Shaman.Reset(sim) eleShaman.rotation.Reset(eleShaman, sim) } - -func (eleShaman *ElementalShaman) OnGCDReady(sim *core.Simulation) { - eleShaman.tryUseGCD(sim) -} - -func (eleShaman *ElementalShaman) OnManaTick(sim *core.Simulation) { - if eleShaman.FinishedWaitingForManaAndGCDReady(sim) { - eleShaman.tryUseGCD(sim) - } -} - -func (eleShaman *ElementalShaman) tryUseGCD(sim *core.Simulation) { - if eleShaman.TryDropTotems(sim) { - return - } - - eleShaman.rotation.DoAction(eleShaman, sim) - //actionSuccessful := newAction.Cast(sim) - //if actionSuccessful { - // eleShaman.rotation.OnActionAccepted(eleShaman, sim, newAction) - //} else { - // // Only way for a shaman spell to fail is due to mana cost. - // // Wait until we have enough mana to cast. - // eleShaman.WaitForMana(sim, newAction.GetManaCost()) - //} -} - -// Picks which attacks / abilities the Shaman does. -type Rotation interface { - GetPresimOptions() *core.PresimOptions - - // Returns the action this rotation would like to take next. - DoAction(*ElementalShaman, *core.Simulation) - - // Returns this rotation to its initial state. Called before each Sim iteration. - Reset(*ElementalShaman, *core.Simulation) -} - -// ################################################################ -// LB ONLY -// ################################################################ -type LBOnlyRotation struct { -} - -func (rotation *LBOnlyRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { - if !eleShaman.LightningBolt.Cast(sim, sim.GetPrimaryTarget()) { - eleShaman.WaitForMana(sim, eleShaman.LightningBolt.CurCast.Cost) - } -} - -func (rotation *LBOnlyRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) {} -func (rotation *LBOnlyRotation) GetPresimOptions() *core.PresimOptions { return nil } - -func NewLBOnlyRotation() *LBOnlyRotation { - return &LBOnlyRotation{} -} - -// ################################################################ -// CL ON CD -// ################################################################ -type CLOnCDRotation struct { -} - -func (rotation *CLOnCDRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { - var spell *core.Spell - if eleShaman.ChainLightning.IsReady(sim) { - spell = eleShaman.ChainLightning - } else { - spell = eleShaman.LightningBolt - } - - if !spell.Cast(sim, sim.GetPrimaryTarget()) { - eleShaman.WaitForMana(sim, spell.CurCast.Cost) - } -} - -func (rotation *CLOnCDRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) {} -func (rotation *CLOnCDRotation) GetPresimOptions() *core.PresimOptions { return nil } - -func NewCLOnCDRotation() *CLOnCDRotation { - return &CLOnCDRotation{} -} - -// ################################################################ -// FIXED ROTATION -// ################################################################ -type FixedRotation struct { - numLBsPerCL int32 - numLBsSinceLastCL int32 -} - -func (rotation *FixedRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { - var spell *core.Spell - if rotation.numLBsSinceLastCL < rotation.numLBsPerCL { - spell = eleShaman.LightningBolt - rotation.numLBsSinceLastCL++ - } else if eleShaman.ChainLightning.IsReady(sim) { - spell = eleShaman.ChainLightning - rotation.numLBsSinceLastCL = 0 - } else if eleShaman.HasTemporarySpellCastSpeedIncrease() { - // If we have a temporary haste effect (like bloodlust or quags eye) then - // we should add LB casts instead of waiting - spell = eleShaman.LightningBolt - rotation.numLBsSinceLastCL++ - } - - if spell == nil { - common.NewWaitAction(sim, &eleShaman.Unit, eleShaman.ChainLightning.TimeToReady(sim), common.WaitReasonRotation).Cast(sim) - } else { - if !spell.Cast(sim, sim.GetPrimaryTarget()) { - eleShaman.WaitForMana(sim, spell.CurCast.Cost) - } - } -} - -func (rotation *FixedRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { - rotation.numLBsSinceLastCL = rotation.numLBsPerCL // This lets us cast CL first -} - -func (rotation *FixedRotation) GetPresimOptions() *core.PresimOptions { return nil } - -func NewFixedRotation(numLBsPerCL int32) *FixedRotation { - return &FixedRotation{ - numLBsPerCL: numLBsPerCL, - } -} - -// ################################################################ -// CL ON CLEARCAST -// ################################################################ -type CLOnClearcastRotation struct { - // Whether the second-to-last spell procced clearcasting - prevPrevCastProccedCC bool -} - -func (rotation *CLOnClearcastRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { - var spell *core.Spell - if !eleShaman.ChainLightning.IsReady(sim) || !rotation.prevPrevCastProccedCC { - spell = eleShaman.LightningBolt - } else { - spell = eleShaman.ChainLightning - } - - if !spell.Cast(sim, sim.GetPrimaryTarget()) { - eleShaman.WaitForMana(sim, spell.CurCast.Cost) - } else { - rotation.prevPrevCastProccedCC = eleShaman.ClearcastingAura.GetStacks() == 2 - } -} - -func (rotation *CLOnClearcastRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { - rotation.prevPrevCastProccedCC = true // Lets us cast CL first -} - -func (rotation *CLOnClearcastRotation) GetPresimOptions() *core.PresimOptions { return nil } - -func NewCLOnClearcastRotation() *CLOnClearcastRotation { - return &CLOnClearcastRotation{} -} - -// ################################################################ -// ADAPTIVE -// ################################################################ -type AdaptiveRotation struct { - manaTracker common.ManaSpendingRateTracker - - baseRotation Rotation // The rotation used most of the time - surplusRotation Rotation // The rotation used when we have extra mana -} - -func (rotation *AdaptiveRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { - didLB := false - if sim.GetNumTargets() == 1 { - sp := eleShaman.GetStat(stats.NatureSpellPower) + eleShaman.GetStat(stats.SpellPower) - castSpeed := eleShaman.CastSpeed() - lb := ((612 + (sp * 0.794)) * 1.2) / (2 / castSpeed) - cl := ((786 + (sp * 0.651)) * 1.0666) / core.MaxFloat((1.5/castSpeed), 1) - if eleShaman.has4pT6 { - lb *= 1.05 - } - if lb+10 >= cl { - eleShaman.LightningBolt.Cast(sim, sim.GetPrimaryTarget()) - didLB = true - } - } - - if !didLB { - // If we have enough mana to burn, use the surplus rotation. - if rotation.manaTracker.ProjectedManaSurplus(sim, eleShaman.GetCharacter()) { - rotation.surplusRotation.DoAction(eleShaman, sim) - } else { - rotation.baseRotation.DoAction(eleShaman, sim) - } - } - - rotation.manaTracker.Update(sim, eleShaman.GetCharacter()) -} - -func (rotation *AdaptiveRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { - rotation.manaTracker.Reset() - rotation.baseRotation.Reset(eleShaman, sim) - rotation.surplusRotation.Reset(eleShaman, sim) -} - -func (rotation *AdaptiveRotation) GetPresimOptions() *core.PresimOptions { - return &core.PresimOptions{ - SetPresimPlayerOptions: func(player *proto.Player) { - player.Spec.(*proto.Player_ElementalShaman).ElementalShaman.Rotation.Type = proto.ElementalShaman_Rotation_CLOnClearcast - }, - - OnPresimResult: func(presimResult proto.UnitMetrics, iterations int32, duration time.Duration) bool { - if float64(presimResult.SecondsOomAvg) >= 0.03*duration.Seconds() { - rotation.baseRotation = NewLBOnlyRotation() - rotation.surplusRotation = NewCLOnClearcastRotation() - } else { - rotation.baseRotation = NewCLOnClearcastRotation() - rotation.surplusRotation = NewCLOnCDRotation() - } - return true - }, - } -} - -func NewAdaptiveRotation() *AdaptiveRotation { - return &AdaptiveRotation{ - manaTracker: common.NewManaSpendingRateTracker(), - } -} - -// A single action that an Agent can take. -type AgentAction interface { - GetActionID() core.ActionID - - // TODO: Maybe change this to 'ResourceCost' - // Amount of mana required to perform the action. - GetManaCost() float64 - - // Do the action. Returns whether the action was successful. An unsuccessful - // action indicates that the prerequisites, like resource cost, were not met. - Cast(sim *core.Simulation) bool -} diff --git a/sim/shaman/elemental/rotation.go b/sim/shaman/elemental/rotation.go new file mode 100644 index 000000000..e34c92fce --- /dev/null +++ b/sim/shaman/elemental/rotation.go @@ -0,0 +1,255 @@ +package elemental + +import ( + "time" + + "github.com/wowsims/tbc/sim/common" + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" + "github.com/wowsims/tbc/sim/core/stats" +) + +func (eleShaman *ElementalShaman) GetPresimOptions() *core.PresimOptions { + return eleShaman.rotation.GetPresimOptions() +} + +func (eleShaman *ElementalShaman) OnGCDReady(sim *core.Simulation) { + eleShaman.tryUseGCD(sim) +} + +func (eleShaman *ElementalShaman) OnManaTick(sim *core.Simulation) { + if eleShaman.FinishedWaitingForManaAndGCDReady(sim) { + eleShaman.tryUseGCD(sim) + } +} + +func (eleShaman *ElementalShaman) tryUseGCD(sim *core.Simulation) { + if eleShaman.TryDropTotems(sim) { + return + } + + eleShaman.rotation.DoAction(eleShaman, sim) + //actionSuccessful := newAction.Cast(sim) + //if actionSuccessful { + // eleShaman.rotation.OnActionAccepted(eleShaman, sim, newAction) + //} else { + // // Only way for a shaman spell to fail is due to mana cost. + // // Wait until we have enough mana to cast. + // eleShaman.WaitForMana(sim, newAction.GetManaCost()) + //} +} + +// Picks which attacks / abilities the Shaman does. +type Rotation interface { + GetPresimOptions() *core.PresimOptions + + // Returns the action this rotation would like to take next. + DoAction(*ElementalShaman, *core.Simulation) + + // Returns this rotation to its initial state. Called before each Sim iteration. + Reset(*ElementalShaman, *core.Simulation) +} + +// ################################################################ +// LB ONLY +// ################################################################ +type LBOnlyRotation struct { +} + +func (rotation *LBOnlyRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { + if !eleShaman.LightningBolt.Cast(sim, sim.GetPrimaryTarget()) { + eleShaman.WaitForMana(sim, eleShaman.LightningBolt.CurCast.Cost) + } +} + +func (rotation *LBOnlyRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) {} +func (rotation *LBOnlyRotation) GetPresimOptions() *core.PresimOptions { return nil } + +func NewLBOnlyRotation() *LBOnlyRotation { + return &LBOnlyRotation{} +} + +// ################################################################ +// CL ON CD +// ################################################################ +type CLOnCDRotation struct { +} + +func (rotation *CLOnCDRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { + var spell *core.Spell + if eleShaman.ChainLightning.IsReady(sim) { + spell = eleShaman.ChainLightning + } else { + spell = eleShaman.LightningBolt + } + + if !spell.Cast(sim, sim.GetPrimaryTarget()) { + eleShaman.WaitForMana(sim, spell.CurCast.Cost) + } +} + +func (rotation *CLOnCDRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) {} +func (rotation *CLOnCDRotation) GetPresimOptions() *core.PresimOptions { return nil } + +func NewCLOnCDRotation() *CLOnCDRotation { + return &CLOnCDRotation{} +} + +// ################################################################ +// FIXED ROTATION +// ################################################################ +type FixedRotation struct { + numLBsPerCL int32 + numLBsSinceLastCL int32 +} + +func (rotation *FixedRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { + var spell *core.Spell + if rotation.numLBsSinceLastCL < rotation.numLBsPerCL { + spell = eleShaman.LightningBolt + rotation.numLBsSinceLastCL++ + } else if eleShaman.ChainLightning.IsReady(sim) { + spell = eleShaman.ChainLightning + rotation.numLBsSinceLastCL = 0 + } else if eleShaman.HasTemporarySpellCastSpeedIncrease() { + // If we have a temporary haste effect (like bloodlust or quags eye) then + // we should add LB casts instead of waiting + spell = eleShaman.LightningBolt + rotation.numLBsSinceLastCL++ + } + + if spell == nil { + common.NewWaitAction(sim, &eleShaman.Unit, eleShaman.ChainLightning.TimeToReady(sim), common.WaitReasonRotation).Cast(sim) + } else { + if !spell.Cast(sim, sim.GetPrimaryTarget()) { + eleShaman.WaitForMana(sim, spell.CurCast.Cost) + } + } +} + +func (rotation *FixedRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { + rotation.numLBsSinceLastCL = rotation.numLBsPerCL // This lets us cast CL first +} + +func (rotation *FixedRotation) GetPresimOptions() *core.PresimOptions { return nil } + +func NewFixedRotation(numLBsPerCL int32) *FixedRotation { + return &FixedRotation{ + numLBsPerCL: numLBsPerCL, + } +} + +// ################################################################ +// CL ON CLEARCAST +// ################################################################ +type CLOnClearcastRotation struct { + // Whether the second-to-last spell procced clearcasting + prevPrevCastProccedCC bool +} + +func (rotation *CLOnClearcastRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { + var spell *core.Spell + if !eleShaman.ChainLightning.IsReady(sim) || !rotation.prevPrevCastProccedCC { + spell = eleShaman.LightningBolt + } else { + spell = eleShaman.ChainLightning + } + + if !spell.Cast(sim, sim.GetPrimaryTarget()) { + eleShaman.WaitForMana(sim, spell.CurCast.Cost) + } else { + rotation.prevPrevCastProccedCC = eleShaman.ClearcastingAura.GetStacks() == 2 + } +} + +func (rotation *CLOnClearcastRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { + rotation.prevPrevCastProccedCC = true // Lets us cast CL first +} + +func (rotation *CLOnClearcastRotation) GetPresimOptions() *core.PresimOptions { return nil } + +func NewCLOnClearcastRotation() *CLOnClearcastRotation { + return &CLOnClearcastRotation{} +} + +// ################################################################ +// ADAPTIVE +// ################################################################ +type AdaptiveRotation struct { + manaTracker common.ManaSpendingRateTracker + + baseRotation Rotation // The rotation used most of the time + surplusRotation Rotation // The rotation used when we have extra mana +} + +func (rotation *AdaptiveRotation) DoAction(eleShaman *ElementalShaman, sim *core.Simulation) { + didLB := false + if sim.GetNumTargets() == 1 { + sp := eleShaman.GetStat(stats.NatureSpellPower) + eleShaman.GetStat(stats.SpellPower) + castSpeed := eleShaman.CastSpeed() + lb := ((612 + (sp * 0.794)) * 1.2) / (2 / castSpeed) + cl := ((786 + (sp * 0.651)) * 1.0666) / core.MaxFloat((1.5/castSpeed), 1) + if eleShaman.has4pT6 { + lb *= 1.05 + } + if lb+10 >= cl { + eleShaman.LightningBolt.Cast(sim, sim.GetPrimaryTarget()) + didLB = true + } + } + + if !didLB { + // If we have enough mana to burn, use the surplus rotation. + if rotation.manaTracker.ProjectedManaSurplus(sim, eleShaman.GetCharacter()) { + rotation.surplusRotation.DoAction(eleShaman, sim) + } else { + rotation.baseRotation.DoAction(eleShaman, sim) + } + } + + rotation.manaTracker.Update(sim, eleShaman.GetCharacter()) +} + +func (rotation *AdaptiveRotation) Reset(eleShaman *ElementalShaman, sim *core.Simulation) { + rotation.manaTracker.Reset() + rotation.baseRotation.Reset(eleShaman, sim) + rotation.surplusRotation.Reset(eleShaman, sim) +} + +func (rotation *AdaptiveRotation) GetPresimOptions() *core.PresimOptions { + return &core.PresimOptions{ + SetPresimPlayerOptions: func(player *proto.Player) { + player.Spec.(*proto.Player_ElementalShaman).ElementalShaman.Rotation.Type = proto.ElementalShaman_Rotation_CLOnClearcast + }, + + OnPresimResult: func(presimResult proto.UnitMetrics, iterations int32, duration time.Duration) bool { + if float64(presimResult.SecondsOomAvg) >= 0.03*duration.Seconds() { + rotation.baseRotation = NewLBOnlyRotation() + rotation.surplusRotation = NewCLOnClearcastRotation() + } else { + rotation.baseRotation = NewCLOnClearcastRotation() + rotation.surplusRotation = NewCLOnCDRotation() + } + return true + }, + } +} + +func NewAdaptiveRotation() *AdaptiveRotation { + return &AdaptiveRotation{ + manaTracker: common.NewManaSpendingRateTracker(), + } +} + +// A single action that an Agent can take. +type AgentAction interface { + GetActionID() core.ActionID + + // TODO: Maybe change this to 'ResourceCost' + // Amount of mana required to perform the action. + GetManaCost() float64 + + // Do the action. Returns whether the action was successful. An unsuccessful + // action indicates that the prerequisites, like resource cost, were not met. + Cast(sim *core.Simulation) bool +} diff --git a/sim/shaman/enhancement/enhancement.go b/sim/shaman/enhancement/enhancement.go index 325d4cffb..ebf30f95b 100644 --- a/sim/shaman/enhancement/enhancement.go +++ b/sim/shaman/enhancement/enhancement.go @@ -1,9 +1,6 @@ package enhancement import ( - "fmt" - "time" - "github.com/wowsims/tbc/sim/common" "github.com/wowsims/tbc/sim/core" "github.com/wowsims/tbc/sim/core/proto" @@ -102,328 +99,7 @@ func (enh *EnhancementShaman) Initialize() { enh.Env.RegisterPostFinalizeEffect(enh.SetupRotationSchedule) } -func (enh *EnhancementShaman) SetupRotationSchedule() { - // Fill the GCD schedule based on our settings. - maxDuration := enh.Env.GetMaxDuration() - - var curTime time.Duration - - if enh.Talents.Stormstrike { - ssAction := common.ScheduledAbility{ - Duration: core.GCDDefault, - TryCast: func(sim *core.Simulation) bool { - ss := enh.Stormstrike - success := ss.Cast(sim, sim.GetPrimaryTarget()) - if !success { - enh.WaitForMana(sim, ss.CurCast.Cost) - } - return success - }, - } - curTime = core.DurationFromSeconds(enh.Rotation.FirstStormstrikeDelay) - for curTime <= maxDuration { - ability := ssAction - ability.DesiredCastAt = curTime - castAt := enh.scheduler.Schedule(ability) - curTime = castAt + time.Second*10 - } - } - - shockCD := enh.ShockCD() - shockAction := common.ScheduledAbility{ - Duration: core.GCDDefault, - TryCast: func(sim *core.Simulation) bool { - var shock *core.Spell - if enh.Rotation.WeaveFlameShock && !enh.FlameShockDot.IsActive() { - shock = enh.FlameShock - } else if enh.Rotation.PrimaryShock == proto.EnhancementShaman_Rotation_Earth { - shock = enh.EarthShock - } else if enh.Rotation.PrimaryShock == proto.EnhancementShaman_Rotation_Frost { - shock = enh.FrostShock - } - - success := shock.Cast(sim, sim.GetPrimaryTarget()) - if !success { - enh.WaitForMana(sim, shock.CurCast.Cost) - } - return success - }, - } - if enh.Rotation.PrimaryShock != proto.EnhancementShaman_Rotation_None { - curTime = 0 - for curTime <= maxDuration { - ability := shockAction - ability.DesiredCastAt = curTime - ability.MinCastAt = curTime - ability.MaxCastAt = curTime + time.Second*10 - castAt := enh.scheduler.Schedule(ability) - curTime = castAt + shockCD - } - } else if enh.Rotation.WeaveFlameShock { - // Flame shock but no regular shock, so only use it once every 12s. - curTime = 0 - for curTime <= maxDuration { - ability := shockAction - ability.DesiredCastAt = curTime - ability.MinCastAt = curTime - ability.MaxCastAt = curTime + time.Second*10 - castAt := enh.scheduler.Schedule(ability) - curTime = castAt + time.Second*12 - } - } - - // We need to directly manage all GCD-bound CDs ourself. - if enh.Consumes.Drums == proto.Drums_DrumsOfBattle { - enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfBattleActionID) - } else if enh.Consumes.Drums == proto.Drums_DrumsOfRestoration { - enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfRestorationActionID) - } else if enh.Consumes.Drums == proto.Drums_DrumsOfWar { - enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfWarActionID) - } - enh.scheduler.ScheduleMCD(enh.GetCharacter(), enh.BloodlustActionID()) - - scheduleTotem := func(duration time.Duration, prioritizeEarlier bool, precast bool, tryCast func(sim *core.Simulation) (bool, float64)) { - totemAction := common.ScheduledAbility{ - Duration: time.Second * 1, - TryCast: func(sim *core.Simulation) bool { - success, manaCost := tryCast(sim) - if !success { - enh.WaitForMana(sim, manaCost) - } - return success - }, - PrioritizeEarlierForConflicts: prioritizeEarlier, - } - - curTime := time.Duration(0) - if precast { - curTime = duration - } - for curTime <= maxDuration { - ability := totemAction - ability.DesiredCastAt = curTime - if prioritizeEarlier { - ability.MinCastAt = curTime - time.Second*30 - ability.MaxCastAt = curTime + time.Second*15 - } else { - ability.MinCastAt = curTime - time.Second*5 - ability.MaxCastAt = curTime + time.Second*30 - } - castAt := enh.scheduler.Schedule(ability) - if castAt == common.Unresolved { - panic("No timeslot found for totem") - } - curTime = castAt + duration - } - } - scheduleSpellTotem := func(duration time.Duration, spell *core.Spell) { - scheduleTotem(duration, false, false, func(sim *core.Simulation) (bool, float64) { - success := spell.Cast(sim, sim.GetPrimaryTarget()) - return success, spell.CurCast.Cost - }) - } - schedule2MTotem := func(castFactory func(sim *core.Simulation) *core.Spell) { - scheduleTotem(time.Minute*2, true, true, func(sim *core.Simulation) (bool, float64) { - spell := castFactory(sim) - return spell.Cast(sim, sim.GetPrimaryTarget()), spell.CurCast.Cost - }) - } - - if enh.Totems.TwistFireNova { - var defaultCastFactory func(sim *core.Simulation) - switch enh.Totems.Fire { - case proto.FireTotem_MagmaTotem: - defaultCastFactory = func(sim *core.Simulation) { - if enh.SearingTotemDot.IsActive() || enh.MagmaTotemDot.IsActive() || enh.FireNovaTotemDot.IsActive() { - return - } - - cast := enh.MagmaTotem - success := cast.Cast(sim, nil) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - } - case proto.FireTotem_SearingTotem: - defaultCastFactory = func(sim *core.Simulation) { - if enh.SearingTotemDot.IsActive() || enh.MagmaTotemDot.IsActive() || enh.FireNovaTotemDot.IsActive() { - return - } - - cast := enh.SearingTotem - success := cast.Cast(sim, sim.GetPrimaryTarget()) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - } - case proto.FireTotem_TotemOfWrath: - defaultCastFactory = func(sim *core.Simulation) { - if enh.NextTotemDrops[shaman.FireTotem] > sim.CurrentTime+time.Second*5 { - // Skip dropping if we've gone OOM reverted to dropping default only, and have plenty of time left. - return - } - - cast := enh.TotemOfWrath - success := cast.Cast(sim, nil) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - } - } - - fntAction := common.ScheduledAbility{ - Duration: time.Second * 1, - TryCast: func(sim *core.Simulation) bool { - if enh.Metrics.WentOOM && enh.CurrentManaPercent() < 0.2 { - return false - } - - cast := enh.FireNovaTotem - success := cast.Cast(sim, nil) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - return success - }, - } - defaultAction := common.ScheduledAbility{ - Duration: time.Second * 1, - TryCast: func(sim *core.Simulation) bool { - defaultCastFactory(sim) - return true - }, - } - - curTime := time.Duration(0) - nextNovaCD := time.Duration(0) - defaultNext := false - for curTime <= maxDuration { - ability := fntAction - if defaultNext { - ability = defaultAction - } - ability.DesiredCastAt = curTime - ability.MinCastAt = curTime - ability.MaxCastAt = curTime + time.Second*15 - - castAt := enh.scheduler.Schedule(ability) - - if defaultNext { - curTime = nextNovaCD - defaultNext = false - } else { - nextNovaCD = castAt + time.Second*15 + 1 - if defaultCastFactory == nil { - curTime = nextNovaCD - } else { - curTime = castAt + enh.FireNovaTickLength() + 1 - defaultNext = true - } - } - } - } else { - switch enh.Totems.Fire { - case proto.FireTotem_MagmaTotem: - scheduleSpellTotem(time.Second*20+1, enh.MagmaTotem) - case proto.FireTotem_SearingTotem: - scheduleSpellTotem(time.Minute*1+1, enh.SearingTotem) - case proto.FireTotem_TotemOfWrath: - schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.TotemOfWrath }) - } - } - - if enh.Totems.Air != proto.AirTotem_NoAirTotem { - var defaultCastFactory func(sim *core.Simulation) *core.Spell - switch enh.Totems.Air { - case proto.AirTotem_GraceOfAirTotem: - defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.GraceOfAirTotem } - case proto.AirTotem_TranquilAirTotem: - defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.TranquilAirTotem } - case proto.AirTotem_WindfuryTotem: - defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.WindfuryTotem } - case proto.AirTotem_WrathOfAirTotem: - defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.WrathOfAirTotem } - } - - if enh.Totems.TwistWindfury { - wfAction := common.ScheduledAbility{ - Duration: time.Second * 1, - TryCast: func(sim *core.Simulation) bool { - if enh.Metrics.WentOOM && enh.CurrentManaPercent() < 0.2 { - return false - } - - cast := enh.WindfuryTotem - success := cast.Cast(sim, nil) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - return success - }, - PrioritizeEarlierForConflicts: true, - } - defaultAction := common.ScheduledAbility{ - Duration: time.Second * 1, - TryCast: func(sim *core.Simulation) bool { - if enh.NextTotemDrops[shaman.AirTotem] > sim.CurrentTime+time.Second*10 { - // Skip dropping if we've gone OOM reverted to dropping default only, and have plenty of time left. - return true - } - - cast := defaultCastFactory(sim) - success := cast.Cast(sim, sim.GetPrimaryTarget()) - if !success { - enh.WaitForMana(sim, cast.CurCast.Cost) - } - return success - }, - } - - curTime := time.Second * 10 - for curTime <= maxDuration { - ability := wfAction - ability.DesiredCastAt = curTime - ability.MinCastAt = curTime - time.Second*8 - ability.MaxCastAt = curTime + time.Second*20 - defaultAbility := defaultAction - castAt := enh.scheduler.ScheduleGroup([]common.ScheduledAbility{ability, defaultAbility}) - if castAt == common.Unresolved { - panic(fmt.Sprintf("No timeslot found for air totem, desired: %s", curTime)) - } - curTime = castAt + time.Second*10 - } - } else { - schedule2MTotem(defaultCastFactory) - } - } - - if enh.Totems.Earth != proto.EarthTotem_NoEarthTotem { - switch enh.Totems.Earth { - case proto.EarthTotem_StrengthOfEarthTotem: - schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.StrengthOfEarthTotem }) - case proto.EarthTotem_TremorTotem: - schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.TremorTotem }) - } - } - - if enh.Totems.Water != proto.WaterTotem_NoWaterTotem { - if enh.Totems.Water == proto.WaterTotem_ManaSpringTotem { - schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.ManaSpringTotem }) - } - } -} - func (enh *EnhancementShaman) Reset(sim *core.Simulation) { enh.Shaman.Reset(sim) enh.scheduler.Reset(sim, enh.GetCharacter()) } - -func (enh *EnhancementShaman) OnGCDReady(sim *core.Simulation) { - enh.scheduler.DoNextAbility(sim, &enh.Character) -} - -func (enh *EnhancementShaman) OnManaTick(sim *core.Simulation) { - if enh.IsWaitingForMana() && !enh.DoneWaitingForMana(sim) { - // Do nothing, just need to check so metrics get updated. - } -} diff --git a/sim/shaman/enhancement/rotation.go b/sim/shaman/enhancement/rotation.go new file mode 100644 index 000000000..8320e8f3a --- /dev/null +++ b/sim/shaman/enhancement/rotation.go @@ -0,0 +1,332 @@ +package enhancement + +import ( + "fmt" + "time" + + "github.com/wowsims/tbc/sim/common" + "github.com/wowsims/tbc/sim/core" + "github.com/wowsims/tbc/sim/core/proto" + "github.com/wowsims/tbc/sim/shaman" +) + +func (enh *EnhancementShaman) SetupRotationSchedule() { + // Fill the GCD schedule based on our settings. + maxDuration := enh.Env.GetMaxDuration() + + var curTime time.Duration + + if enh.Talents.Stormstrike { + ssAction := common.ScheduledAbility{ + Duration: core.GCDDefault, + TryCast: func(sim *core.Simulation) bool { + ss := enh.Stormstrike + success := ss.Cast(sim, sim.GetPrimaryTarget()) + if !success { + enh.WaitForMana(sim, ss.CurCast.Cost) + } + return success + }, + } + curTime = core.DurationFromSeconds(enh.Rotation.FirstStormstrikeDelay) + for curTime <= maxDuration { + ability := ssAction + ability.DesiredCastAt = curTime + castAt := enh.scheduler.Schedule(ability) + curTime = castAt + time.Second*10 + } + } + + shockCD := enh.ShockCD() + shockAction := common.ScheduledAbility{ + Duration: core.GCDDefault, + TryCast: func(sim *core.Simulation) bool { + var shock *core.Spell + if enh.Rotation.WeaveFlameShock && !enh.FlameShockDot.IsActive() { + shock = enh.FlameShock + } else if enh.Rotation.PrimaryShock == proto.EnhancementShaman_Rotation_Earth { + shock = enh.EarthShock + } else if enh.Rotation.PrimaryShock == proto.EnhancementShaman_Rotation_Frost { + shock = enh.FrostShock + } + + success := shock.Cast(sim, sim.GetPrimaryTarget()) + if !success { + enh.WaitForMana(sim, shock.CurCast.Cost) + } + return success + }, + } + if enh.Rotation.PrimaryShock != proto.EnhancementShaman_Rotation_None { + curTime = 0 + for curTime <= maxDuration { + ability := shockAction + ability.DesiredCastAt = curTime + ability.MinCastAt = curTime + ability.MaxCastAt = curTime + time.Second*10 + castAt := enh.scheduler.Schedule(ability) + curTime = castAt + shockCD + } + } else if enh.Rotation.WeaveFlameShock { + // Flame shock but no regular shock, so only use it once every 12s. + curTime = 0 + for curTime <= maxDuration { + ability := shockAction + ability.DesiredCastAt = curTime + ability.MinCastAt = curTime + ability.MaxCastAt = curTime + time.Second*10 + castAt := enh.scheduler.Schedule(ability) + curTime = castAt + time.Second*12 + } + } + + // We need to directly manage all GCD-bound CDs ourself. + if enh.Consumes.Drums == proto.Drums_DrumsOfBattle { + enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfBattleActionID) + } else if enh.Consumes.Drums == proto.Drums_DrumsOfRestoration { + enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfRestorationActionID) + } else if enh.Consumes.Drums == proto.Drums_DrumsOfWar { + enh.scheduler.ScheduleMCD(enh.GetCharacter(), core.DrumsOfWarActionID) + } + enh.scheduler.ScheduleMCD(enh.GetCharacter(), enh.BloodlustActionID()) + + scheduleTotem := func(duration time.Duration, prioritizeEarlier bool, precast bool, tryCast func(sim *core.Simulation) (bool, float64)) { + totemAction := common.ScheduledAbility{ + Duration: time.Second * 1, + TryCast: func(sim *core.Simulation) bool { + success, manaCost := tryCast(sim) + if !success { + enh.WaitForMana(sim, manaCost) + } + return success + }, + PrioritizeEarlierForConflicts: prioritizeEarlier, + } + + curTime := time.Duration(0) + if precast { + curTime = duration + } + for curTime <= maxDuration { + ability := totemAction + ability.DesiredCastAt = curTime + if prioritizeEarlier { + ability.MinCastAt = curTime - time.Second*30 + ability.MaxCastAt = curTime + time.Second*15 + } else { + ability.MinCastAt = curTime - time.Second*5 + ability.MaxCastAt = curTime + time.Second*30 + } + castAt := enh.scheduler.Schedule(ability) + if castAt == common.Unresolved { + panic("No timeslot found for totem") + } + curTime = castAt + duration + } + } + scheduleSpellTotem := func(duration time.Duration, spell *core.Spell) { + scheduleTotem(duration, false, false, func(sim *core.Simulation) (bool, float64) { + success := spell.Cast(sim, sim.GetPrimaryTarget()) + return success, spell.CurCast.Cost + }) + } + schedule2MTotem := func(castFactory func(sim *core.Simulation) *core.Spell) { + scheduleTotem(time.Minute*2, true, true, func(sim *core.Simulation) (bool, float64) { + spell := castFactory(sim) + return spell.Cast(sim, sim.GetPrimaryTarget()), spell.CurCast.Cost + }) + } + + if enh.Totems.TwistFireNova { + var defaultCastFactory func(sim *core.Simulation) + switch enh.Totems.Fire { + case proto.FireTotem_MagmaTotem: + defaultCastFactory = func(sim *core.Simulation) { + if enh.SearingTotemDot.IsActive() || enh.MagmaTotemDot.IsActive() || enh.FireNovaTotemDot.IsActive() { + return + } + + cast := enh.MagmaTotem + success := cast.Cast(sim, nil) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + } + case proto.FireTotem_SearingTotem: + defaultCastFactory = func(sim *core.Simulation) { + if enh.SearingTotemDot.IsActive() || enh.MagmaTotemDot.IsActive() || enh.FireNovaTotemDot.IsActive() { + return + } + + cast := enh.SearingTotem + success := cast.Cast(sim, sim.GetPrimaryTarget()) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + } + case proto.FireTotem_TotemOfWrath: + defaultCastFactory = func(sim *core.Simulation) { + if enh.NextTotemDrops[shaman.FireTotem] > sim.CurrentTime+time.Second*5 { + // Skip dropping if we've gone OOM reverted to dropping default only, and have plenty of time left. + return + } + + cast := enh.TotemOfWrath + success := cast.Cast(sim, nil) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + } + } + + fntAction := common.ScheduledAbility{ + Duration: time.Second * 1, + TryCast: func(sim *core.Simulation) bool { + if enh.Metrics.WentOOM && enh.CurrentManaPercent() < 0.2 { + return false + } + + cast := enh.FireNovaTotem + success := cast.Cast(sim, nil) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + return success + }, + } + defaultAction := common.ScheduledAbility{ + Duration: time.Second * 1, + TryCast: func(sim *core.Simulation) bool { + defaultCastFactory(sim) + return true + }, + } + + curTime := time.Duration(0) + nextNovaCD := time.Duration(0) + defaultNext := false + for curTime <= maxDuration { + ability := fntAction + if defaultNext { + ability = defaultAction + } + ability.DesiredCastAt = curTime + ability.MinCastAt = curTime + ability.MaxCastAt = curTime + time.Second*15 + + castAt := enh.scheduler.Schedule(ability) + + if defaultNext { + curTime = nextNovaCD + defaultNext = false + } else { + nextNovaCD = castAt + time.Second*15 + 1 + if defaultCastFactory == nil { + curTime = nextNovaCD + } else { + curTime = castAt + enh.FireNovaTickLength() + 1 + defaultNext = true + } + } + } + } else { + switch enh.Totems.Fire { + case proto.FireTotem_MagmaTotem: + scheduleSpellTotem(time.Second*20+1, enh.MagmaTotem) + case proto.FireTotem_SearingTotem: + scheduleSpellTotem(time.Minute*1+1, enh.SearingTotem) + case proto.FireTotem_TotemOfWrath: + schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.TotemOfWrath }) + } + } + + if enh.Totems.Air != proto.AirTotem_NoAirTotem { + var defaultCastFactory func(sim *core.Simulation) *core.Spell + switch enh.Totems.Air { + case proto.AirTotem_GraceOfAirTotem: + defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.GraceOfAirTotem } + case proto.AirTotem_TranquilAirTotem: + defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.TranquilAirTotem } + case proto.AirTotem_WindfuryTotem: + defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.WindfuryTotem } + case proto.AirTotem_WrathOfAirTotem: + defaultCastFactory = func(sim *core.Simulation) *core.Spell { return enh.WrathOfAirTotem } + } + + if enh.Totems.TwistWindfury { + wfAction := common.ScheduledAbility{ + Duration: time.Second * 1, + TryCast: func(sim *core.Simulation) bool { + if enh.Metrics.WentOOM && enh.CurrentManaPercent() < 0.2 { + return false + } + + cast := enh.WindfuryTotem + success := cast.Cast(sim, nil) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + return success + }, + PrioritizeEarlierForConflicts: true, + } + defaultAction := common.ScheduledAbility{ + Duration: time.Second * 1, + TryCast: func(sim *core.Simulation) bool { + if enh.NextTotemDrops[shaman.AirTotem] > sim.CurrentTime+time.Second*10 { + // Skip dropping if we've gone OOM reverted to dropping default only, and have plenty of time left. + return true + } + + cast := defaultCastFactory(sim) + success := cast.Cast(sim, sim.GetPrimaryTarget()) + if !success { + enh.WaitForMana(sim, cast.CurCast.Cost) + } + return success + }, + } + + curTime := time.Second * 10 + for curTime <= maxDuration { + ability := wfAction + ability.DesiredCastAt = curTime + ability.MinCastAt = curTime - time.Second*8 + ability.MaxCastAt = curTime + time.Second*20 + defaultAbility := defaultAction + castAt := enh.scheduler.ScheduleGroup([]common.ScheduledAbility{ability, defaultAbility}) + if castAt == common.Unresolved { + panic(fmt.Sprintf("No timeslot found for air totem, desired: %s", curTime)) + } + curTime = castAt + time.Second*10 + } + } else { + schedule2MTotem(defaultCastFactory) + } + } + + if enh.Totems.Earth != proto.EarthTotem_NoEarthTotem { + switch enh.Totems.Earth { + case proto.EarthTotem_StrengthOfEarthTotem: + schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.StrengthOfEarthTotem }) + case proto.EarthTotem_TremorTotem: + schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.TremorTotem }) + } + } + + if enh.Totems.Water != proto.WaterTotem_NoWaterTotem { + if enh.Totems.Water == proto.WaterTotem_ManaSpringTotem { + schedule2MTotem(func(sim *core.Simulation) *core.Spell { return enh.ManaSpringTotem }) + } + } +} + +func (enh *EnhancementShaman) OnGCDReady(sim *core.Simulation) { + enh.scheduler.DoNextAbility(sim, &enh.Character) +} + +func (enh *EnhancementShaman) OnManaTick(sim *core.Simulation) { + if enh.IsWaitingForMana() && !enh.DoneWaitingForMana(sim) { + // Do nothing, just need to check so metrics get updated. + } +} diff --git a/sim/warrior/dps/TestArms.results b/sim/warrior/dps/TestArms.results index 1e1a0aeef..c9ba2d5f8 100644 --- a/sim/warrior/dps/TestArms.results +++ b/sim/warrior/dps/TestArms.results @@ -66,31 +66,31 @@ dps_results: { dps_results: { key: "TestArms-AllItems-AshtongueTalismanofValor-32485" value: { - dps: 679.5822536460153 + dps: 676.3973472171882 } } dps_results: { key: "TestArms-AllItems-BadgeofTenacity-32658" value: { - dps: 678.5518498110079 + dps: 680.5585328596936 } } dps_results: { key: "TestArms-AllItems-BadgeoftheSwarmguard-21670" value: { - dps: 681.9108392390261 + dps: 682.0849174030294 } } dps_results: { key: "TestArms-AllItems-BandoftheEternalChampion-29301" value: { - dps: 702.5950172234875 + dps: 709.0639217943251 } } dps_results: { key: "TestArms-AllItems-BandoftheEternalSage-29305" value: { - dps: 685.622573046783 + dps: 683.8522802380845 } } dps_results: { @@ -126,7 +126,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-BlazefuryMedallion-17111" value: { - dps: 683.0862355630524 + dps: 684.2970151541751 } } dps_results: { @@ -138,7 +138,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-BoldArmor" value: { - dps: 528.7192008633945 + dps: 525.6995470844242 } } dps_results: { @@ -162,7 +162,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-BrutalEarthstormDiamond" value: { - dps: 680.861420530266 + dps: 686.9458263778329 } } dps_results: { @@ -180,13 +180,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-BurningRage" value: { - dps: 617.7022583369135 + dps: 618.3523122991942 } } dps_results: { key: "TestArms-AllItems-ChaoticSkyfireDiamond" value: { - dps: 696.7539998725879 + dps: 690.5416328192774 } } dps_results: { @@ -204,13 +204,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-CoreofAr'kelos-29776" value: { - dps: 691.0260380002782 + dps: 686.5129860684319 } } dps_results: { key: "TestArms-AllItems-CrystalforgedTrinket-32654" value: { - dps: 690.5207706779158 + dps: 686.4912722906156 } } dps_results: { @@ -234,13 +234,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-DarkmoonCard:Wrath-31857" value: { - dps: 679.0834871574936 + dps: 677.5304777365387 } } dps_results: { key: "TestArms-AllItems-DesolationBattlegear" value: { - dps: 559.5558726142128 + dps: 561.2763838029429 } } dps_results: { @@ -252,7 +252,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-DestroyerArmor" value: { - dps: 540.6104272715083 + dps: 541.76295397687 } } dps_results: { @@ -312,7 +312,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-EnigmaticSkyfireDiamond" value: { - dps: 683.1758177404428 + dps: 685.7041711110795 } } dps_results: { @@ -336,13 +336,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-FaithinFelsteel" value: { - dps: 554.1680019858903 + dps: 554.613056246533 } } dps_results: { key: "TestArms-AllItems-FelstalkerArmor" value: { - dps: 654.0397016769298 + dps: 653.9519381779397 } } dps_results: { @@ -354,7 +354,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-Figurine-NightseyePanther-24128" value: { - dps: 691.2115807850541 + dps: 691.8343304067453 } } dps_results: { @@ -366,7 +366,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-FlameGuard" value: { - dps: 526.4421591588157 + dps: 527.8414616638831 } } dps_results: { @@ -402,7 +402,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-IconofUnyieldingCourage-28121" value: { - dps: 680.6689486940767 + dps: 680.2218697292188 } } dps_results: { @@ -438,19 +438,19 @@ dps_results: { dps_results: { key: "TestArms-AllItems-LionheartChampion-28429" value: { - dps: 781.3277836515113 + dps: 783.8513905443039 } } dps_results: { key: "TestArms-AllItems-LionheartExecutioner-28430" value: { - dps: 811.3374305170098 + dps: 813.1446020273754 } } dps_results: { key: "TestArms-AllItems-MadnessoftheBetrayer-32505" value: { - dps: 695.839675839572 + dps: 696.631666187143 } } dps_results: { @@ -462,7 +462,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-MarkoftheChampion-23206" value: { - dps: 698.0686391432093 + dps: 699.7015256003614 } } dps_results: { @@ -486,7 +486,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-NetherscaleArmor" value: { - dps: 664.7845247049128 + dps: 664.8366596533806 } } dps_results: { @@ -498,19 +498,19 @@ dps_results: { dps_results: { key: "TestArms-AllItems-OnslaughtArmor" value: { - dps: 451.7333540854056 + dps: 448.5443396981565 } } dps_results: { key: "TestArms-AllItems-OnslaughtBattlegear" value: { - dps: 696.1140809965731 + dps: 696.9137099380765 } } dps_results: { key: "TestArms-AllItems-PotentUnstableDiamond" value: { - dps: 687.7329914642041 + dps: 686.7443871681872 } } dps_results: { @@ -522,13 +522,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-PrimalIntent" value: { - dps: 682.0663299224409 + dps: 677.4568421448245 } } dps_results: { key: "TestArms-AllItems-Quagmirran'sEye-27683" value: { - dps: 669.6205347564359 + dps: 674.8033903743056 } } dps_results: { @@ -552,19 +552,19 @@ dps_results: { dps_results: { key: "TestArms-AllItems-Romulo'sPoisonVial-28579" value: { - dps: 698.2764708957928 + dps: 701.6670638417221 } } dps_results: { key: "TestArms-AllItems-ScarabofDisplacement-30629" value: { - dps: 662.0099647949421 + dps: 658.5796496099091 } } dps_results: { key: "TestArms-AllItems-Scryer'sBloodgem-29132" value: { - dps: 672.8954218013754 + dps: 670.6985136664407 } } dps_results: { @@ -582,7 +582,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-ShardofContempt-34472" value: { - dps: 704.5852589444304 + dps: 706.5638399393257 } } dps_results: { @@ -594,7 +594,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-ShiftingNaaruSliver-34429" value: { - dps: 667.4792206784518 + dps: 668.5106380142685 } } dps_results: { @@ -606,7 +606,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-Slayer'sCrest-23041" value: { - dps: 691.041329750798 + dps: 697.3745778142277 } } dps_results: { @@ -624,7 +624,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-StormGauntlets-12632" value: { - dps: 667.1776705458133 + dps: 667.2285222830102 } } dps_results: { @@ -636,7 +636,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-SwiftSkyfireDiamond" value: { - dps: 687.7329914642041 + dps: 686.7443871681872 } } dps_results: { @@ -666,7 +666,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-TheBladefist-29348" value: { - dps: 639.2547309423421 + dps: 643.8046799463517 } } dps_results: { @@ -702,13 +702,13 @@ dps_results: { dps_results: { key: "TestArms-AllItems-TheSkullofGul'dan-32483" value: { - dps: 678.3399374908638 + dps: 675.7711011880452 } } dps_results: { key: "TestArms-AllItems-TheTwinBladesofAzzinoth" value: { - dps: 798.5181887187697 + dps: 800.1502209896333 } } dps_results: { @@ -726,7 +726,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-Timbal'sFocusingCrystal-34470" value: { - dps: 676.9101774505402 + dps: 675.5964972163638 } } dps_results: { @@ -768,7 +768,7 @@ dps_results: { dps_results: { key: "TestArms-AllItems-WorldBreaker-30090" value: { - dps: 755.9432899049798 + dps: 758.0371406194043 } } dps_results: { @@ -786,19 +786,19 @@ dps_results: { dps_results: { key: "TestArms-Average-Default" value: { - dps: 695.0891964577281 + dps: 695.2164519408616 } } dps_results: { key: "TestArms-Settings-Human-Arms P1-Basic-FullBuffs-LongMultiTarget" value: { - dps: 1146.1919037139317 + dps: 1142.9663508338406 } } dps_results: { key: "TestArms-Settings-Human-Arms P1-Basic-FullBuffs-LongSingleTargetFullDebuffs" value: { - dps: 691.822364785276 + dps: 696.8140571062988 } } dps_results: { @@ -816,19 +816,19 @@ dps_results: { dps_results: { key: "TestArms-Settings-Human-Arms P1-Basic-NoBuffs-LongMultiTarget" value: { - dps: 932.1264251565319 + dps: 936.1329370546623 } } dps_results: { key: "TestArms-Settings-Human-Arms P1-Basic-NoBuffs-LongSingleTargetFullDebuffs" value: { - dps: 558.61962720266 + dps: 558.4221129116924 } } dps_results: { key: "TestArms-Settings-Human-Arms P1-Basic-NoBuffs-LongSingleTargetNoDebuffs" value: { - dps: 502.4261501831783 + dps: 504.4185574214488 } } dps_results: { @@ -852,7 +852,7 @@ dps_results: { dps_results: { key: "TestArms-Settings-Orc-Arms P1-Basic-FullBuffs-LongSingleTargetNoDebuffs" value: { - dps: 625.9991226329519 + dps: 626.1960670388321 } } dps_results: { @@ -864,30 +864,30 @@ dps_results: { dps_results: { key: "TestArms-Settings-Orc-Arms P1-Basic-NoBuffs-LongMultiTarget" value: { - dps: 951.1083844504066 + dps: 944.8147269062799 } } dps_results: { key: "TestArms-Settings-Orc-Arms P1-Basic-NoBuffs-LongSingleTargetFullDebuffs" value: { - dps: 565.5618617650472 + dps: 566.7449549986579 } } dps_results: { key: "TestArms-Settings-Orc-Arms P1-Basic-NoBuffs-LongSingleTargetNoDebuffs" value: { - dps: 507.9886286116444 + dps: 507.3965254442103 } } dps_results: { key: "TestArms-Settings-Orc-Arms P1-Basic-NoBuffs-ShortSingleTargetFullDebuffs" value: { - dps: 707.9294366256211 + dps: 702.5281121007765 } } dps_results: { key: "TestArms-SwitchInFrontOfTarget-Default" value: { - dps: 593.972856580764 + dps: 595.5139204323319 } } diff --git a/sim/warrior/dps/TestFury.results b/sim/warrior/dps/TestFury.results index c234e5257..8b5d6c73b 100644 --- a/sim/warrior/dps/TestFury.results +++ b/sim/warrior/dps/TestFury.results @@ -870,13 +870,13 @@ dps_results: { dps_results: { key: "TestFury-Settings-Orc-Fury P1-Basic-NoBuffs-LongSingleTargetFullDebuffs" value: { - dps: 731.5260092543609 + dps: 733.5415699992277 } } dps_results: { key: "TestFury-Settings-Orc-Fury P1-Basic-NoBuffs-LongSingleTargetNoDebuffs" value: { - dps: 631.2384530915724 + dps: 634.7554604155265 } } dps_results: { diff --git a/sim/warrior/dps/rotation.go b/sim/warrior/dps/rotation.go index da9e30954..fe13bbc81 100644 --- a/sim/warrior/dps/rotation.go +++ b/sim/warrior/dps/rotation.go @@ -44,9 +44,6 @@ func (war *DpsWarrior) doRotation(sim *core.Simulation) { return } - isExecutePhase := sim.IsExecutePhase() - canSlam := war.Rotation.UseSlam && !isExecutePhase || war.Rotation.UseSlamDuringExecute - if war.castSlamAt != 0 { if sim.CurrentTime < war.castSlamAt { return @@ -64,6 +61,8 @@ func (war *DpsWarrior) doRotation(sim *core.Simulation) { } // If using a GCD will clip the next slam, only allow high priority spells like BT/MS/WW/debuffs. + isExecutePhase := sim.IsExecutePhase() + canSlam := war.Rotation.UseSlam && (!isExecutePhase || war.Rotation.UseSlamDuringExecute) highPrioSpellsOnly := canSlam && sim.CurrentTime+core.GCDDefault-war.slamGCDDelay > war.AutoAttacks.MainhandSwingAt+war.slamLatency if isExecutePhase { @@ -143,9 +142,7 @@ func (war *DpsWarrior) executeRotation(sim *core.Simulation, highPrioSpellsOnly } } - if war.Rotation.UseHsDuringExecute { - war.tryQueueHsCleave(sim) - } + war.tryQueueHsCleave(sim) } func (war *DpsWarrior) shouldSunder(sim *core.Simulation) bool { @@ -252,6 +249,10 @@ func (war *DpsWarrior) tryMaintainDebuffs(sim *core.Simulation) bool { } func (war *DpsWarrior) tryQueueHsCleave(sim *core.Simulation) { + if sim.IsExecutePhase() && !war.Rotation.UseHsDuringExecute { + return + } + if war.ShouldQueueHSOrCleave(sim) { war.QueueHSOrCleave(sim) } diff --git a/sim/warrior/recklessness.go b/sim/warrior/recklessness.go index 65001bd27..8468ecaa0 100644 --- a/sim/warrior/recklessness.go +++ b/sim/warrior/recklessness.go @@ -53,5 +53,8 @@ func (warrior *Warrior) RegisterRecklessnessCD() { warrior.AddMajorCooldown(core.MajorCooldown{ Spell: reckSpell, Type: core.CooldownTypeDPS, + CanActivate: func(sim *core.Simulation, character *core.Character) bool { + return warrior.StanceMatches(BerserkerStance) + }, }) }