---@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