Mercurial > wow > ouroloot
view core.lua @ 66:43913e02a1ef
Detect LFR loot as best we can, and bundle it into the same warning given for heroic loot formatted by name only. Less tedious method of bumping data revisions.
author | Farmbuyer of US-Kilrogg <farmbuyer@gmail.com> |
---|---|
date | Fri, 27 Apr 2012 10:11:56 +0000 |
parents | 69fd720f853e |
children | c01875b275ca |
line wrap: on
line source
local nametag, addon = ... --[==[ g_loot's numeric indices are loot entries (including titles, separators, etc); its named indices are: - forum saved text from forum markup window, default nil - attend saved text from raid attendence window, default nil - printed.FOO last loot index formatted into text window FOO, default 0 - raiders accumulating raid roster data as we see raid members; indexed by player name with subtable fields: - class capitalized English codename ("WARRIOR", "DEATHKNIGHT", etc) - subgroup 1-8 - race English codename ("BloodElf", etc) - sex 1 = unknown/error, 2 = male, 3 = female - level can be 0 if player was offline at the time - guild guild name, or missing if unguilded - online 1 = online, 2 = offline, 3 = no longer in raid [both of these next two fields use time_t values:] - join time player joined the raid (or first time we've seen them) - leave time player left the raid (or time we've left the raid, if 'online' is not 3) Common g_loot entry indices: - kind time/boss/loot - hour 0-23, on the *physical instance server*, not the realm server - minute 0-59, ditto - stamp time_t on the local computer - cols graphical display data; cleared when logging out Time specific g_loot indices: - startday table with month/day/year/text fields from makedate() text is always "dd Month yyyy" Boss specific g_loot indices: - bossname name of boss/encounter; may be changed if "snarky boss names" option is enabled - reason wipe/kill ("pull" does not generate an entry) - instance name of instance, including size and difficulty - maxsize max raid size: 5/10/25, presumably also 15 and 40 could show up; can be 0 if we're outside an instance and the player inside has an older version - duration in seconds; may be missing (only present if local) - raidersnap copy of g_loot.raiders at the time of the boss event Loot specific g_loot indices: - person recipient - person_class class of recipient if available; may be missing; will be classID-style (e.g., DEATHKNIGHT) - itemname not including square brackets - id itemID as number - itemlink full clickable link - itexture icon path (e.g., Interface\Icons\INV_Misc_Rune_01) - quality ITEM_QUALITY_* number - disposition offspec/gvault/shard; missing otherwise; can be set from the extratext field - count e.g., "x3"; missing otherwise; can be set/removed from extratext; triggers only for a stack of items, not "the boss dropped double axes today" - variant 1 = heroic item, 2 = LFR item; missing otherwise - cache_miss if GetItemInfo failed; SHOULD be missing (changes other fields) - bcast_from player's name if received rebroadcast from that player; missing otherwise; can be deleted as a result of in-game fiddling of loot data - extratext text in Note column, including disposition and rebroadcasting - extratext_byhand true if text edited by player directly; missing otherwise Functions arranged like this, with these lables (for jumping to). As a rule, member functions with UpperCamelCase names are called directly by user-facing code, ones with lowercase names are "one step removed", and names with leading underscores are strictly internal helper functions. ------ Saved variables ------ Constants ------ Addon member data ------ Globals ------ Expiring caches ------ Ace3 framework stuff ------ Event handlers ------ Slash command handler ------ On/off ------ Behind the scenes routines ------ Saved texts ------ Loot histories ------ Player communication This started off as part of a raid addon package written by somebody else. After he retired, I began modifying the code. Eventually I set aside the entire package and rewrote the loot tracker module from scratch. Many of the variable/function naming conventions (sv_*, g_*, and family) stayed across the rewrite. Some variables are needlessly initialized to nil just to look uniform. ]==] ------ Saved variables OuroLootSV = nil -- possible copy of g_loot OuroLootSV_saved = nil -- table of copies of saved texts, default nil; keys -- are numeric indices of tables, subkeys of those -- are name/forum/attend/date OuroLootSV_opts = nil -- same as option_defaults until changed -- autoshard: optional name of disenchanting player, default nil -- threshold: optional loot threshold, default nil OuroLootSV_hist = nil OuroLootSV_log = {} ------ Constants local option_defaults = { ['datarev'] = 18, -- cheating, this isn't actually an option ['popup_on_join'] = true, ['register_slashloot'] = true, ['scroll_to_bottom'] = true, ['chatty_on_kill'] = false, ['no_tracking_wipes'] = false, ['snarky_boss'] = true, ['keybinding'] = false, ['bossmod'] = "DBM", ['keybinding_text'] = 'CTRL-SHIFT-O', ['forum'] = { ['[url] Wowhead'] = '[url=http://www.wowhead.com/?item=$I]$N[/url]$X - $T', ['[url] MMO/Wowstead'] = '[http://db.mmo-champion.com/i/$I]$X - $T', ['[item] by name'] = '[item]$N[/item]$X - $T', ['[item] by ID'] = '[item]$I[/item]$X - $T', ['Custom...'] = '', }, ['forum_current'] = '[item] by name', ['display_disabled_LODs'] = false, ['display_bcast_from'] = true, } local virgin = "First time loaded? Hi! Use the /ouroloot or /loot command" .." to show the main display. You should probably browse the instructions" .." if you've never used this before; %s to display the help window. This" .." welcome message will not intrude again." local newer_warning = "A newer version has been released. You can %s to display" .." a download URL for copy-and-pasting. You can %s to ping other raiders" .." for their installed versions (same as '/ouroloot ping' or clicking the" .." 'Ping!' button on the options panel)." local qualnames = { ['gray'] = 0, ['grey'] = 0, ['poor'] = 0, ['trash'] = 0, ['white'] = 1, ['common'] = 1, ['green'] = 2, ['uncommon'] = 2, ['blue'] = 3, ['rare'] = 3, ['epic'] = 4, ['purple'] = 4, ['legendary'] = 5, ['orange'] = 5, ['artifact'] = 6, --['heirloom'] = 7, } local my_name = UnitName('player') local comm_cleanup_ttl = 4 -- seconds in the cache local revision_large = nil -- defaults to 1, possibly changed by revision local g_LOOT_ITEM_ss, g_LOOT_ITEM_MULTIPLE_sss, g_LOOT_ITEM_SELF_s, g_LOOT_ITEM_SELF_MULTIPLE_ss ------ Addon member data local flib = LibStub("LibFarmbuyer") addon.author_debug = flib.author_debug -- Play cute games with namespaces here just to save typing. WTB Lua 5.2 PST. do local _G = _G setfenv (1, addon) commrev = '16' revision = _G.GetAddOnMetadata(nametag,"Version") or "?" -- "x.yy.z", etc ident = "OuroLoot2" identTg = "OuroLoot2Tg" status_text = nil tekdebug = nil if _G.tekDebug then local tdframe = _G.tekDebug:GetFrame("Ouro Loot") function tekdebug (txt) -- tekDebug notices "<name passed to getframe>|r:" tdframe:AddMessage('|cff17ff0dOuro Loot|r:'..txt,1,1,1) end end DEBUG_PRINT = false debug = { comm = false, loot = false, flow = false, notraid = false, cache = false, alsolog = false, } -- This looks ugly, but it factors out the load-time decisions from -- the run-time ones. if tekdebug then function dprint (t,...) if DEBUG_PRINT and debug[t] then local text = flib.safefprint(tekdebug,"<"..t.."> ",...) if debug.alsolog then addon:log_with_timestamp(text) end end end else function dprint (t,...) if DEBUG_PRINT and debug[t] then local text = flib.safeprint("<"..t.."> ",...) if debug.alsolog then addon:log_with_timestamp(text) end end end end if author_debug and tekdebug then function pprint (t,...) local text = flib.safefprint(tekdebug,"<<"..t..">> ",...) if debug.alsolog then addon:log_with_timestamp(text) end end else pprint = flib.nullfunc end enabled = false rebroadcast = false display = nil -- display frame, when visible loot_clean = nil -- index of last GUI entry with known-current visual data sender_list = {active={},names={}} -- this should be reworked threshold = debug.loot and 0 or 3 -- rare by default sharder = nil -- name of person whose loot is marked as shards -- The rest is also used in the GUI: popped = nil -- non-nil when reminder has been shown, actual value unimportant bossmod_registered = nil bossmods = {} requesting = nil -- for prompting for additional rebroadcasters -- don't use NUM_ITEM_QUALITIES as the upper bound unless we expect heirlooms to show up thresholds = {} for i = 0,6 do thresholds[i] = _G.ITEM_QUALITY_COLORS[i].hex .. _G["ITEM_QUALITY"..i.."_DESC"] .. "|r" end _G.setfenv (1, _G) end addon = LibStub("AceAddon-3.0"):NewAddon(addon, "Ouro Loot", "AceTimer-3.0", "AceComm-3.0", "AceConsole-3.0", "AceEvent-3.0") ------ Globals local g_loot = nil local g_restore_p = nil local g_wafer_thin = nil -- for prompting for additional rebroadcasters local g_today = nil -- "today" entry in g_loot local g_boss_signpost = nil local opts = nil local pairs, ipairs, tinsert, tremove, tostring, tonumber, wipe = pairs, ipairs, table.insert, table.remove, tostring, tonumber, table.wipe local pprint, tabledump = addon.pprint, flib.tabledump local CopyTable, GetNumRaidMembers = CopyTable, GetNumRaidMembers -- En masse forward decls of symbols defined inside local blocks local _register_bossmod, makedate, create_new_cache, _init, _log -- Try to extract numbers from the .toc "Version" and munge them into an -- integral form for comparison. The result doesn't need to be meaningful as -- long as we can reliably feed two of them to "<" and get useful answers. -- -- This makes/reinforces an assumption that revision_large of release packages -- (e.g., 2016001) will always be higher than those of development packages -- (e.g., 87), due to the tagging system versus subversion file revs. This -- is good, as local dev code will never trigger a false positive update -- warning for other users. do local r = 0 for d in addon.revision:gmatch("%d+") do r = 1000*r + d end -- If it's a big enough number to obviously be a release, then make -- sure it's big enough to overcome many small previous point releases. while r > 2000 and r < 2000000 do r = 1000*r end revision_large = math.max(r,1) end -- Hypertext support, inspired by DBM broadcast pizza timers do local hypertext_format_str = "|HOuroRaid:%s|h%s[%s]|r|h" -- TEXT will automatically be surrounded by brackets -- COLOR can be item quality code or a hex string function addon.format_hypertext (code, text, color) return hypertext_format_str:format (code, type(color)=='number' and ITEM_QUALITY_COLORS[color].hex or color, text) end DEFAULT_CHAT_FRAME:HookScript("OnHyperlinkClick", function(self, link, string, mousebutton) local ltype, arg = strsplit(":",link) if ltype ~= "OuroRaid" then return end -- XXX this is crap, redo this as a dispatch table with code at the call site if arg == 'openloot' then addon:BuildMainDisplay() elseif arg == 'popupurl' then -- Sadly, this is not generated by the packager, so hardcode it for now. -- The 'data' field is handled differently for onshow than for other callbacks. StaticPopup_Show("OUROL_URL", --[[text_arg1=]]nil, --[[text_arg2=]]nil, --[[data=]][[http://www.curse.com/addons/wow/ouroloot]]) elseif arg == 'doping' then addon:DoPing() elseif arg == 'help' then addon:BuildMainDisplay('help') elseif arg == 'bcaston' then if not addon.rebroadcast then addon:Activate(nil,true) end addon:broadcast('bcast_responder') elseif arg == 'waferthin' then -- mint? it's wafer thin! g_wafer_thin = true -- fuck off, I'm full addon:broadcast('bcast_denied') -- remove once tested elseif arg == 'reload' then addon:BuildMainDisplay('opt') end end) local old = ItemRefTooltip.SetHyperlink function ItemRefTooltip:SetHyperlink (link, ...) if link:match("^OuroRaid") then return end return old (self, link, ...) end end do -- copied here because it's declared local to the calendar ui, thanks blizz >< local CALENDAR_FULLDATE_MONTH_NAMES = { FULLDATE_MONTH_JANUARY, FULLDATE_MONTH_FEBRUARY, FULLDATE_MONTH_MARCH, FULLDATE_MONTH_APRIL, FULLDATE_MONTH_MAY, FULLDATE_MONTH_JUNE, FULLDATE_MONTH_JULY, FULLDATE_MONTH_AUGUST, FULLDATE_MONTH_SEPTEMBER, FULLDATE_MONTH_OCTOBER, FULLDATE_MONTH_NOVEMBER, FULLDATE_MONTH_DECEMBER, } -- returns "dd Month yyyy", mm, dd, yyyy function makedate() Calendar_LoadUI() local _, M, D, Y = CalendarGetDate() local text = ("%d %s %d"):format(D, CALENDAR_FULLDATE_MONTH_NAMES[M], Y) return text, M, D, Y end end -- Returns an instance name or abbreviation, followed by the raid size local function instance_tag() -- possibly redo this with the new GetRaidDifficulty function local name, typeof, diffcode, diffstr, _, perbossheroic, isdynamic = GetInstanceInfo() local t, r name = addon.instance_abbrev[name] or name if typeof == "none" then return name, MAX_RAID_MEMBERS end -- diffstr is "5 Player", "10 Player (Heroic)", etc. ugh. if (GetLFGMode()) and (GetLFGModeType() == 'raid') then t,r = 'LFR', 25 elseif diffcode == 1 then t,r = (GetNumRaidMembers()>0) and "10",10 or "5",5 elseif diffcode == 2 then t,r = (GetNumRaidMembers()>0) and "25",25 or "5h",5 elseif diffcode == 3 then t,r = "10h", 10 elseif diffcode == 4 then t,r = "25h", 25 end -- dynamic difficulties always return normal "codes" if isdynamic and perbossheroic == 1 then t = t .. "h" end pprint("instance_tag final", t, r) return name .. "(" .. t .. ")", r end addon.instance_tag = instance_tag -- grumble addon.latest_instance = nil -- spelling reminder, assigned elsewhere ------ Expiring caches --[[ foo = create_new_cache("myfoo",15[,cleanup]) -- ttl foo:add("blah") foo:test("blah") -- returns true ]] do local caches = {} local cleanup_group = _G.AnimTimerFrame:CreateAnimationGroup() local time = _G.time cleanup_group:SetLooping("REPEAT") cleanup_group:SetScript("OnLoop", function(cg) addon.dprint('cache',"OnLoop firing") local now = time() local alldone = true -- this is ass-ugly for name,c in pairs(caches) do local fifo = c.fifo local active = #fifo > 0 while (#fifo > 0) and (now - fifo[1].t > c.ttl) do addon.dprint('cache', name, "cache removing", fifo[1].t, "<", fifo[1].m, ">") tremove(fifo,1) end if active and #fifo == 0 and c.func then addon.dprint('cache', name, "empty, firing cleanup") c:func() end alldone = alldone and (#fifo == 0) end if alldone then addon.dprint('cache',"OnLoop FINISHING animation group") cleanup_group:Finish() else addon.dprint('cache',"OnLoop done, not yet finished") end end) local function _add (cache, x) local datum = { t=time(), m=x } cache.hash[x] = datum tinsert (cache.fifo, datum) if not cleanup_group:IsPlaying() then addon.dprint('cache', cache.name, "with entry", datum.t, "<", datum.m, "> STARTING animation group") cache.cleanup:SetDuration(1) -- hmmm cleanup_group:Play() end end local function _test (cache, x) -- FIXME This can return false positives, if called after the onloop -- fifo has been removed but before the GC has removed the weak entry. -- What to do, what to do... return cache.hash[x] ~= nil end function create_new_cache (name, ttl, on_alldone) -- setting OnFinished for cleanup fires at the end of each inner loop, -- with no 'requested' argument to distinguish cases. thus, on_alldone. local c = { ttl = ttl, name = name, add = _add, test = _test, cleanup = cleanup_group:CreateAnimation("Animation"), func = on_alldone, fifo = {}, hash = setmetatable({}, {__mode='kv'}), } c.cleanup:SetOrder(1) caches[name] = c return c end end ------ Ace3 framework stuff function addon:OnInitialize() _log = OuroLootSV_log -- VARIABLES_LOADED has fired by this point; test if we're doing something like -- relogging during a raid and already have collected loot data g_restore_p = OuroLootSV ~= nil self.dprint('flow', "oninit sets restore as", g_restore_p) if OuroLootSV_opts == nil then OuroLootSV_opts = {} self:ScheduleTimer(function(s) s:Print(virgin, s.format_hypertext('help',"click here",ITEM_QUALITY_UNCOMMON)) virgin = nil end,10,self) else virgin = nil end opts = OuroLootSV_opts local stored_datarev = opts.datarev or 14 for opt,default in pairs(option_defaults) do if opts[opt] == nil then opts[opt] = default end end opts.datarev = option_defaults.datarev -- transition&remove old options opts['forum_use_itemid'] = nil if opts['forum_format'] then opts.forum['Custom...'] = opts['forum_format'] opts['forum_format'] = nil end if opts.forum['[url]'] then opts.forum['[url] Wowhead'] = opts.forum['[url]'] opts.forum['[url]'] = nil opts.forum['[url] MMO/Wowstead'] = option_defaults.forum['[url] MMO/Wowstead'] if opts['forum_current'] == '[url]' then opts['forum_current'] = '[url] Wowhead' end end option_defaults = nil if OuroLootSV then -- may not be the same as testing g_restore_p soon if OuroLootSV.saved then OuroLootSV_saved = OuroLootSV.saved; OuroLootSV.saved = nil end if OuroLootSV.threshold then opts.threshold = OuroLootSV.threshold; OuroLootSV.threshold = nil end if OuroLootSV.autoshard then opts.autoshard = OuroLootSV.autoshard; OuroLootSV.autoshard = nil end end -- get item filter table if needed if opts.itemfilter == nil then opts.itemfilter = addon.default_itemfilter end addon.default_itemfilter = nil self:RegisterChatCommand("ouroloot", "OnSlash") if opts.register_slashloot then -- NOTA BENE: do not use /loot in the LoadOn list, ChatTypeInfo gets confused -- maybe try to detect if this command is already in use... SLASH_ACECONSOLE_OUROLOOT2 = "/loot" end self.history_all = self.history_all or OuroLootSV_hist or {} local r = assert(GetRealmName()) self.history_all[r] = self:_prep_new_history_category (self.history_all[r], r) self.history = self.history_all[r] if (not InCombatLockdown()) and OuroLootSV_hist and (OuroLootSV_hist.HISTFORMAT == nil) -- restored data but it's older then -- Big honkin' loop for rname,realm in pairs(self.history_all) do for pk,player in ipairs(realm) do for lk,loot in ipairs(player) do if loot.count == "" then loot.count = nil end end end end end self.history_all.HISTFORMAT = nil -- don't keep this in live data --OuroLootSV_hist = nil -- Handle changes to the stored data format in stages from oldest to newest. if OuroLootSV then local dirty = false local bumpers = {} bumpers[14] = function() for i,e in ipairs(OuroLootSV) do if e.bosskill then e.bossname, e.bosskill = e.bosskill, nil end end end bumpers[15] = function() for i,e in ipairs(OuroLootSV) do if e.kind == 'boss' then e.maxsize, e.raiderlist, e.raidersnap = 0, nil, {} end end OuroLootSV.raiders = OuroLootSV.raiders or {} for name,r in pairs(OuroLootSV.raiders) do r.subgroup = 0 end end bumpers[16] = function() for i,e in ipairs(OuroLootSV) do if e.kind == 'boss' then -- brown paper bag bugs e.raidersnap = e.raidersnap or {} e.maxsize = e.maxsize or 0 end end end bumpers[17] = function() for i,e in ipairs(OuroLootSV) do if e.kind == 'loot' and e.is_heroic then e.variant, e.is_heroic = 1, nil -- Could try detecting any previous LFR loot here, but... gah end end end --[===[ local real = bumpers bumpers = newproxy(true) local mt = getmetatable(bumpers) mt.__index = real mt.__gc = function() print"whadda ya know, garbage collection works" end ]===] while stored_datarev < opts.datarev do self:Printf("Transitioning saved data format to %d...", stored_datarev+1) dirty = true bumpers[stored_datarev]() stored_datarev = stored_datarev + 1 end if dirty then self:Print("Saved data has been massaged into shape.") end end _init(self) self.dprint('flow', "version strings:", revision_large, self.status_text) self.OnInitialize = nil -- free up ALL the things! end function addon:OnEnable() self:RegisterEvent("PLAYER_LOGOUT") self:RegisterEvent("RAID_ROSTER_UPDATE") -- Cribbed from Talented. I like the way jerry thinks: the first argument -- can be a format spec for the remainder of the arguments. (The new -- AceConsole:Printf isn't used because we can't specify a prefix without -- jumping through ridonkulous hoops.) The part about overriding :Print -- with a version using prefix hyperlinks is my fault. -- -- There is no ITEM_QUALITY_LEGENDARY constant. Sigh. do local AC = LibStub("AceConsole-3.0") local chat_prefix = self.format_hypertext('openloot',"Ouro Loot",--[[legendary]]5) function addon:Print (str, ...) if type(str) == 'string' and str:find("%", nil, --[[plainmatch=]]true) then return AC:Print (chat_prefix, str:format(...)) else return AC:Print (chat_prefix, str, ...) end end end while opts.keybinding do if InCombatLockdown() then self:Print("Cannot create '%s' as a keybinding while in combat!", opts.keybinding_text) self:Print("The rest of the addon will continue to work, but you will need to reload out of combat to get the keybinding. Either type /reload or use the button on %s in the lower right.", self.format_hypertext('reload',"the options tab",ITEM_QUALITY_UNCOMMON)) break end KeyBindingFrame_LoadUI() local btn = CreateFrame("Button", "OuroLootBindingOpen", nil, "SecureActionButtonTemplate") btn:SetAttribute("type", "macro") btn:SetAttribute("macrotext", "/ouroloot toggle") if SetBindingClick(opts.keybinding_text, "OuroLootBindingOpen") then -- a simple SaveBindings(GetCurrentBindingSet()) occasionally fails when GCBS -- decides to return neither 1 nor 2 during load, for reasons nobody has ever learned local c = GetCurrentBindingSet() if c == ACCOUNT_BINDINGS or c == CHARACTER_BINDINGS then SaveBindings(c) end else self:Print("Error registering '%s' as a keybinding, check spelling!", opts.keybinding_text) end break end --[[ The four loot format patterns of interest, changed into relatively tight string match patterns. Done at enable-time rather than load-time against the slim chance that one of the non-US "delocalizers" needs to mess with the global patterns before we transform them. The SELF variants can be replaced with LOOT_ITEM_PUSHED_SELF[_MULTIPLE] to trigger on 'receive item' instead, which would detect extracting stuff from mail, or s/PUSHED/CREATED/ for things like healthstones and guild cauldron flasks. ]] -- LOOT_ITEM = "%s receives loot: %s." --> (.+) receives loot: (.+)%. g_LOOT_ITEM_ss = _G.LOOT_ITEM:gsub('%.$','%%.'):gsub('%%s','(.+)') -- LOOT_ITEM_MULTIPLE = "%s receives loot: %sx%d." --> (.+) receives loot: (.+)(x%d+)%. g_LOOT_ITEM_MULTIPLE_sss = _G.LOOT_ITEM_MULTIPLE:gsub('%.$','%%.'):gsub('%%s','(.+)'):gsub('x%%d','(x%%d+)') -- LOOT_ITEM_SELF = "You receive loot: %s." --> You receive loot: (.+)%. g_LOOT_ITEM_SELF_s = _G.LOOT_ITEM_SELF:gsub('%.$','%%.'):gsub('%%s','(.+)') -- LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d." --> You receive loot: (.+)(x%d+)%. g_LOOT_ITEM_SELF_MULTIPLE_ss = _G.LOOT_ITEM_SELF_MULTIPLE:gsub('%.$','%%.'):gsub('%%s','(.+)'):gsub('x%%d','(x%%d+)') --[[ Stick something in the Blizzard addons options list, where most users will probably look these days. Try to be conservative about needless frame creation. ]] local bliz = CreateFrame("Frame") bliz.name = "Ouro Loot" bliz:SetScript("OnShow", function(_b) local button = CreateFrame("Button",nil,_b,"UIPanelButtonTemplate") button:SetWidth(150) button:SetHeight(22) button:SetScript("OnClick", function() _G.InterfaceOptionsFrameCancel:Click() _G.HideUIPanel(GameMenuFrame) addon:OpenMainDisplayToTab"Options" end) button:SetText('"/ouroloot opt"') button:SetPoint("TOPLEFT",20,-20) _b:SetScript("OnShow",nil) end) _G.InterfaceOptions_AddCategory(bliz) self:_scan_LOD_modules() if self.debug.flow then self:Print"is in control-flow debug mode." end end --function addon:OnDisable() end do local prototype = {} local function module_OnEnable (plugin) if plugin.option_defaults then local SVname = 'OuroLoot'..plugin:GetName()..'_opts' if not _G[SVname] then _G[SVname] = {} if type(plugin.OnFirstTime) == 'function' then plugin:OnFirstTime() end end plugin.opts = _G[SVname] for option,default in pairs(plugin.option_defaults) do if plugin.opts[option] == nil then plugin.opts[option] = default end end plugin.option_defaults = nil end end -- By default, no plugins. First plugin to use the special registration -- sets up code for any subsequent plugins. addon.is_plugin = flib.nullfunc local function module_rtg (plugin, text_type, ...) local registry = { [text_type]=plugin } addon.is_plugin = function(a,t) return registry[t] end prototype.register_text_generator = function(p,t,...) registry[t] = p return addon:register_text_generator(t,...) end return addon:register_text_generator(text_type,...) end prototype.OnEnable = module_OnEnable prototype.default_OnEnable = module_OnEnable prototype.register_text_generator = module_rtg addon:SetDefaultModuleLibraries("AceConsole-3.0") addon:SetDefaultModulePrototype(prototype) -- Fires before the plugin's own OnEnable (inherited or otherwise). --function addon:OnModuleCreated (plugin) -- print("created plugin", plugin:GetName()) --end local olrev = tonumber("@project-revision@") or 0 local err = [[Module '%s' cannot register itself because it failed a required condition: '%s']] function addon:ConstrainedNewModule (modname, minrev, mincomm, mindata) if not addon.author_debug then if minrev and minrev > olrev then self:Print(err,modname, "revision "..olrev.." older than minimum "..minrev) return false end if mincomm and mincomm > tonumber(self.commrev) then self:Print(err,modname, "commrev "..self.commrev.." older than minimum "..mincomm) return false end if mindata and mindata > opts.datarev then self:Print(err,modname, "datarev "..opts.datarev.." older than minimum "..mindata) return false end end return self:NewModule(modname) end end ------ Event handlers function addon:_clear_SVs() g_loot = {} -- not saved, just fooling PLAYER_LOGOUT tests OuroLootSV = nil OuroLootSV_saved = nil OuroLootSV_opts = nil OuroLootSV_hist = nil OuroLootSV_log = nil ReloadUI() end function addon:PLAYER_LOGOUT() self:UnregisterEvent("RAID_ROSTER_UPDATE") self:UnregisterEvent("PLAYER_ENTERING_WORLD") local worth_saving = #g_loot > 0 or next(g_loot.raiders) if not worth_saving then for text in self:registered_textgen_iter() do worth_saving = worth_saving or g_loot.printed[text] > 0 end end if worth_saving then opts.autoshard = self.sharder opts.threshold = self.threshold for i,e in ipairs(g_loot) do e.cols = nil end OuroLootSV = g_loot else OuroLootSV = nil end worth_saving = false for r,t in pairs(self.history_all) do if type(t) == 'table' then if #t == 0 then self.history_all[r] = nil else worth_saving = true t.realm = nil t.st = nil t.byname = nil end end end if worth_saving then OuroLootSV_hist = self.history_all OuroLootSV_hist.HISTFORMAT = 2 else OuroLootSV_hist = nil end OuroLootSV_log = #OuroLootSV_log > 0 and OuroLootSV_log or nil end do local IsInInstance, UnitName, UnitIsConnected, UnitClass, UnitRace, UnitSex, UnitLevel, UnitInRaid, UnitIsVisible, GetGuildInfo, GetRaidRosterInfo = IsInInstance, UnitName, UnitIsConnected, UnitClass, UnitRace, UnitSex, UnitLevel, UnitInRaid, UnitIsVisible, GetGuildInfo, GetRaidRosterInfo local time, difftime = time, difftime local R_ACTIVE, R_OFFLINE, R_LEFT = 1, 2, 3 local lastevent, now = 0, 0 local redo_count = 0 local redo, timer_handle function addon:CheckRoster (leaving_p, now_a) if not g_loot.raiders then return end -- bad transition now = now_a or time() if leaving_p then if timer_handle then self:CancelTimer(timer_handle) timer_handle = nil end for name,r in pairs(g_loot.raiders) do r.leave = r.leave or now end return end for name,r in pairs(g_loot.raiders) do if r.online ~= R_LEFT and not UnitInRaid(name) then r.online = R_LEFT r.leave = now end end if redo then redo_count = redo_count + 1 end redo = false for i = 1, GetNumRaidMembers() do local unit = 'raid'..i local name = UnitName(unit) -- No, that's not my typo, it really is "uknownbeing" in Blizzard's code. if name and name ~= UNKNOWN and name ~= UNKNOWNOBJECT and name ~= UKNOWNBEING then if not g_loot.raiders[name] then g_loot.raiders[name] = { needinfo=true } end local r = g_loot.raiders[name] -- We grab a bunch of return values here, but only pay attention to -- them under specific circumstances. local grri_name, connected, subgroup, level, class, _ grri_name, _, subgroup, level, _, class, connected = GetRaidRosterInfo(i) if name ~= grri_name then error("UnitName ("..tostring(name)..") =/= grri_name (".. tostring(grri_name)..") of same raidindex ("..i..")") end r.subgroup = subgroup if r.needinfo and UnitIsVisible(unit) then r.needinfo = nil r.class = class --select(2,UnitClass(unit)) r.race = select(2,UnitRace(unit)) r.sex = UnitSex(unit) r.level = level --UnitLevel(unit) r.guild = GetGuildInfo(unit) end --local connected = UnitIsConnected(unit) if connected and r.online ~= R_ACTIVE then r.join = r.join or now r.online = R_ACTIVE elseif (not connected) and r.online ~= R_OFFLINE then r.leave = now r.online = R_OFFLINE end redo = redo or r.needinfo end end if redo then -- XXX test redo_count here and eventually give up? if not timer_handle then timer_handle = self:ScheduleRepeatingTimer("RAID_ROSTER_UPDATE", 60) end else redo_count = 0 if timer_handle then self:CancelTimer(timer_handle) timer_handle = nil end end end function addon:RAID_ROSTER_UPDATE (event) if GetNumRaidMembers() == 0 then -- Leaving a raid group. -- Because of PLAYER_ENTERING_WORLD, this code also executes on load -- screens while soloing and in regular groups. Take care. self.dprint('flow', "GetNumRaidMembers == 0") if self.enabled and not self.debug.notraid then self.dprint('flow', "enabled, leaving raid") self.popped = nil self:Deactivate() -- self:UnregisterEvent("CHAT_MSG_LOOT") self:CheckRoster(--[[leaving raid]]true) end return end local inside,whatkind = IsInInstance() if inside and (whatkind == "pvp" or whatkind == "arena") then self.dprint('flow', "got RRU event but in pvp zone, bailing") return end local docheck = self.enabled if event == "Activate" then -- dispatched manually from Activate self:RegisterEvent("CHAT_MSG_LOOT") _register_bossmod(self) docheck = true elseif event == "RAID_ROSTER_UPDATE" then -- hot code path, be careful -- event registration from onload, joined a raid, maybe show popup self.dprint('flow', "RRU check:", self.popped, opts.popup_on_join) if (not self.popped) and opts.popup_on_join then self.popped = StaticPopup_Show "OUROL_REMIND" self.popped.data = self return end end -- Throttle the checks fired by common events. if docheck and not InCombatLockdown() then now = time() if difftime(now,lastevent) > 45 then lastevent = now self:CheckRoster(false,now) end end end end -- helper for CHAT_MSG_LOOT handler do local function maybe_trash_kill_entry() -- this is set on various boss interactions, so we've got a kill/wipe -- entry already if addon.latest_instance then return end addon.latest_instance = instance_tag() local ss, max = addon:snapshot_raid() addon:_mark_boss_kill (addon._addLootEntry{ kind='boss', reason='kill', bossname=[[trash]], instance=addon.latest_instance, duration=0, raidersnap=ss, maxsize=max }) end -- Recent loot cache local candidates = {} local function prefer_local_loots (cache) -- The function name is a bit of a misnomer, as local entries overwrite -- remote entries as the candidate table is populated. This routine is -- here to extract the final results once the cache timers have expired. -- -- Keep this sync'd with the local_override branch below. for i,sig in ipairs(candidates) do addon.dprint('loot', "processing candidate entry", i, sig) local loot = candidates[sig] if loot then addon.dprint('loot', i, "was found") maybe_trash_kill_entry() -- Generate *some* kind of boss/location entry candidates[sig] = nil local looti = addon._addLootEntry(loot) if (loot.disposition ~= 'shard') and (loot.disposition ~= 'gvault') and (not addon.history_suppress) then addon:_addHistoryEntry(looti) end end end if addon.display then addon:redisplay() end wipe(candidates) end addon.recent_loot = create_new_cache ('loot', comm_cleanup_ttl+3, prefer_local_loots) local GetItemInfo, GetItemIcon = GetItemInfo, GetItemIcon -- 'from' and onwards only present if this is triggered by a broadcast function addon:_do_loot (local_override, recipient, itemid, count, from, extratext) local itexture = GetItemIcon(itemid) local iname, ilink, iquality = GetItemInfo(itemid) local i if (not iname) or (not itexture) then i = true iname, ilink, iquality, itexture = UNKNOWN..': '..itemid, 'item:6948', ITEM_QUALITY_COMMON, [[ICONS\INV_Misc_QuestionMark]] end self.dprint('loot',">>_do_loot, R:", recipient, "I:", itemid, "C:", count, "frm:", from, "ex:", extratext, "q:", iquality) itemid = tonumber(ilink:match("item:(%d+)") or 0) -- This is only a 'while' to make jumping out of it easy and still do cleanup below. while local_override or ((iquality >= self.threshold) and not opts.itemfilter[itemid]) do if (self.rebroadcast and (not from)) and not local_override then self:vbroadcast('loot', recipient, itemid, count) end if (not self.enabled) and (not local_override) then break end local signature = recipient .. iname .. (count or "") if from and self.recent_loot:test(signature) then self.dprint('cache', "remote loot <",signature,"> already in cache, skipping") else -- There is some redundancy in all this, in the interests of ease-of-coding i = { kind = 'loot', person = recipient, person_class= select(2,UnitClass(recipient)), cache_miss = i and true or nil, quality = iquality, itemname = iname, id = itemid, itemlink = ilink, itexture = itexture, disposition = (recipient == self.sharder) and 'shard' or nil, count = (count and count ~= "") and count or nil, bcast_from = from, extratext = extratext, variant = self:is_variant_item(ilink), } if local_override then -- player is adding loot by hand, don't wait for network cache timeouts -- keep this sync'd with prefer_local_loots above if i.extratext == 'shard' or i.extratext == 'gvault' or i.extratext == 'offspec' then i.disposition = i.extratext end local looti = self._addLootEntry(i) if (i.disposition ~= 'shard') and (i.disposition ~= 'gvault') and (not self.history_suppress) then self:_addHistoryEntry(looti) end i = looti -- return value mostly for gui's manual entry else self.recent_loot:add(signature) candidates[signature] = i tinsert (candidates, signature) self.dprint('cache', "loot <",signature,"> added to cache as candidate", #candidates) end end break end self.dprint('loot',"<<_do_loot out") return i end function addon:CHAT_MSG_LOOT (event, ...) if (not self.rebroadcast) and (not self.enabled) and (event ~= "manual") then return end --[[ iname: Hearthstone iquality: integer ilink: clickable formatted link itemstring: item:6948:.... itexture: inventory icon texture ]] if event == "CHAT_MSG_LOOT" then local msg = ... local person, itemstring, count --ChatFrame2:AddMessage("original string: >"..(msg:gsub("\124","\124\124")).."<") -- test in most likely order: other people get more loot than "you" do person, itemstring, count = msg:match(g_LOOT_ITEM_MULTIPLE_sss) if not person then person, itemstring = msg:match(g_LOOT_ITEM_ss) end if not person then itemstring, count = msg:match(g_LOOT_ITEM_SELF_MULTIPLE_ss) if not itemstring then itemstring = msg:match(g_LOOT_ITEM_SELF_s) end end self.dprint('loot', "CHAT_MSG_LOOT, person is", person, ", itemstring is", itemstring, ", count is", count) if not itemstring then return end -- "So-and-So selected Greed", etc, not actual looting -- Name might be colorized, remove the highlighting if person then person = person:match("|c%x%x%x%x%x%x%x%x(%S+)") or person else person = my_name -- UNIT_YOU / You end --local id = tonumber((select(2, strsplit(":", itemstring)))) local id = tonumber(itemstring:match('|Hitem:(%d+):')) return self:_do_loot (false, person, id, count) elseif event == "broadcast" then return self:_do_loot(false, ...) elseif event == "manual" then local r,i,n = ... return self:_do_loot(true, r,i,nil,nil,n) end end end ------ Slash command handler -- Thought about breaking this up into a table-driven dispatcher. But -- that would result in a pile of teensy functions, most of which would -- never be called. Too much overhead. (2.0: Most of these removed now -- that GUI is in place.) function addon:OnSlash (txt) --, editbox) txt = strtrim(txt:lower()) local cmd, arg = "" do local s,e = txt:find("^%a+") if s then cmd = txt:sub(s,e) s = txt:find("%S", e+2) if s then arg = txt:sub(s,-1) end end end if cmd == "" then if InCombatLockdown() then return self:Print("Shouldn't display window in combat.") else return self:BuildMainDisplay() end elseif cmd:find("^thre") then self:SetThreshold(arg) elseif cmd == "on" then self:Activate(arg) elseif cmd == "off" then self:Deactivate() elseif cmd == "broadcast" or cmd == "bcast" then self:Activate(nil,true) elseif cmd == "toggle" then if self.display then self.display:Hide() else return self:BuildMainDisplay() end elseif cmd == "fake" then -- maybe comment this out for real users self:_mark_boss_kill (self._addLootEntry{ kind='boss',reason='kill',bossname="Baron Steamroller",instance=instance_tag(),duration=0 }) self:CHAT_MSG_LOOT ('manual', my_name, 54797) if self.display then self:redisplay() end self:Print "Baron Steamroller has been slain. Congratulations on your rug." elseif cmd == "debug" then if arg then self.debug[arg] = not self.debug[arg] _G.print(arg,self.debug[arg]) if self.debug[arg] then self.DEBUG_PRINT = true end else self.DEBUG_PRINT = not self.DEBUG_PRINT end elseif cmd == "save" and arg and arg:len() > 0 then self:save_saveas(arg) elseif cmd == "list" then self:save_list() elseif cmd == "restore" and arg and arg:len() > 0 then self:save_restore(tonumber(arg)) elseif cmd == "delete" and arg and arg:len() > 0 then self:save_delete(tonumber(arg)) elseif cmd == "help" then self:BuildMainDisplay('help') elseif cmd == "ping" then self:DoPing() elseif cmd == "fixcache" then self:do_item_cache_fixup() else if self:OpenMainDisplayToTab(cmd) then return end self:Print("Unknown command '%s'. %s to see the help window.", cmd, self.format_hypertext('help',"Click here",ITEM_QUALITY_UNCOMMON)) end end function addon:SetThreshold (arg, quiet_p) local q = tonumber(arg) if q then q = math.floor(q+0.001) if q<0 or q>6 then return self:Print("Threshold must be 0-6.") end else q = qualnames[arg] if not q then return self:Print("Unrecognized item quality argument.") end end self.threshold = q if not quiet_p then self:Print("Threshold now set to %s.", self.thresholds[q]) end end ------ On/off function addon:Activate (opt_threshold, opt_bcast_only) self.dprint('flow', ":Activate is running") self:RegisterEvent("RAID_ROSTER_UPDATE") self:RegisterEvent("PLAYER_ENTERING_WORLD", function() self:ScheduleTimer("RAID_ROSTER_UPDATE", 5, "PLAYER_ENTERING_WORLD") end) self.popped = true if GetNumRaidMembers() > 0 then self.dprint('flow', ">:Activate calling RRU") self:RAID_ROSTER_UPDATE("Activate") elseif self.debug.notraid then self.dprint('flow', ">:Activate registering loot and bossmods") self:RegisterEvent("CHAT_MSG_LOOT") _register_bossmod(self) elseif g_restore_p then g_restore_p = nil self.popped = nil -- get the reminder if later joining a raid if #g_loot == 0 then -- only generated text and raider join/leave data, not worth verbage self.dprint('flow', ">:Activate restored generated texts, un-popping") return end self:Print("Ouro Raid Loot restored previous data, but not in a raid", "and 5-player mode not active. |cffff0505NOT tracking loot|r;", "use 'enable' to activate loot tracking, or 'clear' to erase", "previous data, or 'help' to read about saved-texts commands.") return end self.rebroadcast = true -- hardcode to true; this used to be more complicated self.enabled = not opt_bcast_only if opt_threshold then self:SetThreshold (opt_threshold, --[[quiet_p=]]true) end self:Print("Ouro Raid Loot is %s. Threshold currently %s.", self.enabled and "tracking" or "only broadcasting", self.thresholds[self.threshold]) self:broadcast('revcheck',revision_large) end -- Note: running '/loot off' will also avoid the popup reminder when -- joining a raid, but will not change the saved option setting. function addon:Deactivate() self.enabled = false self.rebroadcast = false self:UnregisterEvent("RAID_ROSTER_UPDATE") self:UnregisterEvent("PLAYER_ENTERING_WORLD") self:UnregisterEvent("CHAT_MSG_LOOT") self:Print("Ouro Raid Loot deactivated.") end function addon:Clear(verbose_p) local repopup, st if self.display then -- in the new version, this is likely to always be the case repopup = true st = self.display:GetUserData("eoiST") if not st then self.dprint('flow', "Clear: display visible but eoiST not set??") end self.display:Hide() end g_restore_p = nil OuroLootSV = nil self:_reset_timestamps() if verbose_p then if (OuroLootSV_saved and #OuroLootSV_saved>0) then self:Print("Current loot data cleared, %d saved sets remaining.", #OuroLootSV_saved) else self:Print("Current loot data cleared.") end end _init(self,st) if repopup then addon:BuildMainDisplay() end end ------ Behind the scenes routines -- Semi-experimental debugging aid. do -- Putting _log local to here can result in this sequence: -- 1) logging happens, followed by reload or logout/login -- 2) _log points to SV_log -- 3) VARIABLES_LOADED replaces SV_log pointer with restored version -- 4) logging happens to _log table (now with no other references) -- 5) at logout, nothing new has been entered in the table being saved local date = _G.date function addon:log_with_timestamp (msg) tinsert (_log, date('%m:%d %H:%M:%S ')..msg) end end -- Check for plugins which haven't already been loaded, and add hooks for -- them. Credit to DBM for the approach here. function addon:_scan_LOD_modules() for i = 1, GetNumAddOns() do if GetAddOnMetadata (i, "X-OuroLoot-Plugin") and IsAddOnLoadOnDemand(i) and not IsAddOnLoaded(i) then local folder, _, _, enabled, _, reason = GetAddOnInfo(i) if enabled or opts.display_disabled_LODs then local tabtitle = GetAddOnMetadata (i, "X-OuroLoot-Plugin") self:_gui_add_LOD_tab (tabtitle, folder, i, enabled, reason) end end end end -- Adds indices to traverse the tables in a nice sorted order. do local byindex, temp = {}, {} local function sort (src, dest) for k in pairs(src) do temp[#temp+1] = k end table.sort(temp) wipe(dest) for i = 1, #temp do dest[i] = src[temp[i]] end end function addon.sender_list.sort() sort (addon.sender_list.active, byindex) wipe(temp) addon.sender_list.activeI = #byindex sort (addon.sender_list.names, byindex) wipe(temp) end addon.sender_list.namesI = byindex end function addon:DoPing() self:Print("Give me a ping, Vasili. One ping only, please.") self.sender_list.active = {} self.sender_list.names = {} self:broadcast('ping') self:broadcast('revcheck',revision_large) end function addon:_check_revision (otherrev) self.dprint('comm', "revchecking against", otherrev) otherrev = tonumber(otherrev) if otherrev == revision_large then -- normal case elseif otherrev < revision_large then self.dprint('comm', "ours is newer, notifying") self:broadcast('revcheck',revision_large) else self.dprint('comm', "ours is older, yammering") if newer_warning then self:Print(newer_warning, self.format_hypertext('popupurl',"click here",ITEM_QUALITY_UNCOMMON), self.format_hypertext('doping',"click here",ITEM_QUALITY_UNCOMMON)) newer_warning = nil end end end -- Generic helpers -- Returns index and entry at that index, or nil if not found. function addon._find_next_after (kind, index) index = index + 1 while index <= #g_loot do if g_loot[index].kind == kind then return index, g_loot[index] end index = index + 1 end end -- Essentially a _find_next_after('time'-or-'boss'), but if KIND is -- 'boss', will also stop upon finding a timestamp. Returns nil if -- appropriate fencepost is not found. function addon._find_timeboss_fencepost (kind, index) local fencepost local closest_time = addon._find_next_after('time',index) if kind == 'time' then fencepost = closest_time elseif kind == 'boss' then local closest_boss = addon._find_next_after('boss',index) if not closest_boss then fencepost = closest_time elseif not closest_time then fencepost = closest_boss else fencepost = math.min(closest_time,closest_boss) end end return fencepost end -- Iterate through g_loot entries according to the KIND field. Loop variables -- are g_loot indices and the corresponding entries (essentially ipairs + some -- conditionals). function addon:filtered_loot_iter (filter_kind) return self._find_next_after, filter_kind, 0 end do local itt local function create() local tip, lefts = CreateFrame("GameTooltip"), {} for i = 1, 2 do -- scanning idea here also snagged from Talented local L,R = tip:CreateFontString(), tip:CreateFontString() L:SetFontObject(GameFontNormal) R:SetFontObject(GameFontNormal) tip:AddFontStrings(L,R) lefts[i] = L end tip.lefts = lefts return tip end function addon:is_variant_item(item) -- returns number or *nil* itt = itt or create() itt:SetOwner(UIParent,"ANCHOR_NONE") itt:ClearLines() itt:SetHyperlink(item) local t = itt.lefts[2]:GetText() itt:Hide() return (t == ITEM_HEROIC and 1) or (t == RAID_FINDER and 2) -- no ITEM_ for this, apparently or nil end end -- Called when first loading up, and then also when a 'clear' is being -- performed. If SV's are present then g_restore_p will be true. function _init (self, possible_st) self.dprint('flow',"_init running") self.loot_clean = nil self.hist_clean = nil if g_restore_p then g_loot = OuroLootSV self.popped = #g_loot > 0 self.dprint('flow', "restoring", #g_loot, "entries") self:ScheduleTimer("Activate", 12, opts.threshold) -- FIXME printed could be too large if entries were deleted, how much do we care? self.sharder = opts.autoshard else g_loot = { printed = {}, raiders = {} } end self.threshold = opts.threshold or self.threshold -- in the case of restoring but not tracking self:gui_init(g_loot) opts.autoshard = nil opts.threshold = nil if g_restore_p then self:zero_printed_fenceposts() -- g_loot.printed.* = previous/safe values else self:zero_printed_fenceposts(0) -- g_loot.printed.* = 0 end if possible_st then possible_st:SetData(g_loot) end self.status_text = ("%s communicating as ident %s commrev %s"):format(self.revision,self.ident,self.commrev) self:RegisterComm(self.ident) self:RegisterComm(self.identTg, "OnCommReceivedNocache") if self.author_debug then _G.OL = self _G.Oloot = g_loot end end -- Raid roster snapshots do function addon:snapshot_raid (only_inraid_p) local ss = CopyTable(g_loot.raiders) local instance,maxsize = instance_tag() if only_inraid_p then for name,info in next, ss do if info.online == 3 then ss[name] = nil end end end return ss, maxsize, instance, time() end end -- Tie-in with Deadly Boss Mods (or other such addons) do local candidates = {} local location local function fixup_durations (cache) local boss, bossi boss = candidates[1] if #candidates == 1 then -- (1) or (2) boss.duration = boss.duration or 0 addon.dprint('loot', "only one boss candidate") else -- (3), should only be one 'cast entry and our local entry if #candidates ~= 2 then -- could get a bunch of 'cast entries on the heels of one another -- before the local one ever fires, apparently... sigh --addon:Print("<warning> s3 cache has %d entries, does that seem right to you?", #candidates) end if candidates[2].duration == nil then --addon:Print("<warning> s3's second entry is not the local trigger, does that seem right to you?") end -- try and be generic anyhow for i,c in ipairs(candidates) do if c.duration then boss = c addon.dprint('loot', "fixup found boss candidate", i, "duration", c.duration) break end end end bossi = addon._addLootEntry(boss) -- addon. bossi = addon._adjustBossOrder (bossi, g_boss_signpost) or bossi g_boss_signpost = nil addon.latest_instance = boss.instance addon.dprint('loot', "added boss entry", bossi) if boss.reason == 'kill' then addon:_mark_boss_kill (bossi) if opts.chatty_on_kill then addon:Print("Registered kill for '%s' in %s!", boss.bossname, boss.instance) end end wipe(candidates) end addon.recent_boss = create_new_cache ('boss', 10, fixup_durations) -- Similar to _do_loot, but duration+ parms only present when locally generated. local function _do_boss (self, reason, bossname, intag, maxsize, duration) self.dprint('loot',">>_do_boss, R:", reason, "B:", bossname, "T:", intag, "MS:", maxsize, "D:", duration) if self.rebroadcast and duration then self:vbroadcast('boss', reason, bossname, intag, maxsize) end -- This is only a loop to make jumping out of it easy, and still do cleanup below. while self.enabled do if reason == 'wipe' and opts.no_tracking_wipes then break end bossname = (opts.snarky_boss and self.boss_abbrev[bossname] or bossname) or bossname local not_from_local = duration == nil local signature = bossname .. reason if not_from_local and self.recent_boss:test(signature) then self.dprint('cache', "remote boss <",signature,"> already in cache, skipping") else self.recent_boss:add(signature) g_boss_signpost = #g_loot + 1 self.dprint('loot', "added boss signpost", g_boss_signpost) -- Possible scenarios: (1) we don't see a boss event at all (e.g., we're -- outside the instance) and so this only happens once as a non-local event, -- (2) we see a local event first and all non-local events are filtered -- by the cache, (3) we happen to get some non-local events before doing -- our local event (not because of network weirdness but because our local -- DBM might not trigger for a while). local c = { kind = 'boss', bossname = bossname, reason = reason, instance = intag, duration = duration, -- deliberately may be nil raidersnap = self:snapshot_raid(), maxsize = maxsize, } tinsert(candidates,c) end break end self.dprint('loot',"<<_do_boss out") end -- This exposes the function to OCR, and can be a wrapper layer later. addon.on_boss_broadcast = _do_boss function addon:_mark_boss_kill (index) local e = g_loot[index] if not e then self:Print("Something horribly wrong;", index, "is not a valid entry!") return end if not e.bossname then self:Print("Something horribly wrong;", index, "is not a boss entry!") return end if e.reason ~= 'wipe' then -- enh, bail self.loot_clean = index-1 end local attempts = 1 local first local i,d = 1,g_loot[1] while d ~= e do if d.bossname and d.bossname == e.bossname and d.instance == e.instance and d.reason == 'wipe' then first = first or i attempts = attempts + 1 assert(tremove(g_loot,i)==d,"_mark_boss_kill screwed up data badly") else i = i + 1 end d = g_loot[i] end e.reason = 'kill' e.attempts = attempts self.loot_clean = first or index-1 end function addon:register_boss_mod (name, registration_func, deregistration_func) assert(type(name)=='string') assert(type(registration_func)=='function') if deregistration_func ~= nil then assert(type(deregistration_func)=='function') end self.bossmods[#self.bossmods+1] = { n = name, r = registration_func, d = deregistration_func, } self.bossmods[name] = self.bossmods[#self.bossmods] assert(self.bossmods[name].n == self.bossmods[#self.bossmods].n) end function _register_bossmod (self, force_p) local x = self.bossmod_registered and self.bossmods[self.bossmod_registered] if x then if x.n == opts.bossmod and not force_p then -- trying to register with already-registered boss mod return else -- deregister if x.d then x.d(self) end end end x = nil for k,v in ipairs(self.bossmods) do if v.n == opts.bossmod then x = k break end end if not x then self.status_text = "|cffff1010No boss-mod found!|r" self:Print(self.status_text) return end if self.bossmods[x].r (self, _do_boss) then self.bossmod_registered = self.bossmods[x].n else self:Print("|cffff1010Boss mod registration failed|r") end end end -- Adding entries to the loot record, and tracking the corresponding timestamp. do -- This shouldn't be required. /sadface local loot_entry_mt = { __index = function (e,key) if key == 'cols' then pprint('mt', e.kind, "key is", key) --tabledump(e) -- not actually that useful addon:_fill_out_eoi_data(1) end return rawget(e,key) end } -- Given a loot index, searches backwards for a timestamp. Returns that -- index and the time entry, or nil if it falls off the beginning. Pass an -- optional second index to search no earlier than that. -- May also be able to make good use of this in forum-generation routine. function addon:find_previous_time_entry(i,stop) stop = stop or 0 while i > stop do if g_loot[i].kind == 'time' then return i, g_loot[i] end i = i - 1 end end -- format_timestamp (["format_string"], Day, [Loot]) -- DAY is a loot entry with kind=='time', and controls the date printed. -- LOOT may be any kind of entry in the g_loot table. If present, it -- overrides the hour and minute printed; if absent, those values are -- taken from the DAY entry. -- FORMAT_STRING may contain $x (x in Y/M/D/h/m) tokens. local format_timestamp_values, point2dee = {}, "%.2d" function addon:format_timestamp (fmt_opt, day_entry, time_entry_opt) if not time_entry_opt then if type(fmt_opt) == 'table' then -- Two entries, default format time_entry_opt, day_entry = day_entry, fmt_opt fmt_opt = "$Y/$M/$D $h:$m" --elseif type(fmt_opt) == "string" then -- Day entry only, caller-specified format end end --format_timestamp_values.Y = point2dee:format (day_entry.startday.year % 100) format_timestamp_values.Y = ("%.4d"):format (day_entry.startday.year) format_timestamp_values.M = point2dee:format (day_entry.startday.month) format_timestamp_values.D = point2dee:format (day_entry.startday.day) format_timestamp_values.h = point2dee:format ((time_entry_opt or day_entry).hour) format_timestamp_values.m = point2dee:format ((time_entry_opt or day_entry).minute) return fmt_opt:gsub ('%$([YMDhm])', format_timestamp_values) end local done_todays_date function addon:_reset_timestamps() done_todays_date = nil end local function do_todays_date() local text, M, D, Y = makedate() local found,ts = #g_loot+1 repeat found,ts = addon:find_previous_time_entry(found-1) if found and ts.startday.text == text then done_todays_date = true end until done_todays_date or (not found) if done_todays_date then g_today = ts else done_todays_date = true g_today = g_loot[addon._addLootEntry{ kind = 'time', startday = { text = text, month = M, day = D, year = Y } }] end addon:_fill_out_eoi_data(1) end -- Adding anything original to g_loot goes through this routine. function addon._addLootEntry (e) setmetatable(e,loot_entry_mt) if not done_todays_date then do_todays_date() end local h, m = GetGameTime() --local localuptime = math.floor(GetTime()) local time_t = time() e.hour = h e.minute = m e.stamp = time_t --localuptime local index = #g_loot + 1 g_loot[index] = e return index end -- Problem: (1) boss kill happens, (2) fast looting happens, (3) boss -- cache cleanup fires. Result: loot shows up before boss kill entry. -- Solution: We need to shuffle the boss entry above any of the loot -- from that boss. function addon._adjustBossOrder (is, should_be) --pprint('loot', is, should_be) if is == should_be then --pprint('loot', "equal, yay") return end if (type(is)~='number') or (type(should_be)~='number') or (is < should_be) then pprint('loot', is, should_be, "...the hell? bailing") return end if g_loot[should_be].kind == 'time' then should_be = should_be + 1 if is == should_be then --pprint('loot', "needed to mark day entry, otherwise equal, yay") return end end assert(g_loot[is].kind == 'boss') local boss = tremove (g_loot, is) --pprint('loot', "MOVING", boss.bossname) tinsert (g_loot, should_be, boss) return should_be end end -- In the rare case of items getting put into the loot table without current -- item cache data (which will have arrived by now). function addon:do_item_cache_fixup() self:Print("Fixing up missing item cache data...") local numfound = 0 local borkedpat = '^'..UNKNOWN..': (%S+)' for i,e in self:filtered_loot_iter('loot') do if e.cache_miss then local borked_id = e.itemname:match(borkedpat) if borked_id then numfound = numfound + 1 -- Best to use the safest and most flexible API here, which is GII and -- its assload of return values. local iname, ilink, iquality, _,_,_,_,_,_, itexture = GetItemInfo(borked_id) if iname then self:Print(" Entry %d patched up with %s.", i, ilink) e.quality = iquality e.itemname = iname e.id = tonumber(ilink:match("item:(%d+)")) e.itemlink = ilink e.itexture = itexture e.cache_miss = nil end end end end self:Print("...finished. Found %d |4entry:entries; with weird data.", numfound) end ------ Saved texts function addon:check_saved_table(silent_p) local s = OuroLootSV_saved if s and (#s > 0) then return s end OuroLootSV_saved = nil if not silent_p then self:Print("There are no saved loot texts.") end end function addon:save_list() local s = self:check_saved_table(); if not s then return end; for i,t in ipairs(s) do self:Print("#%d %s %d entries %s", i, t.date, t.count, t.name) end end function addon:save_saveas(name) OuroLootSV_saved = OuroLootSV_saved or {} local SV = OuroLootSV_saved local n = #SV + 1 local save = { name = name, date = makedate(), count = #g_loot, } for text in self:registered_textgen_iter() do save[text] = g_loot[text] end self:Print("Saving current loot texts to #%d '%s'", n, name) SV[n] = save return self:save_list() end function addon:save_restore(num) local s = self:check_saved_table(); if not s then return end; if (not num) or (num > #s) then return self:Print("Saved text number must be 1 - "..#s) end local save = s[num] self:Print("Overwriting current loot data with saved text #%d '%s'", num, save.name) self:Clear(--[[verbose_p=]]false) -- Clear will already have displayed the window, and re-selected the first -- tab. Set these up for when the text tabs are clicked. for text in self:registered_textgen_iter() do g_loot[text] = save[text] end end function addon:save_delete(num) local s = self:check_saved_table(); if not s then return end; if (not num) or (num > #s) then return self:Print("Saved text number must be 1 - "..#s) end self:Print("Deleting saved text #"..num) tremove(s,num) return self:save_list() end ------ Loot histories -- history_all = { -- ["Kilrogg"] = { -- ["realm"] = "Kilrogg", -- not saved -- ["st"] = { lib-st display table }, -- not saved -- ["byname"] = { -- not saved -- ["OtherPlayer"] = 2, -- ["Farmbuyer"] = 1, -- } -- [1] = { -- ["name"] = "Farmbuyer", -- [1] = { id = nnnnn, when = "formatted timestamp for displaying" } -- most recent loot -- [2] = { ......., [count = "x3"] } -- previous loot -- }, -- [2] = { -- ["name"] = "OtherPlayer", -- ...... -- }, ...... -- }, -- ["OtherRealm"] = ...... -- } do local tsort = table.sort local comp = function(L,R) return L.when > R.when end -- Builds the map of names to array indices, using passed table or -- self.history, and stores the result into its 'byname' field. Also -- called from the GUI code at least once. function addon:_build_history_names (opt_hist) local hist = opt_hist or self.history local m = {} for i = 1, #hist do m[hist[i].name] = i end hist.byname = m end -- Prepares and returns table to be used as self.history. function addon:_prep_new_history_category (prev_table, realmname) local t = prev_table or { --kind = 'realm', --realm = realmname, } t.realm = realmname --[[ t.cols = setmetatable({ { value = realmname }, }, self.time_column1_used_mt) ]] if not t.byname then self:_build_history_names (t) end return t end -- Maps a name to an array index, creating new tables if needed. Returns -- the index and the table at that index. function addon:get_loot_history (name) local i i = self.history.byname[name] if not i then i = #self.history + 1 self.history[i] = { name=name } self.history.byname[name] = i end return i, self.history[i] end function addon:_addHistoryEntry (lootindex) local e = g_loot[lootindex] if e.kind ~= 'loot' then return end local i,h = self:get_loot_history(e.person) -- If any of these change, update the end of history_handle_disposition. local n = { id = e.id, when = self:format_timestamp (g_today, e), count = e.count, } tinsert (h, 1, n) e.history_unique = n.id .. ' ' .. n.when end -- Create new history table based on current loot. function addon:rewrite_history (realmname) local r = assert(realmname) self.history_all[r] = self:_prep_new_history_category (nil, r) self.history = self.history_all[r] local g_today_real = g_today for i,e in ipairs(g_loot) do if e.kind == 'time' then g_today = e elseif e.kind == 'loot' then self:_addHistoryEntry(i) end end g_today = g_today_real self.hist_clean = nil -- safety measure: resort players' tables based on formatted timestamp for i,h in ipairs(self.history) do tsort (h, comp) end end -- Clears all but latest entry for each player. function addon:preen_history (realmname) local r = assert(realmname) for i,h in ipairs(self.history) do tsort (h, comp) while #h > 1 do tremove (h) end end end -- Given an entry in a g_loot table, looks up the corresponding history -- entry. Returns the player's index and history table (as in get_loot_history) -- and the index into that table of the loot entry. On failure, returns nil -- and an error message ready to be formatted with the loot's name/itemlink. function addon:_history_by_loot_id (loot, operation_text) -- Using assert() here would be concatenating error strings that probably -- wouldn't be used. Do more verbose testing instead. if type(loot) ~= 'table' then error("trying to "..operation_text.." nonexistant entry") end if loot.kind ~= 'loot' then error("trying to "..operation_text.." something that isn't loot") end local player = loot.person local tag = loot.history_unique local errtxt local player_i, player_h, hist_i if not tag then errtxt = "Entry for %s is missing a history tag!" else player_i,player_h = self:get_loot_history(player) for i,h in ipairs(player_h) do local unique = h.id .. ' ' .. h.when if unique == tag then hist_i = i break end end if not hist_i then -- 1) loot an item, 2) clear old history, 3) reassign from current loot -- Bah. Anybody that tricky is already recoding the tables directly anyhow. errtxt = "There is no record of %s ever having been assigned!" end end if errtxt then return nil, errtxt end return player_i, player_h, hist_i end function addon:reassign_loot (index, to_name) assert(type(to_name)=='string' and to_name:len()>0) local e = g_loot[index] local from_i, from_h, hist_i = self:_history_by_loot_id (e, "reassign") local from_name = e.person local to_i,to_h = self:get_loot_history(to_name) if not from_i then -- from_h is the formatted error text self:Print(from_h .. " Loot will be reassigned, but history will NOT be updated.", e.itemlink) else local hist_h = tremove (from_h, hist_i) tinsert (to_h, 1, hist_h) tsort (from_h, comp) tsort (to_h, comp) end e.person = to_name e.person_class = select(2,UnitClass(to_name)) self.hist_clean = nil self:Print("Reassigned entry %d/%s from '%s' to '%s'.", index, e.itemlink, from_name, to_name) end -- Similar to _addHistoryEntry. The second arg may be a loot entry -- (which used to be at LOOTINDEX), or nil (and the loot entry will -- be pulled from LOOTINDEX instead). function addon:_delHistoryEntry (lootindex, opt_e) local e = opt_e or g_loot[lootindex] if e.kind ~= 'loot' then return end local from_i, from_h, hist_i = self:_history_by_loot_id (e, "delete") if not from_i then -- from_h is the formatted error text self:Print(from_h .. " Loot will be deleted, but history will NOT be updated.", e.itemlink) return end --[[local hist_h = ]]tremove (from_h, hist_i) tsort (from_h, comp) self.hist_clean = nil self:Print("Removed history entry %d/%s from '%s'.", lootindex, e.itemlink, e.person) end -- Any extra work for the "Mark as <x>" dropdown actions. The -- corresponding <x> will already have been assigned in the loot entry. local deleted_cache = {} --setmetatable({}, {__mode='k'}) function addon:history_handle_disposition (index, olddisp) local e = g_loot[index] -- Standard disposition has a nil entry, but that's tedious in debug -- output, so force to a string instead. olddisp = olddisp or 'normal' local newdisp = e.disposition or 'normal' -- Ignore misclicks and the like if olddisp == newdisp then return end local name = e.person if (newdisp == 'shard' or newdisp == 'gvault') then local name_i, name_h, hist_i = self:_history_by_loot_id (e, "mark") -- remove history entry if hist_i then local hist_h = tremove (name_h, hist_i) deleted_cache[e.history_unique] = hist_h self.hist_clean = nil elseif (olddisp == 'shard' or olddisp == 'gvault') then -- Sharding a vault item, or giving the auto-sharder something to bank, -- etc, wouldn't necessarily have had a history entry to begin with. else self:Print(name_h .. " Loot has been marked, but history will NOT be updated.", e.itemlink) end return end if (olddisp == 'shard' or olddisp == 'gvault') and (newdisp == 'normal' or newdisp == 'offspec') then local name_i, name_h = self:get_loot_history(name) -- Must create a new history entry. Could call '_addHistoryEntry(index)' -- but that would duplicate a lot of effort. To start with, check the -- cache of stuff we've already deleted; if it's not there then just do -- the same steps as _addHistoryEntry. local entry if e.history_unique and deleted_cache[e.history_unique] then entry = deleted_cache[e.history_unique] deleted_cache[e.history_unique] = nil end local when = g_today and self:format_timestamp (g_today, e) or tostring(e.stamp) entry = entry or { id = e.id, when = when, count = e.count, } tinsert (name_h, 1, entry) e.history_unique = e.history_unique or (entry.id .. ' ' .. entry.when) self.hist_clean = nil return end end end ------ Player communication do local select, tconcat, strsplit = select, table.concat, strsplit --[[ old way: repeated string concatenations, BAD new way: new table on every call, BAD local msg = ... for i = 2, select('#',...) do msg = msg .. '\a' .. (select(i,...) or "") end return msg ]] local function assemble(t,...) if select('#',...) > 0 then local msg = {t,...} -- tconcat requires strings, but T is known to be one already for i = 2, #msg do msg[i] = tostring(msg[i]) or "" end return tconcat (msg, '\a') end return t end -- broadcast('tag', <stuff>) -- vbroadcast('tag', <stuff>) function addon:vbroadcast(tag,...) return self:broadcast(self.commrev..tag,...) end function addon:broadcast(tag,...) local msg = assemble(tag,...) self.dprint('comm', "<broadcast>:", msg) -- the "GUILD" here is just so that we can also pick up on it self:SendCommMessage(self.ident, msg, self.debug.comm and "GUILD" or "RAID") end -- whispercast(<to>, 'tag', <stuff>) function addon:whispercast(to,...) local msg = assemble(...) self.dprint('comm', "<whispercast>@", to, ":", msg) self:SendCommMessage(self.identTg, msg, "WHISPER", to) end local function adduser (name, status, active) if status then addon.sender_list.names[name] = status end if active then addon.sender_list.active[name] = active end end -- Incoming handler functions. All take the sender name and the incoming -- tag as the first two arguments. All of these are active even when the -- player is not tracking loot, so test for that when appropriate. local OCR_funcs = {} OCR_funcs.ping = function (sender) pprint('comm', "incoming ping from", sender) addon:whispercast (sender, 'pong', addon.revision, addon.enabled and "tracking" or (addon.rebroadcast and "broadcasting" or "disabled")) end OCR_funcs.pong = function (sender, _, rev, status) local s = ("|cff00ff00%s|r %s is |cff00ffff%s|r"):format(sender,rev,status) addon:Print("Echo: ", s) adduser (sender, s, status=="tracking" or status=="broadcasting" or nil) end OCR_funcs.revcheck = function (sender, _, revlarge) addon.dprint('comm', "revcheck, sender", sender) addon:_check_revision (revlarge) end OCR_funcs.loot = function (sender, _, recip, item, count, extratext) addon.dprint('comm', "DOTloot, sender", sender, "recip", recip, "item", item, "count", count) if not addon.enabled then return end adduser (sender, nil, true) addon:CHAT_MSG_LOOT ("broadcast", recip, item, count, sender, extratext) end OCR_funcs['16loot'] = OCR_funcs.loot OCR_funcs.boss = function (sender, _, reason, bossname, instancetag) addon.dprint('comm', "DOTboss, sender", sender, "reason", reason, "name", bossname, "it", instancetag) if not addon.enabled then return end adduser (sender, nil, true) addon:on_boss_broadcast (reason, bossname, instancetag, --[[maxsize=]]0) end OCR_funcs['16boss'] = function (sender, _, reason, bossname, instancetag, maxsize) addon.dprint('comm', "DOTboss16, sender", sender, "reason", reason, "name", bossname, "it", instancetag, "size", maxsize) if not addon.enabled then return end adduser (sender, nil, true) addon:on_boss_broadcast (reason, bossname, instancetag, maxsize) end OCR_funcs.bcast_req = function (sender) if addon.debug.comm or ((not g_wafer_thin) and (not addon.rebroadcast)) then addon:Print("%s has requested additional broadcasters! Choose %s to enable rebroadcasting, or %s to remain off and also ignore rebroadcast requests for as long as you're logged in.", sender, addon.format_hypertext('bcaston',"the red pill",'|cffff4040'), addon.format_hypertext('waferthin',"the blue pill",'|cff0070dd')) end addon.popped = true end OCR_funcs.bcast_responder = function (sender) if addon.debug.comm or addon.requesting or ((not g_wafer_thin) and (not addon.rebroadcast)) then addon:Print(sender, "has answered the call and is now broadcasting loot.") end end -- remove this tag once it's all tested OCR_funcs.bcast_denied = function (sender) if addon.requesting then addon:Print(sender, "declines futher broadcast requests.") end end -- Incoming message dispatcher local function dotdotdot (sender, tag, ...) local f = OCR_funcs[tag] addon.dprint('comm', ":... processing",tag,"from",sender) if f then return f(sender,tag,...) end addon.dprint('comm', "unknown comm message",tag",from", sender) end -- Recent message cache addon.recent_messages = create_new_cache ('comm', comm_cleanup_ttl) function addon:OnCommReceived (prefix, msg, distribution, sender) if prefix ~= self.ident then return end if not self.debug.comm then if distribution ~= "RAID" and distribution ~= "WHISPER" then return end if sender == my_name then return end end self.dprint('comm', ":OCR from", sender, "message is", msg) if self.recent_messages:test(msg) then self.dprint('cache', "OCR message <",msg,"> already in cache, skipping") return end self.recent_messages:add(msg) -- Nothing is actually returned, just (ab)using tail calls. return dotdotdot(sender,strsplit('\a',msg)) end function addon:OnCommReceivedNocache (prefix, msg, distribution, sender) if prefix ~= self.identTg then return end if not self.debug.comm then if distribution ~= "WHISPER" then return end if sender == my_name then return end end self.dprint('comm', ":OCRN from", sender, "message is", msg) return dotdotdot(sender,strsplit('\a',msg)) end end -- vim:noet