Bastion aims to serve as a highly performant, simplisitic, and expandable World of Warcraft data visualization framework.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Bastion/src/Unit/Unit.lua

1621 lines
44 KiB

---@type Tinkr
local Tinkr,
---@class Bastion
Bastion = ...
-- Create a new Unit class
---@class Bastion.Unit
---@field id boolean | number
---@field ttd_ticker false | cbObject
---@field unit? TinkrObjectReference
---@field aura_table Bastion.AuraTable | nil
local Unit = {
---@type Bastion.Cache
cache = nil,
aura_table = nil,
---@type UnitId | WowGameObject
unit = nil,
ttd_ticker = false,
ttd = 0,
id = false, --[[ @asnumber ]]
watching_for_swings = false,
health = {}
}
function Unit:UpdateHealth()
if #self.health > 60 then
table.remove(self.health, 1)
end
table.insert(self.health, {
time = GetTime(),
percent = self:GetHP(),
health = self:GetHealth(),
maxHealth =
self:GetMaxHealth()
})
end
function Unit:__index(k)
local response = Bastion.ClassMagic:Resolve(Unit, k)
if k == "unit" then
return rawget(self, k)
end
if response == nil then
response = rawget(self, k)
end
if response == nil then
error("Unit:__index: " .. k .. " does not exist")
end
return response
end
-- Equals
---@param other Bastion.Unit
---@return boolean
function Unit:__eq(other)
return UnitIsUnit(self:GetOMToken(), other:GetOMToken())
end
-- tostring
---ToString
---
---```lua
---print(Unit:New('player'))
---```
---@return string
function Unit:__tostring()
return "Bastion.__Unit(" .. tostring(self:GetOMToken()) .. ")" .. " - " .. (self:GetName() or "")
end
-- Constructor
---@param unit? TinkrObjectReference
---@return Bastion.Unit
function Unit:New(unit)
---@class Bastion.Unit
local self = setmetatable({}, Unit)
self.unit = unit
self.last_shadow_techniques = 0
self.swings_since_sht = 0
self.last_off_attack = 0
self.last_main_attack = 0
self.last_combat_time = 0
self.cache = Bastion.Cache:New()
self.aura_table = Bastion.AuraTable:New(self)
self.regression_history = {}
self.health = {}
return self
end
-- Check if the unit is valid
---@return boolean
function Unit:IsValid()
return self:GetOMToken() ~= nil and self:Exists()
end
-- Check if the unit exists
---@return boolean
function Unit:Exists()
return Object(self:GetOMToken()) ~= false
end
-- Get the units token
function Unit:Token()
return self:GetOMToken()
end
-- Get the units name
function Unit:GetName()
local unitName, realm = UnitName(self:GetOMToken())
return unitName
end
-- Get the units GUID
function Unit:GetGUID()
return ObjectGUID(self:GetOMToken())
end
-- Get the units health
---@return number
function Unit:GetHealth()
return UnitHealth(self:GetOMToken())
end
-- Get the units max health
---@return number
function Unit:GetMaxHealth()
return UnitHealthMax(self:GetOMToken())
end
-- Return Health Percent as an integer (0-100)
---@return number
function Unit:GetHP()
return self:GetHealth() / self:GetMaxHealth() * 100
end
-- Get realized health
---@return number
function Unit:GetRealizedHealth()
return self:GetHealth() - self:GetHealAbsorbedHealth()
end
-- get realized health percentage
---@return number
function Unit:GetRealizedHP()
return self:GetRealizedHealth() / self:GetMaxHealth() * 100
end
-- Get the abosorbed unit health
---@return number
function Unit:GetHealAbsorbedHealth()
return UnitGetTotalHealAbsorbs(self:GetOMToken())
end
-- Return Health Percent as an integer (0-100)
---@return number
function Unit:GetHealthPercent()
return self:GetHP()
end
-- Get the units power type
---@return Enum.PowerType
function Unit:GetPowerType()
return select(1, UnitPowerType(self:GetOMToken()))
end
-- Get the units power
---@param powerType? number
---@return number
function Unit:GetPower(powerType)
local powerType = powerType or self:GetPowerType()
return UnitPower(self:GetOMToken(), powerType)
end
-- Get the units max power
---@param powerType? number
---@return number
function Unit:GetMaxPower(powerType)
local powerType = powerType or self:GetPowerType()
return UnitPowerMax(self:GetOMToken(), powerType)
end
-- Get the units power percentage
---@param powerType number | nil
---@return number
function Unit:GetPP(powerType)
local powerType = powerType or self:GetPowerType()
return self:GetPower(powerType) / self:GetMaxPower(powerType) * 100
end
-- Get the units power deficit
---@param powerType number | nil
---@return number
function Unit:GetPowerDeficit(powerType)
local powerType = powerType or self:GetPowerType()
return self:GetMaxPower(powerType) - self:GetPower(powerType)
end
-- Get the units position
function Unit:GetPosition()
local x, y, z = ObjectPosition(self:GetOMToken())
return Bastion.Vector3:New(x, y, z)
end
-- Get the units distance from another unit
---@param unit Bastion.Unit
---@return number
function Unit:GetDistance(unit)
local pself = self:GetPosition()
local punit = unit:GetPosition()
return pself:Distance(punit)
end
-- Is the unit dead
---@return boolean
function Unit:IsDead()
return UnitIsDeadOrGhost(self:GetOMToken())
end
-- Is the unit alive
---@return boolean
function Unit:IsAlive()
return not UnitIsDeadOrGhost(self:GetOMToken())
end
-- Is the unit a pet
---@return boolean
function Unit:IsPet()
return UnitIsUnit(self:GetOMToken(), "pet")
end
function Unit:IsOtherPet()
local petName = self:GetName()
local ownerName = ""
ownerName = (
string.match(petName, string.gsub(UNITNAME_TITLE_PET, "%%s", "(%.*)"))
or string.match(petName, string.gsub(UNITNAME_TITLE_MINION, "%%s", "(%.*)"))
or string.match(petName, string.gsub(UNITNAME_TITLE_GUARDIAN, "%%s", "(%.*)"))
)
return ownerName ~= nil
end
-- Is the unit a friendly unit
---@return boolean
function Unit:IsFriendly()
return UnitIsFriend("player", self:GetOMToken())
end
-- IsEnemy
---@return boolean
function Unit:IsEnemy()
return UnitCanAttack("player", self:GetOMToken())
end
-- Is the unit a hostile unit
---@return boolean
function Unit:IsHostile()
return UnitCanAttack(self:GetOMToken(), "player")
end
---@param unit Bastion.Unit
function Unit:GetReaction(unit)
return UnitReaction(unit:GetOMToken(), self:GetOMToken())
end
-- Is the unit a boss
---@return boolean
function Unit:IsBoss()
if UnitClassification(self:GetOMToken()) == "worldboss" then
return true
end
for i = 1, 5 do
local bossGUID = UnitGUID("boss" .. i)
if self:GetGUID() == bossGUID then
return true
end
end
return false
end
---@return UnitIds
function Unit:GetOMToken()
if not self.unit then
return "none"
end
return self.unit:unit()
end
-- Is the unit a target
---@return boolean
function Unit:IsTarget()
return UnitIsUnit(self:GetOMToken(), "target")
end
-- Is the unit a focus
---@return boolean
function Unit:IsFocus()
return UnitIsUnit(self:GetOMToken(), "focus")
end
-- Is the unit a mouseover
---@return boolean
function Unit:IsMouseover()
return UnitIsUnit(self:GetOMToken(), "mouseover")
end
-- Is the unit a tank
---@return boolean
function Unit:IsTank()
return UnitGroupRolesAssigned(self:GetOMToken()) == "TANK"
end
-- Is the unit a healer
---@return boolean
function Unit:IsHealer()
return UnitGroupRolesAssigned(self:GetOMToken()) == "HEALER"
end
-- Is the unit a damage dealer
---@return boolean
function Unit:IsDamage()
return UnitGroupRolesAssigned(self:GetOMToken()) == "DAMAGER"
end
-- Get the units role
---@return "TANK" | "HEALER" | "DAMAGER" | "NONE"
function Unit:GetRole()
return UnitGroupRolesAssigned(self:GetOMToken())
end
---@return number | boolean
function Unit:GetSpecializationID()
if CanInspect(self:GetOMToken(), false) then
return ObjectSpecializationID(self:GetOMToken())
end
return false
end
---@param fallback? boolean
---@return "TANK" | "HEALER" | "DAMAGER" | "NONE" | false
function Unit:GetSpecializationRole(fallback)
local specID = self:GetSpecializationID()
if type(specID) == "number" then
return GetSpecializationRoleByID(specID)
end
return fallback and self:GetRole() or false
end
-- Is the unit a player
---@return boolean
function Unit:IsPlayer()
return UnitIsPlayer(self:GetOMToken())
end
-- Is the unit a player controlled unit
---@return boolean
function Unit:IsPCU()
return UnitPlayerControlled(self:GetOMToken())
end
-- Get if the unit is affecting combat
---@return boolean
function Unit:IsAffectingCombat()
return UnitAffectingCombat(self:GetOMToken())
end
-- Get the units class id
---@return Bastion.Class
function Unit:GetClass()
local locale, class, classID = UnitClass(self:GetOMToken())
return Bastion.Class:New(locale, class, classID)
end
-- Get the units auras
---@return Bastion.AuraTable
function Unit:GetAuras()
return self.aura_table
end
-- Get the raw unit
---@return string
function Unit:GetRawUnit()
return self:GetOMToken()
end
local isClassicWow = select(4, GetBuildInfo()) < 40000
-- Check if two units are in melee
-- function Unit:InMelee(unit)
-- return UnitInMelee(self:GetOMToken(), unit:GetOMToken())
-- end
local losFlag = bit.bor(0x1, 0x10)
-- Check if the unit can see another unit
---@param unit Bastion.Unit
---@return boolean
function Unit:CanSee(unit)
-- mechagon smoke cloud
-- local mechagonID = 2097
-- local smokecloud = 298602
-- local name, instanceType, difficultyID, difficultyName, maxPlayers, dynamicDifficulty, isDynamic, instanceID, instanceGroupSize, LfgDungeonID =
-- GetInstanceInfo()
-- otherUnit = otherUnit and otherUnit or "player"
-- if instanceID == 2097 then
-- if (self:debuff(smokecloud, unit) and not self:debuff(smokecloud, otherUnit))
-- or (self:debuff(smokecloud, otherUnit) and not self:debuff(smokecloud, unit))
-- then
-- return false
-- end
-- end
local ignoreLoS = {
[98696] = true -- Illysanna Ravencrest (BRH)
}
if not unit:IsPlayer() then
local id = unit:GetID()
if id and ignoreLoS[id] then
return true
end
end
local ax, ay, az = ObjectPosition(self:GetOMToken())
local ah = ObjectHeight(self:GetOMToken())
local attx, atty, attz = GetUnitAttachmentPosition(unit:GetOMToken(), 34)
if not attx or not ax then
return false
end
if not ah then
return false
end
if (ax == 0 and ay == 0 and az == 0) or (attx == 0 and atty == 0 and attz == 0) then
return true
end
if not attx or not ax then
return false
end
local x, y, z = TraceLine(ax, ay, az + ah, attx, atty, attz, losFlag)
if x ~= 0 or y ~= 0 or z ~= 0 then
return false
else
return true
end
end
-- Check if the unit is casting a spell
---@return boolean
function Unit:IsCasting()
return UnitCastingInfo(self:GetOMToken()) ~= nil
end
---@param percent number
---@return number
function Unit:GetTimeCastIsAt(percent)
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(
self:GetOMToken())
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self
:GetOMToken())
end
if name and startTimeMS and endTimeMS then
local castLength = endTimeMS - startTimeMS
local startTime = startTimeMS / 1000
local timeUntil = (castLength / 1000) * (percent / 100)
return startTime + timeUntil
end
return 0
end
-- Get Casting or channeling spell
---@return Bastion.Spell | boolean
function Unit:GetCastingOrChannelingSpell()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(
self:GetOMToken())
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self
:GetOMToken())
end
if name then
return Bastion.Globals.SpellBook:GetSpell(spellId)
end
return false
end
-- Get the end time of the cast or channel
---@return number
function Unit:GetCastingOrChannelingEndTime()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(
self:GetOMToken())
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self
:GetOMToken())
end
if name then
return endTimeMS / 1000
end
return 0
end
-- Check if the unit is channeling a spell
---@return boolean
function Unit:IsChanneling()
return UnitChannelInfo(self:GetOMToken()) ~= nil
end
-- Check if the unit is casting or channeling a spell
---@return boolean
function Unit:IsCastingOrChanneling()
return self:IsCasting() or self:IsChanneling()
end
---@return Bastion.Unit
function Unit:CastTarget()
---@diagnostic disable-next-line: param-type-mismatch
return self:IsCastingOrChanneling() and Bastion.UnitManager:Get(ObjectCastingTarget(self:GetOMToken())) or
Bastion.UnitManager:Get("none")
end
---@param unit Bastion.Unit
---@return boolean
function Unit:CastTargetIsUnit(unit)
return self:IsCastingOrChanneling() and self:CastTarget():IsUnit(unit)
end
---@return boolean
function Unit:IsImmobilized()
return bit.band(self:GetMovementFlag(), 0x400) > 0
end
-- Check if the unit can attack the target
---@param unit Bastion.Unit
---@return boolean
function Unit:CanAttack(unit)
return UnitCanAttack(self:GetOMToken(), unit:GetOMToken())
end
---@return number
function Unit:GetChannelOrCastPercentComplete()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(
self:GetOMToken())
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self
:GetOMToken())
end
if name and startTimeMS and endTimeMS then
local start = startTimeMS / 1000
local finish = endTimeMS / 1000
local current = GetTime()
return ((current - start) / (finish - start)) * 100
end
return 0
end
-- Check if unit is interruptible
---@return boolean
function Unit:IsInterruptible()
local name, text, texture, startTimeMS, endTimeMS, isTradeSkill, castID, notInterruptible, spellId = UnitCastingInfo(
self:GetOMToken())
if not name then
name, text, texture, startTimeMS, endTimeMS, isTradeSkill, notInterruptible, spellId = UnitChannelInfo(self
:GetOMToken())
end
if name then
return not notInterruptible
end
return false
end
-- Check if unit is interruptible
---@param percent? number
---@param ignoreInterruptible? boolean
---@return boolean
function Unit:IsInterruptibleAt(percent, ignoreInterruptible)
if not ignoreInterruptible and not self:IsInterruptible() then
return false
end
local percent = percent or math.random(2, 20)
local castPercent = self:GetChannelOrCastPercentComplete()
if castPercent >= percent then
return true
end
return false
end
-- Get the number of enemies in a given range of the unit and cache the result for .5 seconds
---@param range number
---@return number
function Unit:GetEnemies(range)
local enemies = self.cache:Get("enemies_" .. range)
if enemies then
return enemies
end
local count = 0
Bastion.UnitManager:EnumEnemies(function(unit)
if not self:IsUnit(unit) and self:IsWithinCombatDistance(unit, range) and unit:IsAlive() and self:CanSee(unit) and unit:IsEnemy() then
count = count + 1
end
return false
end)
self.cache:Set("enemies_" .. range, count, 0.5)
return count
end
-- Get the number of melee attackers
---@param facing? boolean
---@return number
function Unit:GetMeleeAttackers(facing)
local enemies = self.cache:Get("melee_attackers")
if enemies then
return enemies
end
local count = 0
Bastion.UnitManager:EnumEnemies(function(unit)
if not self:IsUnit(unit) and unit:IsAlive() and self:CanSee(unit) and self:InMelee(unit) and unit:IsEnemy() and (not facing or self:IsFacing(unit)) then
count = count + 1
end
return false
end)
self.cache:Set("melee_attackers", count, 0.5)
return count
end
---@param distance number
---@param percent number
---@return number
function Unit:GetPartyHPAround(distance, percent)
local count = 0
Bastion.UnitManager:EnumFriends(function(unit)
if not self:IsUnit(unit) and unit:GetDistance(self) <= distance and unit:IsAlive() and self:CanSee(unit) and unit:GetHP() <= percent then
count = count + 1
end
return false
end)
return count
end
-- Is moving
---@return boolean
function Unit:IsMoving()
return GetUnitSpeed(self:GetOMToken()) > 0
end
---@return TinkrMovementFlags
function Unit:GetMovementFlag()
return ObjectMovementFlag(self:GetOMToken())
end
-- Is moving at all
---@return boolean
function Unit:IsMovingAtAll()
return ObjectMovementFlag(self:GetOMToken()) ~= 0
end
---@param unit? Bastion.Unit
---@return number
function Unit:GetComboPoints(unit)
if Tinkr.classic or Tinkr.era then
if not unit then
return 0
end
return GetComboPoints(self:GetOMToken(), unit:GetOMToken())
end
return UnitPower(self:GetOMToken(), Enum.PowerType.ComboPoints)
end
---@return number
function Unit:GetComboPointsMax()
if Tinkr.classic or Tinkr.era then
return 5
end
return UnitPowerMax(self:GetOMToken(), 4)
end
-- Get combopoints deficit
---@param unit Bastion.Unit | nil
---@return number
function Unit:GetComboPointsDeficit(unit)
if Tinkr.classic or Tinkr.era then
return self:GetComboPointsMax() - self:GetComboPoints(unit)
end
return self:GetComboPointsMax() - self:GetComboPoints()
end
-- IsUnit
---@param unit Bastion.Unit
---@return boolean
function Unit:IsUnit(unit)
return UnitIsUnit(self:GetOMToken(), unit and unit:GetOMToken() or "none")
end
-- IsTanking
---@param unit Bastion.Unit
---@return boolean
function Unit:IsTanking(unit)
local isTanking, status, threatpct, rawthreatpct, threatvalue = UnitDetailedThreatSituation(self:GetOMToken(),
unit:GetOMToken())
return isTanking
end
-- IsFacing
---@param unit Bastion.Unit
---@return boolean
function Unit:IsFacing(unit)
local rot = ObjectRotation(self:GetOMToken())
local x, y, z = ObjectPosition(self:GetOMToken())
local x2, y2, z2 = ObjectPosition(unit:GetOMToken())
if not x or not x2 or not rot then
return false
end
---@diagnostic disable-next-line: deprecated
local angle = math.atan2(y2 - y, x2 - x) - rot
angle = math.deg(angle)
angle = angle % 360
if angle > 180 then
angle = angle - 360
end
return math.abs(angle) < 90
end
-- IsBehind
---@param unit Bastion.Unit
---@return boolean
function Unit:IsBehind(unit)
local rot = ObjectRotation(unit:GetOMToken())
local x, y, z = ObjectPosition(unit:GetOMToken())
local x2, y2, z2 = ObjectPosition(self:GetOMToken())
if not x or not x2 then
return false
end
---@diagnostic disable-next-line: deprecated
local angle = math.atan2(y2 - y, x2 - x) - rot
angle = math.deg(angle)
angle = angle % 360
if angle > 180 then
angle = angle - 360
end
return math.abs(angle) > 90
end
-- IsInfront
---@param unit Bastion.Unit
---@return boolean
function Unit:IsInfront(unit)
return not self:IsBehind(unit)
end
---@return number
function Unit:GetMeleeBoost()
local meleeBoost = 0
if IsPlayerSpell(197524) then
local astralInfluenceNode = C_Traits.GetNodeInfo(C_ClassTalents.GetActiveConfigID() or 0, 82210)
local currentSpec = select(1, GetSpecializationInfo(GetSpecialization()))
if astralInfluenceNode then
local currentRank = astralInfluenceNode.activeRank
if currentRank > 0 then
meleeBoost = ((currentSpec == 103 or currentSpec == 104) and 1 or 3) + (currentRank == 2 and 2 or 0)
end
end
elseif IsPlayerSpell(196924) then
meleeBoost = 3
end
return meleeBoost
end
function Unit:GetModelId()
return ObjectModelId(self:GetOMToken())
end
-- Melee calculation
-- float fMaxDist = fmaxf((float)(*(float*)((uintptr_t)this + 0x1BF8) + 1.3333) + *(float*)((uintptr_t)target + 0x1BF8), 5.0);
-- fMaxDist = fMaxDist + 1.0;
-- Vector3 myPos = ((WoWGameObject*)this)->GetPosition();
-- Vector3 targetPos = ((WoWGameObject*)target)->GetPosition();
-- return ((myPos.x - targetPos.x) * (myPos.x - targetPos.x)) + ((myPos.y - targetPos.y) * (myPos.y - targetPos.y)) + ((myPos.z - targetPos.z) * (myPos.z - targetPos.z)) <= (float)(fMaxDist * fMaxDist);
-- InMelee
---@param unit Bastion.Unit
---@return boolean
function Unit:InMelee(unit)
local x, y, z = ObjectPosition(self:GetOMToken())
local x2, y2, z2 = ObjectPosition(unit:GetOMToken())
if not x or not x2 then
return false
end
local scr = ObjectCombatReach(self:GetOMToken())
local ucr = ObjectCombatReach(unit:GetOMToken())
if not scr or not ucr then
return false
end
local dist = math.sqrt((x - x2) ^ 2 + (y - y2) ^ 2 + (z - z2) ^ 2)
local maxDist = math.max((scr + 1.3333) + ucr, 5.0)
maxDist = maxDist + 1.0 + self:GetMeleeBoost()
return dist <= maxDist
end
-- Get object id
---@return number
function Unit:GetID()
if self.id ~= false then
---@type number
return self.id
end
self.id = ObjectID(self:GetOMToken())
---@type number
return self.id or 0
end
-- In party
---@return boolean
function Unit:IsInParty()
return UnitInParty(self:GetOMToken())
end
-- In party
---@return boolean
function Unit:IsInRaid()
return UnitInRaid(self:GetOMToken()) ~= nil
end
---@return boolean
function Unit:IsInPartyOrRaid()
return self:IsInParty() or self:IsInRaid()
end
-- Linear regression between time and percent to something
---@param time table
---@param percent table
---@return number, number
function Unit:LinearRegression(time, percent)
local x = time
local y = percent
local n = #x
local sum_x = 0
local sum_y = 0
local sum_xy = 0
local sum_xx = 0
local sum_yy = 0
for i = 1, n do
sum_x = sum_x + x[i]
sum_y = sum_y + y[i]
sum_xy = sum_xy + x[i] * y[i]
sum_xx = sum_xx + x[i] * x[i]
sum_yy = sum_yy + y[i] * y[i]
end
local slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x)
local intercept = (sum_y - slope * sum_x) / n
return slope, intercept
end
-- Use linear regression to get the health percent at a given time in the future
---@param time number
---@return number
function Unit:PredictHealth(time)
local x = {}
local y = {}
if #self.regression_history > 60 then
table.remove(self.regression_history, 1)
end
table.insert(self.regression_history, { time = GetTime(), percent = self:GetHP() })
for i = 1, #self.regression_history do
local entry = self.regression_history[i]
table.insert(x, entry.time)
table.insert(y, entry.percent)
end
local slope, intercept = self:LinearRegression(x, y)
return slope * (GetTime() + time) + intercept
end
-- Use linear regression to guess the time until a given health percent
---@param percent number
---@return number
function Unit:PredictTime(percent)
local x = {}
local y = {}
if #self.regression_history > 60 then
table.remove(self.regression_history, 1)
end
table.insert(self.regression_history, { time = GetTime(), percent = self:GetHP() })
for i = 1, #self.regression_history do
local entry = self.regression_history[i]
table.insert(x, entry.time)
table.insert(y, entry.percent)
end
local slope, intercept = self:LinearRegression(x, y)
return (percent - intercept) / slope
end
-- Start time to die ticker
function Unit:StartTTDTicker()
if self.ttd_ticker then
return
end
self.ttd_ticker = C_Timer.NewTicker(0.5, function()
local timeto = self:PredictTime(0) - GetTime()
self.ttd = timeto
end)
end
-- Time until death
---@return number
function Unit:TimeToDie()
if self:IsDead() then
self.regression_history = {}
if self.ttd_ticker then
self.ttd_ticker:Cancel()
self.ttd_ticker = false
end
return 0
end
if not self.ttd_ticker then
self:StartTTDTicker()
end
-- If there's not enough data to make a prediction return 0 unless the unit has more than 5 million health
if #self.regression_history < 5 and self:GetMaxHealth() < 5000000 then
return 0
end
-- if the unit has more than 5 million health but there's not enough data to make a prediction we can assume there's roughly 250000 damage per second and estimate the time to die
if #self.regression_history < 5 and self:GetMaxHealth() > 5000000 then
return self:GetMaxHealth() /
250000 -- 250000 is an estimate of the average damage per second a well geared group will average
end
if self.ttd ~= self.ttd or self.ttd < 0 or self.ttd == math.huge then
return 0
end
return self.ttd
end
---@param lower number
---@param upper? number
---@return boolean
function Unit:TTD(lower, upper)
upper = type(upper) == "nil" and lower or upper
local ttd = self:TimeToDie()
return ttd >= lower and ttd <= upper
end
-- Set combat time if affecting combat and return the difference between now and the last time
---@return number
function Unit:GetCombatTime()
return GetTime() - self.last_combat_time
end
-- Set last combat time
---@param time number
function Unit:SetLastCombatTime(time)
self.last_combat_time = time
end
-- Get combat odds (if the last combat time is less than 1 minute ago return 1 / time, else return 0)
-- the closer to 0 the more likely the unit is to be in combat (0 = 100%) 60 = 0%
---@return number
function Unit:InCombatOdds()
local time = self:GetCombatTime()
local percent = 1 - (time / 60)
return percent * 100
end
-- Get units gcd time
---@return number
function Unit:GetGCD()
local start, duration = GetSpellCooldown(61304)
return start == 0 and 0 or duration - (GetTime() - start)
end
-- Get units max gcd time
--[[
The GCD without Haste is 1.5 seconds
With 50% Haste the GCD is 1 second
With 100% Haste the GCD is 0.5 seconds
The GCD won't drop below 1 second
More than 50% Haste will drop a spell below 1 second
]]
---@return number
function Unit:GetMaxGCD()
local haste = UnitSpellHaste(self:GetOMToken())
if haste > 50 then
haste = 50
end
-- if the unit uses focus their gcd is 1.0 seconds not 1.5
local base = 1.5
if self:GetPowerType() == 3 then
base = 1.0
end
return base / (1 + haste / 100)
end
-- IsStealthed
---@return boolean
function Unit:IsStealthed()
local Stealth = Bastion.Globals.SpellBook:GetSpell(1784)
local Vanish = Bastion.Globals.SpellBook:GetSpell(1856)
local ShadowDance = Bastion.Globals.SpellBook:GetSpell(185422)
local Subterfuge = Bastion.Globals.SpellBook:GetSpell(115192)
local Shadowmeld = Bastion.Globals.SpellBook:GetSpell(58984)
local Sepsis = Bastion.Globals.SpellBook:GetSpell(328305)
local stealthList = Bastion.List:New({
Stealth,
Vanish,
ShadowDance,
Subterfuge,
Shadowmeld,
Sepsis,
})
return self:GetAuras():FindAnyOf(stealthList):IsUp()
end
-- Get unit swing timers
---@return number, number
function Unit:GetSwingTimers()
local main_speed, off_speed = UnitAttackSpeed(self:GetOMToken())
local main_speed = main_speed or 2
local off_speed = off_speed or 2
local main_speed_remains = main_speed - (GetTime() - self.last_main_attack)
local off_speed_remains = off_speed - (GetTime() - self.last_off_attack)
if main_speed_remains < 0 then
main_speed_remains = 0
end
if off_speed_remains < 0 then
off_speed_remains = 0
end
return main_speed_remains, off_speed_remains
end
function Unit:WatchForSwings()
if not self.watching_for_swings then
Bastion.Globals.EventManager:RegisterWoWEvent("COMBAT_LOG_EVENT_UNFILTERED", function()
local _, subtype, _, sourceGUID, sourceName, _, _, destGUID, destName, destFlags, _, spellID, spellName, _, amount, interrupt, a, b, c, d, offhand, multistrike =
CombatLogGetCurrentEventInfo()
if sourceGUID == self:GetGUID() and subtype then
if subtype == "SPELL_ENERGIZE" and spellID == 196911 then
self.last_shadow_techniques = GetTime()
self.swings_since_sht = 0
end
if subtype:sub(1, 5) == "SWING" and not multistrike then
if subtype == "SWING_MISSED" then
offhand = spellName
end
local now = GetTime()
if now > self.last_shadow_techniques + 3 then
self.swings_since_sht = self.swings_since_sht + 1
end
if offhand then
self.last_off_attack = GetTime()
else
self.last_main_attack = GetTime()
end
end
end
end)
self.watching_for_swings = true
end
end
-- ismounted
---@return boolean
function Unit:IsMounted()
local mountedFormId = {
[3] = true, -- Mount / Travel Form
[27] = true, -- Swift Flight Form
[29] = true, -- Flight Form
}
return UnitIsMounted(self.unit) or (GetShapeshiftFormID() and mountedFormId[GetShapeshiftFormID()])
end
-- isindoors
---@return boolean
function Unit:IsOutdoors()
return ObjectIsOutdoors(self.unit)
end
-- IsIndoors
---@return boolean
function Unit:IsIndoors()
return not ObjectIsOutdoors(self.unit)
end
-- IsSubmerged
---@return boolean
function Unit:IsSubmerged()
return ObjectIsSubmerged(self.unit)
end
-- IsDry
---@return boolean
function Unit:IsDry()
return not ObjectIsSubmerged(self.unit)
end
-- The unit stagger amount
---@return number
function Unit:GetStagger()
return UnitStagger(self:GetOMToken())
end
-- The percent of health the unit is currently staggering
---@return number
function Unit:GetStaggerPercent()
local stagger = self:GetStagger()
local max_health = self:GetMaxHealth()
return (stagger / max_health) * 100
end
-- Get the units power regen rate
---@return number, number
function Unit:GetPowerRegen()
---@diagnostic disable-next-line: redundant-parameter
return GetPowerRegen(self:GetOMToken())
end
-- Get the units staggered health relation
---@return number
function Unit:GetStaggeredHealth()
local stagger = self:GetStagger()
local max_health = self:GetMaxHealth()
return (stagger / max_health) * 100
end
-- get the units combat reach
---@return number
function Unit:GetCombatReach()
return ObjectCombatReach(self:GetOMToken()) or 0
end
-- Get the units combat distance (distance - combat reach (realized distance))
---@return number
function Unit:GetCombatDistance(Target)
return self:GetDistance(Target) - Target:GetCombatReach()
end
-- Is the unit within distance of the target (combat reach + distance)
--- If the target is within 8 combat yards (8 + combat reach) of the unit
---@param Target Bastion.Unit
---@param Distance number
---@return boolean
function Unit:IsWithinCombatDistance(Target, Distance)
if not Target:Exists() then
return false
end
return self:GetDistance(Target) <= Distance + Target:GetCombatReach()
end
-- Check if the unit is within X yards (consider combat reach)
---@param Target Bastion.Unit
---@param Distance number
---@return boolean
function Unit:IsWithinDistance(Target, Distance)
return self:GetDistance(Target) <= Distance
end
-- Get the angle between the unit and the target in raidans
---@param Target Bastion.Unit
---@return number
function Unit:GetAngle(Target)
if not Target:Exists() then
return 0
end
local sp = self:GetPosition()
local tp = Target:GetPosition()
local an = Tinkr.Common.GetAnglesBetweenPositions(sp.x, sp.y, sp.z, tp.x, tp.y, tp.z)
return an
end
function Unit:GetFacing()
return ObjectRotation(self:GetOMToken()) or 0
end
-- Check if target is within a arc around the unit (angle, distance) accounting for a rotation of self
---@param Target Bastion.Unit
---@param Angle number
---@param Distance number
---@param rotation? number
---@return boolean
function Unit:IsWithinCone(Target, Angle, Distance, rotation)
if not Target:Exists() then
return false
end
local angle = self:GetAngle(Target)
rotation = rotation or self:GetFacing()
local diff = math.abs(angle - rotation)
if diff > math.pi then
diff = math.abs(diff - math.pi * 2)
end
return diff <= Angle and self:GetDistance(Target) <= Distance
end
---@return number
function Unit:GetEmpoweredStage()
local stage = 0
local _, _, _, startTime, _, _, _, spellID, _, numStages = UnitChannelInfo(self:GetOMToken())
if numStages and numStages > 0 then
startTime = startTime / 1000
local currentTime = GetTime()
local stageDuration = 0
for i = 1, numStages do
stageDuration = stageDuration + GetUnitEmpowerStageDuration((self:GetOMToken()), i - 1) / 1000
if startTime + stageDuration > currentTime then
break
end
stage = i
end
end
return stage
end
---@return boolean
function Unit:IsConnected()
return UnitIsConnected(self:GetOMToken())
end
---@return boolean
function Unit:HasIncomingRessurection()
---@diagnostic disable-next-line: return-type-mismatch
return self:IsDead() and UnitHasIncomingResurrection(self:GetOMToken())
end
---@return WowGameObject | false
function Unit:LootTarget()
return ObjectLootTarget(self:GetOMToken())
end
---@return boolean
function Unit:CanLoot()
return ObjectLootable(self:GetOMToken())
end
---@return boolean
function Unit:HasTarget()
return ObjectTarget(self:GetOMToken()) ~= false
end
---@return Bastion.Unit
function Unit:Target()
return self:HasTarget() and Bastion.UnitManager:Get(ObjectTarget(self:GetOMToken()):unit()) or
Bastion.UnitManager:Get("none")
end
local dummyUnits = {
-- City (SW, Orgri, ...)
[31146] = true, -- Raider's Training Dummy
[31144] = true, -- Training Dummy
[32666] = true, -- Training Dummy
[32667] = true, -- Training Dummy
[46647] = true, -- Training Dummy
[114832] = true, -- PvP Training Dummy
[153285] = true, -- Training Dummy
[153292] = true, -- Training Dummy
-- MoP Shrine of Two Moons
[67127] = true, -- Training Dummy
-- WoD Alliance Garrison
[87317] = true, -- Mage Tower Damage Training Dummy
[87318] = true, -- Mage Tower Damage Dungeoneer's Training Dummy (& Garrison)
[87320] = true, -- Mage Tower Damage Raider's Training Dummy
[88314] = true, -- Tanking Dungeoneer's Training Dummy
[88316] = true, -- Healing Training Dummy ----> FRIENDLY
-- WoD Horde Garrison
[87760] = true, -- Mage Tower Damage Training Dummy
[87761] = true, -- Mage Tower Damage Dungeoneer's Training Dummy (& Garrison)
[87762] = true, -- Mage Tower Damage Raider's Training Dummy
[88288] = true, -- Tanking Dungeoneer's Training Dummy
[88289] = true, -- Healing Training Dummy ----> FRIENDLY
-- Legion Druid Class Order Hall
[113964] = true, -- Raider's Training Dummy
[113966] = true, -- Dungeoneer's Training Dummy
-- Legion Mage Class Order Hall
[103397] = true, -- Greater Bullwark Construct
[103404] = true, -- Bullwark Construct
[103402] = true, -- Lesser Bullwark Construct
-- Legion Priest Class Order Hall
[107555] = true, -- Bound void Wraith
[107556] = true, -- Bound void Walker
-- Legion Rogue Class Order Hall
[92164] = true, -- Training Dummy
[92165] = true, -- Dungeoneer's Training Dummy
[92166] = true, -- Raider's Training Dummy
-- Legion Warlock Class Order Hall
[101956] = true, -- Rebellious Fel Lord
[102045] = true, -- Rebellious WrathGuard
[102048] = true, -- Rebellious Felguard
[102052] = true, -- Rebellious imp
-- BfA Dazar'Alor
[144081] = true, -- Training Dummy
[144082] = true, -- Training Dummy
[144085] = true, -- Training Dummy
[144086] = true, -- Raider's Training Dummy
-- BfA Boralus
[126781] = true, -- Training Dummy
[131983] = true, -- Raider's Training Dummy
[131989] = true, -- Training Dummy
[131992] = true, -- Dungeoneer's Training Dummy
-- Shadowlands Kyrian
[154564] = true, -- Valiant's Humility
[154567] = true, -- Purity's Cleaning
[154580] = true, -- Reinforced Guardian
[154583] = true, -- Starlwart Guardian
[154585] = true, -- Valiant's Resolve
[154586] = true, -- Stalwart Phalanx
[160325] = true, -- Humility's Obedience
-- Shadowlands Venthyr
[173942] = true, -- Training Dummy
[175449] = true, -- Raider's Training Dummy
[175450] = true, -- Dungeoneer's Training Dummy
[175451] = true, -- Dungeoneer's Tanking Dummy
[175452] = true, -- Raider's Tanking Dummy
[175455] = true, -- Cleave Training Dummy
[175456] = true, -- Swarm Training Dummy
[175462] = true, -- Sinfall Fiend
-- Shadowlands Night Fae
[174565] = true, -- Dungeoneer's Tanking Dummy
[174566] = true, -- Raider's Tanking Dummy
[174567] = true, -- Raider's Training Dummy
[174568] = true, -- Dungeoneer's Training Dummy
[174569] = true, -- Training Dummy
[174570] = true, -- Swarm Training Dummy
[174571] = true, -- Cleave Training Dummy
-- Shadowlands Necrolord
[174484] = true, -- Dungeoneer's Training Dummy
[174487] = true, -- Training Dummy
[174488] = true, -- Raider's Training Dummy
[174491] = true, -- Tanking Dummy
-- DargonFlight Valdrakken
[198594] = true, -- Cleave Training Dummy
[194648] = false, -- Training Dummy
[189632] = true, -- Animated Duelist
[194643] = true, -- Dungeoneer's Training Dummy
[194644] = true, -- Dungeoneer's Training Dummy
[197833] = true, -- PvP Training Dummy
[189617] = true, -- Boulderfist
[194649] = true, -- Normal Tank Dummy
-- DargonFlight Iskaara
[193563] = true, -- Training Dummy
-- Other
[65310] = true, -- Turnip Punching Bag (toy)
[66374] = true, -- Anatomical Dummy (toy)
[196394] = true, -- Tuskarr Training Dummy (toy)
[196406] = true, -- Rubbery Fish Head (toy)
[199057] = true, -- Black Dragon's Challenge Dummy (toy)
}
---@return boolean
function Unit:IsDummy()
local npcId = self:GetID()
return npcId and npcId >= 0 and dummyUnits[npcId] == true or false
end
---@param npcId? number
---@return boolean
function Unit:IsInBossList(npcId)
npcId = npcId or self:GetID()
for i = 1, 4 do
local thisUnit = Bastion.UnitManager:Get(string.format("boss%d", i))
if thisUnit:Exists() and thisUnit:GetID() == npcId then
return true
end
end
return false
end
---@param npcId number
---@return number
function Unit:SpecialTTDPercentage(npcId)
local specialTTDPercentage = Bastion.TimeToDie.specialTTDPercentageData[npcId]
if not specialTTDPercentage then return 0 end
if type(specialTTDPercentage) == "number" then
return specialTTDPercentage
end
return specialTTDPercentage(self)
end
---@param npcId? number
---@param hp? number
---@return boolean
function Unit:CheckHPFromBossList(npcId, hp)
npcId = npcId or self:GetID()
local thisHP = hp or 100
for i = 1, 4 do
local thisUnit = Bastion.UnitManager:Get(string.format("boss%d", i))
if thisUnit:Exists() and thisUnit:GetID() == npcId and thisUnit:GetHealthPercent() <= thisHP then
return true
end
end
return false
end
---@param percentage number
---@param minSamples? number
---@return Bastion.TimeToDie.Enums | integer
function Unit:TimeToX(percentage, minSamples)
--if self:IsDummy() then return 6666 end
if self:IsPlayer() and Bastion.UnitManager:Get("player"):CanAttack(self) then return Bastion.TimeToDie.Enums.PLAYER end
local seconds = 0
local unitGuid = self:GetGUID()
if not unitGuid then
return Bastion.TimeToDie.Enums.NO_GUID
end
local unitTable = Bastion.TimeToDie.Units[unitGuid]
-- Simple linear regression
-- ( E(x^2) E(x) ) ( a ) ( E(xy) )
-- ( E(x) n ) ( b ) = ( E(y) )
-- Format of the above: ( 2x2 Matrix ) * ( 2x1 Vector ) = ( 2x1 Vector )
-- Solve to find a and b, satisfying y = a + bx
-- Matrix arithmetic has been expanded and solved to make the following operation as fast as possible
if unitTable then
minSamples = minSamples or 3
local history = unitTable.history
local n = #history
if n > minSamples then
local a, b = 0, 0
local ex2, ex, exy, ey = 0, 0, 0, 0
for i = 1, n do
local value = history[i]
local x, y = value.time, value.percentage
ex2 = ex2 + x * x
ex = ex + x
exy = exy + x * y
ey = ey + y
end
-- Invariant to find matrix inverse
local invariant = 1 / (ex2 * n - ex * ex)
-- Solve for a and b
a = (-ex * exy * invariant) + (ex2 * ey * invariant)
b = (n * exy * invariant) - (ex * ey * invariant)
if b ~= 0 then
-- Use best fit line to calculate estimated time to reach target health
seconds = (percentage - a) / b
-- Subtract current time to obtain "time remaining"
seconds = seconds - (GetTime() - unitTable.time)
if seconds < 0 then seconds = Bastion.TimeToDie.Enums.NEGATIVE_TTD end
end
end
end
return seconds
end
---@param minSamples? number
---@return Bastion.TimeToDie.Enums | integer
function Unit:TimeToDie2(minSamples)
if not self:Exists() then
return Bastion.TimeToDie.Enums.DOES_NOT_EXIST
end
local unitGuid = self:GetGUID()
if not unitGuid then
return Bastion.TimeToDie.Enums.NO_GUID
end
minSamples = minSamples or 3
---@type {TTD: {[number]: number}}
local unitInfo = Bastion.Globals.UnitInfo:IsCached(unitGuid) and
Bastion.Globals.UnitInfo:Get(unitGuid) or {}
local ttd = unitInfo.TTD
if not ttd then
ttd = {}
unitInfo.TTD = ttd
end
local v
if not ttd[minSamples] then
v = self:TimeToX(self:SpecialTTDPercentage(self:GetID()), minSamples)
if v >= 0 then
ttd[minSamples] = v
Bastion.Globals.UnitInfo:Set(unitGuid, unitInfo, .5)
end
end
return ttd[minSamples] or v
end
-- Get the boss unit TimeToDie
---@param minSamples? number
---@return Bastion.TimeToDie.Enums | integer
function Unit:BossTimeToDie(minSamples)
if self:IsInBossList() or self:IsDummy() then
return self:TimeToDie2(minSamples)
end
return Bastion.TimeToDie.Enums.DOES_NOT_EXIST
end
-- Get if the unit meets the TimeToDie requirements.
---@param operator CompareThisTable
---@param value number
---@param offset number
---@param valueThreshold number
---@param minSamples? number
---@return boolean
function Unit:FilteredTimeToDie(operator, value, offset, valueThreshold, minSamples)
local TTD = self:TimeToDie2(minSamples)
return TTD > -1 and TTD < valueThreshold and Bastion.Util:CompareThis(operator, TTD + (offset or 0), value) or false
end
-- Get if the boss unit meets the TimeToDie requirements.
---@param operator CompareThisTable
---@param value number
---@param offset number
---@param valueThreshold number
---@param minSamples? number
---@return boolean
function Unit:BossFilteredTimeToDie(operator, value, offset, valueThreshold, minSamples)
if self:IsInBossList() or self:IsDummy() then
return self:FilteredTimeToDie(operator, value, offset, valueThreshold, minSamples)
end
return false
end
-- Get if the Time To Die is Valid for an Unit (i.e. not returning a warning code).
---@param minSamples? number
---@return boolean
function Unit:TimeToDieIsNotValid(minSamples)
return self:TimeToDie2(minSamples) > -1
end
-- Get if the Time To Die is Valid for a boss Unit (i.e. not returning a warning code or not being a boss).
---@param minSamples? number
---@return boolean
function Unit:BossTimeToDieIsNotValid(minSamples)
if self:IsInBossList() then
return self:TimeToDieIsNotValid(minSamples)
end
return true
end
Bastion.Unit = Unit