Quantcast
-- Broker_RaidMakeup.lua
-- Written by KyrosKrane Sylvanblade (kyros@kyros.info)
-- Copyright (c) 2018 KyrosKrane Sylvanblade
-- Licensed under the MIT License, as per the included file.


--#########################################
--# Description
--#########################################

-- This add-on creates a LibDataBroker object that shows you the makeup of your raid (tanks, healers, and dps).
-- Requires an LDB display to show the info.
-- No configuration or setup.


--#########################################
--# Globals and utilities
--#########################################

-- Get a local reference to speed up execution.
local _G = _G
local string = string
local print = print
local setmetatable = setmetatable
local select = select
local type = type
local pairs = pairs

-- Define a global for our namespace
local BRM = { }


--#########################################
--# Frame for event handling
--#########################################

-- Create the frame to hold our event catcher, and the list of events.
BRM.Frame, BRM.Events = CreateFrame("Frame"), {}


--#########################################
--# Debugging setup
--#########################################

-- Debug settings
BRM.DebugMode = false

--@alpha@
BRM.DebugMode = true
--@end-alpha@


-- Print debug output to the chat frame.
function BRM:DebugPrint(...)
	if not BRM.DebugMode then return end

		print ("|cff" .. "a00000" .. "BRM Debug:|r", ...)
end -- BRM:DebugPrint


-- Print regular output to the chat frame.
function BRM:ChatPrint(...)
	print ("|cff" .. "0066ff" .. "BRM:|r", ...)
end -- BRM:DebugPrint


-- Debugging code to see what the hell is being passed in...
function BRM:PrintVarArgs(...)
	if not BRM.DebugMode then return end

	local n = select('#', ...)
	BRM:DebugPrint ("There are ", n, " items in varargs.")
	local msg
	for i = 1, n do
		msg = select(i, ...)
		BRM:DebugPrint ("Item ", i, " is ", msg)
	end
end -- BRM:PrintVarArgs()


-- Dumps a table into chat. Not intended for production use.
function BRM:DumpTable(tab, indent)
	if not BRM.DebugMode then return end

	if not indent then indent = 0 end
	if indent > 10 then
		BRM:DebugPrint("Recursion is at 11 already; aborting.")
		return
	end
	for k, v in pairs(tab) do
		local s = ""
		if indent > 0 then
			for i = 0, indent do
				s = s .. "    "
			end
		end
		if "table" == type(v) then
			s = s .. "Item " .. k .. " is sub-table."
			BRM:DebugPrint(s)
			indent = indent + 1
			BRM:DumpTable(v, indent)
			indent = indent - 1
		else
			s = s .. "Item " .. k .. " is " .. tostring(v)
			BRM:DebugPrint(s)
		end
	end
end -- BRM:DumpTable()


--#########################################
--# Icon class setup
--#########################################

-- The normal way of handling icons is to create a texture, load the icon file into that, set the region you want, and done.
-- However, we need to embed icons into a text string for display in the LDB label.
-- So, we create a custom object to hold icon information, and give it a method to create a text string to display that icon.
-- That text string can then be embedded in the LDB label to show the icon.

-- Create the object class
-- Adapted from http://lua-users.org/wiki/ObjectOrientationTutorial
local IconClass = {}
IconClass.__index = IconClass

setmetatable(IconClass, {
	__call = function (cls, ...)
		return cls.new(...)
	end,
})


-- Create the constructor
function IconClass.new(iconfile, IconFileX, IconFileY, StartX, EndX, StartY, EndY)
	local self = setmetatable({}, IconClass)

	-- The caller has to specify an iconfile before actually using the class, or the results will not be what is expected.
	self.IconFile = iconfile or ""	-- the game file that has the icon we want

	-- The initial values are sane defaults, but they should be updated for each icon.

	-- These settings control the display of the icon
	self.SizeOne = 0	-- SizeOne and SizeTwo jointly control the size of the icon. The logic is not intuitive.
	self.SizeTwo = nil	-- For more details on the SizeOne and SizeTwo parameters, see http://wowwiki.wikia.com/wiki/UI_escape_sequences#Textures
						-- For the purposes of our icons, they should always be 0 and nil (indicating a square sized to the height of the text).

	self.OffsetX = 0	-- This moves the image n pixels horizontally in the final display. For our icons, it's always 0.
	self.OffsetY = 0	-- This moves the image n pixels vertically in the final display. For our icons, it's always 0.

	-- These settings control the extraction of the icon from a bigger texture file
	self.IconFileX = IconFileX or 0	-- the total X (horizontal) pixels in the image file - not just the icon we want
	self.IconFileY = IconFileY or 0	-- the total Y (vertical) pixels in the image file
	self.StartX = StartX or 0		-- The starting point in the file where the icon begins, counted from the left border, in pixels
	self.EndX = EndX or 0			-- The ending point in the file where the icon ends, counted from the left border, in pixels
	self.StartY = StartY or 0		-- The starting point in the file where the icon begins, counted from the top border, in pixels
	self.EndY = EndY or 0			-- The ending point in the file where the icon ends, counted from the top border, in pixels

	return self
end -- IconClass.new()


function IconClass:GetIconStringInner()
	-- Icon strings effectively have to be built right to left, since if the rightmost parameters are required, then the ones to the left are also required.

	-- This function constructs the inner part of the icon string, without the control codes to cause it to display as an actual icon.
	-- This is mostly useful for debugging. In actual use, you'd want the GetIconString() function.

	local required = false -- tells us whether the remaining parameters are required

	local OutputString = "";

	if required or self.StartX > 0 or self.StartY > 0 or self.EndX > 0 or self.EndY > 0 then
		required = true
		OutputString = string.format(":%d:%d:%d:%d", self.StartX, self.EndX, self.StartY, self.EndY)
	end

	if required or self.IconFileX > 0 or self.IconFileY > 0 then
		required = true
		OutputString = string.format(":%d:%d%s", self.IconFileX, self.IconFileY, OutputString)
	end

	if required or self.OffsetX > 0 or self.OffsetY > 0 then
		required = true
		OutputString = string.format(":%d:%d%s", self.OffsetX, self.OffsetY, OutputString)
	end

	-- Size2 can be nil, so to avoid comparing or concatenating a nil, we have some special handling
	local localsizetwo = self.SizeTwo or ""
	if required or (self.SizeTwo and self.SizeTwo >= 0) then
		OutputString = string.format(":%s%s", localsizetwo, OutputString)
	end

	-- Size1 and the icon path are required.
	OutputString = string.format("%s:%d%s", self.IconFile, self.SizeOne, OutputString)

	return OutputString
end -- IconClass:GetIconStringInner()


function IconClass:GetIconString()
	-- This function wraps the icon string in the control code that causes it to display as an icon
	return string.format("\124T%s\124t", self:GetIconStringInner())
end -- IconClass:GetIconString()


-- @TODO: Add method to get the TexCoords of an icon for inclusion in the LDB object's iconCoords parameter.
-- See:
--	https://wow.gamepedia.com/API_Texture_SetTexCoord
--	https://github.com/tekkub/libdatabroker-1-1/wiki/Data-Specifications


--#########################################
--# Select the actual icons used
--#########################################

-- The icons to use when displaying in the broker display
BRM.MainIcon = IconClass("Interface\\Icons\\Inv_helm_robe_raidpriest_k_01") -- Placeholder icon to use until we determine the faction later.
BRM.AllianceIcon = IconClass("Interface\\Calendar\\UI-Calendar-Event-PVP02")
BRM.HordeIcon = IconClass("Interface\\Calendar\\UI-Calendar-Event-PVP01")

-- Role icons
BRM.TankIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-PORTRAITROLES.blp", 64, 64, 0, 0+19, 22, 22+19)
BRM.HealerIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-PORTRAITROLES.blp", 64, 64, 19, 19+19, 1, 1+19)
BRM.DPSIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-PORTRAITROLES.blp", 64, 64, 19, 19+19, 22, 22+19)
BRM.UnknownIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-ROLES.blp", 256, 256, 135, 135+64, 67, 67+64)

-- Icons I considered but didn't like
--BRM.AllianceIcon = "Interface\\Icons\\Inv_misc_head_human_02"
--BRM.HordeIcon = "Interface\\Icons\\Achievement_femalegoblinhead"
--BRM.HealerIcon = "Interface\\Icons\\Spell_holy_flashheal.blp"
--BRM.AllianceIcon = IconClass("Interface\\Icons\\Inv_tabard_a_78wrynnvanguard")
--BRM.HordeIcon = IconClass("Interface\\Icons\\Inv_tabard_a_77voljinsspear")
--BRM.TankIcon = IconClass("Interface\\Icons\\Inv_shield_06.blp")
--BRM.HealerIcon = IconClass("Interface\\Icons\\spell_chargepositive.blp")
--BRM.DPSIcon = IconClass("Interface\\Icons\\Inv_sword_27.blp")
--BRM.UnknownIcon = IconClass("Interface\\Icons\\Inv_misc_questionmark.blp")

-- These high res icons don't look very good when squished down to a broker display. The low-res ones above are better.
--BRM.TankIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-ROLES.blp", 256, 256, 0, 0+64, 68, 68+64)
--BRM.HealerIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-ROLES.blp", 256, 256, 68, 68+64, 0, 0+64)
--BRM.DPSIcon = IconClass("Interface\\LFGFRAME\\UI-LFG-ICON-ROLES.blp", 256, 256, 68, 68+64, 68, 68+64)


--#########################################
--# Constants
--#########################################

-- The strings that define the addon
BRM.ADDON_NAME="Broker_RaidMakeup" -- the internal addon name for LibStub and other addons
BRM.USER_ADDON_NAME="Broker_RaidMakeup" -- the name displayed to the user

-- The strings used by the game to represent the roles. I don't think these are localized in the game.
BRM.ROLE_HEALER = "HEALER"
BRM.ROLE_TANK = "TANK"
BRM.ROLE_DPS = "DAMAGER"
BRM.ROLE_NONE = "NONE"

-- The faction strings. Again, probably not localized
BRM.FACTION_ALLIANCE = "Alliance"
BRM.FACTION_HORDE = "Horde"

-- The version of this add-on
BRM.Version = "@project-version@"


--@alpha@
--#########################################
--# Slash command handling - only for testing
--#########################################

SLASH_BRM1 = "/brm"
SlashCmdList.BRM = function (...) BRM:HandleCommandLine(...) end

function BRM:HandleCommandLine()
	BRM.DebugMode = not BRM.DebugMode
	BRM:ChatPrint("Printing debug statements is now " .. (BRM.DebugMode and "on" or "off") .. ".")
end
--@end-alpha@


--#########################################
--# Variables for tracking raid members
--#########################################

BRM.TankCount = 0
BRM.HealerCount = 0
BRM.DPSCount = 0
BRM.UnknownCount = 0
BRM.TotalCount = 0

-- The game is firing the ACTIVE_TALENT_GROUP_CHANGED event before the PLAYER_ENTERING_WORLD event.
-- Since we're not fully in the world yet at that point, the group and raid query functions are returning unexpected results.
-- So, we use this variable to track whether the add-on is loaded and active.
-- We turn it on in the PLAYER_ENTERING_WORLD event.
BRM.IsActive = false

--#########################################
--# Create string for count display
--#########################################

function BRM:GetDisplayString()
	local OutputString = string.format("%d %s %d %s %d %s %d", BRM.TotalCount, BRM.TankIcon:GetIconString(), BRM.TankCount, BRM.HealerIcon:GetIconString(), BRM.HealerCount, BRM.DPSIcon:GetIconString(), BRM.DPSCount)
	if BRM.UnknownCount > 0 then
		OutputString = string.format("%s %s %d", OutputString, BRM.UnknownIcon:GetIconString(), BRM.UnknownCount)
	end
	return OutputString
end -- BRM:GetDisplayString()


function BRM:IncrementRole(role)
	-- Handle case of nil roles - can happen when the game has not fully loaded and we try to do a role check
	if not role then role = "unknown" end

	if BRM.ROLE_HEALER == role then
		BRM.HealerCount = BRM.HealerCount + 1
	elseif BRM.ROLE_TANK == role then
		BRM.TankCount = BRM.TankCount + 1
	elseif BRM.ROLE_DPS == role then
		BRM.DPSCount = BRM.DPSCount + 1
	else
		BRM.UnknownCount = BRM.UnknownCount + 1
	end

	BRM.TotalCount = BRM.TotalCount + 1
end


function BRM:UpdateComposition()
	BRM:DebugPrint("in BRM:UpdateComposition")

	-- If the addon is not yet active, then just exit
	if not BRM.IsActive then
		BRM:DebugPrint("Addon is not active. Exiting without updating.")
		return
	end

	-- Zero out the counts so we can start fresh
	BRM.TankCount = 0
	BRM.HealerCount = 0
	BRM.DPSCount = 0
	BRM.UnknownCount = 0
	BRM.TotalCount = 0

	-- Figure out how many members in our group. Ungrouped returns zero.
	local members = GetNumGroupMembers()
	BRM:DebugPrint("members is " .. members)

	-- Variable for holding the role of each member we check, and a random iterator
	local Role, i

	if members and members > 0 then
		BRM:DebugPrint("I am in some kind of Group.")

		local CheckWord = IsInRaid() and "raid" or "party" -- this probably isn't localized in the game.
		BRM:DebugPrint("CheckWord is " .. CheckWord)

		-- OK, this is bloody screwy.
		-- If I'm in a party, then the addon has to check player and party1 to party4.
		-- But if I'm in a raid, the addon has to check raid1 to raid40, with no need to check player!

		if "raid" == CheckWord then
			-- Raid - iterate and count by role
			for i=1,members do
				Role = UnitGroupRolesAssigned(CheckWord .. i)
				if Role then
					BRM:DebugPrint("Group member " .. CheckWord .. i .. " has role " .. Role)
					BRM:IncrementRole(Role)
				else
					BRM:DebugPrint("Group member " .. CheckWord .. i .. " has no role")
					BRM:IncrementRole("unknown")
				end
			end -- for raid members
		else
			-- Party - iterate and count by role
			for i = 1, members - 1 do
				Role = UnitGroupRolesAssigned(CheckWord .. i)
				if Role then
					BRM:DebugPrint("Group member " .. CheckWord .. i .. " has role " .. Role)
					BRM:IncrementRole(Role)
				else
					BRM:DebugPrint("Group member " .. CheckWord .. i .. " has no role")
					BRM:IncrementRole("unknown")
				end
			end -- for party members

			-- Now repeat all that for the player.
			Role = UnitGroupRolesAssigned("player")
			if Role then
				BRM:DebugPrint("player has role " .. Role)
				BRM:IncrementRole(Role)
			else
				BRM:DebugPrint("player has no role")
				BRM:IncrementRole("unknown")
			end
		end -- if raid/party
	else
		BRM:DebugPrint("I am not in any kind of Group.")

		-- When not grouped, there is no role to check. So instead, we go off the player's specialization.

		-- get player role
		Role = select(5, GetSpecializationInfo(GetSpecialization()))
		-- GetSpecializationInfo returns: id, name, description, icon, background, role.

		if Role then
			BRM:DebugPrint("My role is " .. Role)
			BRM:IncrementRole(Role)
		else
			BRM:DebugPrint("Did not get role from specialization check")
			BRM:IncrementRole("unknown")
		end
	end

	BRM:DebugPrint("At end of role check, tanks = " .. BRM.TankCount .. ", healers = " .. BRM.HealerCount .. ", dps = " .. BRM.DPSCount .. ", other = " .. BRM.UnknownCount)

	BRM.LDO.text = BRM:GetDisplayString()
end -- BRM:UpdateComposition()


--#########################################
--# Actual LibDataBroker object
--#########################################

BRM.LDO = _G.LibStub("LibDataBroker-1.1"):NewDataObject(BRM.ADDON_NAME, {
	type = "data source",
	text = BRM:GetDisplayString(),
	value = "0",
	icon = BRM.MainIcon:GetIconString(),
	label = BRM.USER_ADDON_NAME,
	OnTooltipShow = function(tooltip)
		if not tooltip or not tooltip.AddLine then return end
		tooltip:AddLine(BRM.USER_ADDON_NAME)
		tooltip:AddLine("Click to refresh")
	end,
}) -- BRM.LDO creation


-- Handler for if user clicks on the display
function BRM.LDO:OnClick(button)
	BRM:DebugPrint("Got click on LDB object")

	if button == "LeftButton" then
		BRM:DebugPrint("Got left button")
	elseif button == "RightButton" then
		BRM:DebugPrint("Got right button")
	else
		BRM:DebugPrint("Got some other button")
	end

	-- I'm trying to capture which situations don't result in an automatic update.
	-- That essentially indicates either an event I missed coding for, or some kind of bug that resulted in invalid role counts.
	local old_TankCount = BRM.TankCount
	local old_HealerCount = BRM.HealerCount
	local old_DPSCount = BRM.DPSCount
	local old_UnknownCount = BRM.UnknownCount
	local old_TotalCount = BRM.TotalCount

	-- Refresh the counts
	BRM:UpdateComposition()

	-- Check if the counts changed, indicating the error above.
	if old_TankCount ~= BRM.TankCount
		or old_HealerCount ~= BRM.HealerCount
		or old_DPSCount ~= BRM.DPSCount
		or old_UnknownCount ~= BRM.UnknownCount
		or old_TotalCount ~= BRM.TotalCount
		then
			BRM:DebugPrint("Counts are different after click.")
			BRM:DebugPrint("old_TankCount is "		.. (old_TankCount		or "nil") .. ", new TankCount is "		.. (BRM.TankCount		or "nil"))
			BRM:DebugPrint("old_HealerCount is "	.. (old_HealerCount		or "nil") .. ", new DPSCount is "		.. (BRM.DPSCount		or "nil"))
			BRM:DebugPrint("old_DPSCount is "		.. (old_DPSCount		or "nil") .. ", new DPSCount is "		.. (BRM.DPSCount		or "nil"))
			BRM:DebugPrint("old_UnknownCount is "	.. (old_UnknownCount	or "nil") .. ", new UnknownCount is "	.. (BRM.UnknownCount	or "nil"))
			BRM:DebugPrint("old_TotalCount is "		.. (old_TotalCount		or "nil") .. ", new TotalCount is "		.. (BRM.TotalCount		or "nil"))

			-- @TODO: Capture some info and give the player a way to report it.

			-- Also schedule an update in five seconds to ensure we capture any additional changes
			C_Timer.After(5, function() BRM:UpdateComposition() end)
	end

end -- BRM.LDO:OnClick()



--#########################################
--# Events to register and handle
--#########################################

-- This event is only for debugging.
-- Note that PLAYER_LOGIN is triggered after all ADDON_LOADED events
function BRM.Events:PLAYER_LOGIN(...)
	BRM:DebugPrint("Got PLAYER_LOGIN event")
end -- BRM.Events:PLAYER_LOGIN()


-- This event is for loading our saved settings.
function BRM.Events:ADDON_LOADED(addon)
	BRM:DebugPrint("Got ADDON_LOADED for " .. addon)
	if addon ~= BRM.ADDON_NAME then return end


	--#########################################
	--# Load saved settings
	--#########################################

	BRM:DebugPrint("Loading or creating DB")
	if BRM_DB then
		-- Load the settings saved by the game.
		BRM:DebugPrint ("Restoring existing BRM DB")
		BRM.DB = BRM_DB

		-- This situation should only occur during development, but just in case, let's handle it.
		if not BRM.DB.MinimapSettings then BRM.DB.MinimapSettings = {} end
	else
		-- Initialize settings on first use
		BRM:DebugPrint ("Creating new BRM DB")
		BRM.DB = {}
		BRM.DB.Version = 1
		BRM.DB.MinimapSettings = {}
	end

	BRM:DebugPrint ("DB contents follow")
	BRM:DumpTable(BRM.DB)
	BRM:DebugPrint ("End DB contents")

	--#########################################
	--# Minimap button for LDB object
	--#########################################

	-- Creating the minimap icon requires somewhere to save the data.
	-- We don't load that until this event.
	-- So, this is the earliest point we can create the minimap icon.

	BRM.MinimapIcon = LibStub("LibDBIcon-1.0")
	BRM.MinimapIcon:Register(BRM.ADDON_NAME, BRM.LDO, BRM.DB.MinimapSettings)
end -- BRM.Events:ADDON_LOADED()


-- This triggers when someone joins or leaves a group, or changes their spec or role in the group.
function BRM.Events:GROUP_ROSTER_UPDATE(...)
	BRM:DebugPrint("Got GROUP_ROSTER_UPDATE")
	BRM:UpdateComposition()
end -- BRM.Events:GROUP_ROSTER_UPDATE()


-- This triggers when the player changes their talent spec.
function BRM.Events:ACTIVE_TALENT_GROUP_CHANGED(...)
	BRM:DebugPrint("Got ACTIVE_TALENT_GROUP_CHANGED")
	BRM:UpdateComposition()
end -- BRM.Events:ACTIVE_TALENT_GROUP_CHANGED()


-- On-load handler for addon initialization.
function BRM.Events:PLAYER_ENTERING_WORLD(...)
	-- Announce our load.
	BRM:DebugPrint("Got PLAYER_ENTERING_WORLD")

	-- It's now safe to turn on the addon and get counts.
	BRM.IsActive = true
	BRM:UpdateComposition()

	-- Get the main app icon based on the player's faction
	BRM.Faction, _ = UnitFactionGroup("player")

	if not BRM.Faction then
		BRM:DebugPrint("Faction is nil")
		return
	end

	if BRM.FACTION_HORDE == BRM.Faction then
		BRM:DebugPrint("Faction is Horde")
		BRM:DebugPrint("Inner string is " .. BRM.HordeIcon:GetIconStringInner())
		BRM.LDO.icon = BRM.HordeIcon.IconFile
	elseif BRM.FACTION_ALLIANCE == BRM.Faction then
		BRM:DebugPrint("Faction is Alliance")
		BRM:DebugPrint("Inner string is " .. BRM.AllianceIcon:GetIconStringInner())
		BRM.LDO.icon = BRM.AllianceIcon.IconFile
	else
		-- What the hell?
		BRM:DebugPrint("Unknown faction detected - " .. BRM.Faction)
	end

end -- BRM.Events:PLAYER_ENTERING_WORLD()


-- Save the db on logout.
function BRM.Events:PLAYER_LOGOUT(...)
	BRM:DebugPrint ("In PLAYER_LOGOUT, saving DB.")
	BRM_DB = BRM.DB
end -- BRM.Events:PLAYER_LOGOUT()


--#########################################
--# Implement the event handlers
--#########################################

-- Create the event handler function.
BRM.Frame:SetScript("OnEvent", function(self, event, ...)
	BRM.Events[event](self, ...) -- call one of the functions above
end)

-- Register all events for which handlers have been defined
for k, v in pairs(BRM.Events) do
	BRM:DebugPrint("Registering event ", k)
	BRM.Frame:RegisterEvent(k)
end