From 879620200ee111d4a920e8c22639a5a60f960e5f Mon Sep 17 00:00:00 2001 From: Horyoshi <42314202+Cinecom@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:59:11 +0200 Subject: [PATCH] profsync: share which recipes you know with the guild website Adds one self-contained module. Opening a profession window reports its recipe list to the guild-chat relay, which forwards it to errorguild.com, so the site can answer "who can make this". Nothing else in the addon is touched. It is quiet by design: * The ONLY trigger is the player opening a profession window, which they have to do to use it. There is no automatic scan and there must not be one -- casting the profession spell unattended was tried and cannot be made to work: the cast answers "Unknown unit." unless self-targeted, three UI addons wrap CastSpellByName, and the window it opens reports "UNKNOWN" until its data arrives, so an early read publishes a partial list or an empty one. * Every open after the first costs one integer comparison. A confirmed profession whose window still has the same row count cannot have changed, so the rows are not walked, nothing is built and nothing is sent. * Bursts are batched. Buying out a trainer produces one message, not thirty; a bare skill-level change is throttled to one per five minutes. * A count is only believed once two consecutive looks agree on a non-zero number, and an empty read is never published -- a report of nothing is a bad read, not an empty profession. * Nothing is written to the SavedVariable until the website acknowledges it, so a dropped message means the work is redone later, never a silent hole. * It prints nothing, ever. /profsync forgets everything so the next open re-sends; /profsync status prints a diagnostic dump. Those are the only output. Traffic goes out on the GUILD addon channel rather than a whisper: four addons in a normal install ship ChatThrottleLib and every one of them takes three arguments, with nowhere to put a whisper target. Recipe ids are sorted, delta-encoded and written as a base-64 varint carrying 5 data bits per character, so a 150-recipe profession fits in two messages. No bit library is needed, which matters -- vanilla's Lua 5.0 has none. Co-Authored-By: Claude Opus 5 --- profsync.lua | 1262 ++++++++++++++++++++++++++++++++++++++++++++++++ shootyepgp.toc | 7 +- 2 files changed, 1266 insertions(+), 3 deletions(-) create mode 100644 profsync.lua diff --git a/profsync.lua b/profsync.lua new file mode 100644 index 0000000..75a2879 --- /dev/null +++ b/profsync.lua @@ -0,0 +1,1262 @@ +--[[--------------------------------------------------------------------------- + profsync.lua — profession / recipe sync for the ERROR guild website + + WHAT IT DOES + Reports which recipes this character knows to the guild-chat relay character + ("Gchat"), which forwards them to errorguild.com. The website then shows, per + recipe, which guild members can craft it. + + HOW IT TALKS + Everything goes out as a hidden ADDON message on the GUILD channel, never as + real chat. That matters for three reasons: + * it never appears in anybody's chat frame, so nobody sees anything; + * the server tags it LANG_ADDON, which bypasses the chat flood filter, so a + burst of chunks can never produce "you must wait before sending"; + * the relay is in the guild and already receives these packets (it used to + discard them), so no extra channel, character or connection is needed. + Nothing is sent unless the guild roster says the relay is online, so an absent + relay costs the guild no traffic at all. + + WHEN IT TALKS — the whole point of the design + It never opens anything and never acts on a timer. The trigger is the player + opening a profession window themselves, which they have to do to use it: + * FIRST time a profession is opened, its whole recipe list is sent. + * Every open after that costs ONE INTEGER COMPARISON — a confirmed + profession whose window still has the same number of rows cannot have + changed, so the rows are not walked, nothing is built, nothing is sent. + * Learn something and the count moves, so the next look sends just the + delta. Learning with the window open is caught immediately. + * Skill levels and dropped professions come from the Skills pane, which + needs no window at all. + An idle character costs exactly zero messages, and so does a busy one that has + not learned anything. + + ⚠ THERE IS DELIBERATELY NO AUTOMATIC SCAN. Earlier versions cast the + profession spell a minute after login to read it unattended. It could not be + made to work: on this client the cast is answered with "Unknown unit." unless + it is self-targeted, three UI addons wrap CastSpellByName, the window it opens + reports "UNKNOWN" until its data arrives, and a window read too early publishes + a partial list — or an empty one, which once wiped a player's whole catalog. + Reading the window the player opened has never once failed. Do not add it back. + + NOTHING IS BELIEVED UNTIL THE WEBSITE SAYS SO + Every batch is parked in `pending` and only written to the SavedVariable when + an acknowledgement comes back. A dropped packet, an offline relay or a logout + mid-send therefore means the work is redone later, never that the addon thinks + it has synced something the website never received. + + WIRE FORMAT (see errorguild_com/api/professions.php for the other end) + EGPROF 1:::: + out over the GUILD addon channel — see RawSendAddonMessage for why it cannot be + a whisper on this client — and answered on that same channel by the relay. + The relay concatenates the bodies of parts 1..total and posts the result. Ops: + F :::: full recipe set for a profession (replaces) + A :::: newly learned recipes only (adds) + X profession dropped (removes) + P :: skill level changed, no recipe change + M :~~~;… labels the website asked for + says what the ids ARE: "i" for a trade skill, where the id is the crafted + item and the website can join its own item metadata onto it, or "e" for a craft + (enchanting, poisons), where the recipe produces no item and the id is the + enchant spell instead. It is sent rather than inferred from the profession so + that a client which moves a profession between the two APIs cannot silently + file spell ids as item ids. + and back from the website, via the relay, ALSO on the guild channel: + K : stored, nothing else needed + N ::,,… stored, but send me names for these + The reply carries the player it is for because it is a broadcast, and because + the relay cannot whisper: OctoWoW blocks whispers from characters below level + 5, and the relay character is a parked level-1. Everyone's addon hears every + reply and acts only on its own. + + is compact on purpose. A maxed crafter knows a few hundred recipes and a + message holds ~240 characters, so a naive "12345,12346,…" would be ten-plus + messages. Instead the ids are sorted, delta-encoded, and each delta written as + a base-64 varint carrying 5 data bits per character (the 6th bit means "another + character follows"). Recipe ids cluster, so most deltas fit in one or two + characters and a whole profession lands in one or two messages. That beats a + generic LZW+base64 pass (what ConsumesManager uses for its multi-account sync) + on this particular data, is a tenth of the code, and needs no bit library — + which matters, because vanilla's Lua 5.0 has none. + + LUA 5.0 / 1.12 NOTES + No bit library, no '#', no '%' operator, no string.gmatch. Uses table.getn, + string.gfind, getglobal, and plain arithmetic for the base conversion. +-----------------------------------------------------------------------------]] + +-- Every profession in 1.12 caps at 75 from its very first tier. Used only to +-- keep a same-named class skill line out of the skill-level messages. +local PROF_MIN_CAP = 75 + +local ADDON_PREFIX = "EGPROF" +local PROTO = "1" +local GUILD_NAME = "ERROR" +local RELAY_NAME = "Gchat" + +-- Timing. Nothing here is urgent; every value is picked to stay out of the +-- player's way rather than to be quick. +-- Just enough for guild data and the skill pane to exist; nothing is opened or +-- cast at login any more, so there is nothing to wait for beyond that. +local LOGIN_DELAY = 15 -- settle time after entering the world +-- One message every 1.5s is comfortably under any per-second chat rule even +-- though addon traffic is exempt from the flood filter, and no batch is urgent: +-- a full first-time sync of a maxed crafter is a handful of messages. +local SEND_SPACING = 1.5 -- seconds between two outgoing addon messages +local SCAN_SETTLE = 0.4 -- re-read a window this often until its count stops moving +local WIN_MAX_TRIES = 15 -- ...for at most this many looks (6s) +local PASSIVE_THROTTLE = 10 -- min seconds between two passive reads of one window +local LEARN_SETTLE = 12 -- quiet time after the last "you have learned" +local LEARN_MAX_WAIT = 90 -- ...but never postpone a learn batch longer than this +local RANK_MIN_GAP = 300 -- min seconds between two skill-level-only messages +local SKILL_DEBOUNCE = 15 -- settle time after SKILL_LINES_CHANGED +local IDLE_CHECK = 300 -- background skill re-check cadence +local ACK_TIMEOUT = 60 -- how long a batch may sit unacknowledged +local RETRY_DELAY = 600 -- pause everything after finding the relay offline +local ROSTER_THROTTLE = 20 -- min seconds between two GuildRoster() requests +local GATE_CACHE = 5 -- how long a "can we send" answer stays valid + +local MAX_MSG = 240 -- payload chars (prefix + tab + this must stay < 256) +local CHUNK_BODY = 224 -- body chars per chunk, leaves room for "1:F:nn:nn:" +local MAX_CHUNKS = 16 -- a single logical message may never exceed this +local MAX_META = 6 -- name/icon entries per metadata message +local MAX_META_ROUNDS = 12 -- metadata batches answered per session + +--[[ Professions the website tracks. + skill — the name in the Skills pane (where the rank comes from) + window — the name GetTradeSkillLine()/GetCraftName() reports, which is NOT + always the skill name: Mining's recipe window is "Smelting" + spell — the spellbook entry that opens that window + A profession with no window/spell has no recipes (gathering) and only ever + reports its skill level. Anything not in this list is ignored entirely, so + an unknown custom profession is skipped rather than mis-filed. + + Ids 1-13 are the vanilla set. 14 and 15 are OctoWoW/Turtle additions: + Jewelcrafting (SkillLine 755) and Survival (142) — checked against Turtle's + own SkillLine data, which adds no other craftable profession. These numbers + are OUR OWN, not SkillLine.dbc ids; nothing in the client exposes those to + Lua, so the table would have to be maintained by hand either way. + + ⚠ Which API a profession uses is NOT declared here, and must not be: the + reader is chosen by whichever event the client fires, so a profession that + moves between the TradeSkill and Craft frames keeps working. ]] +local PROFS = { + { id = 1, skill = "Alchemy", window = "Alchemy", spell = "Alchemy" }, + { id = 2, skill = "Blacksmithing", window = "Blacksmithing", spell = "Blacksmithing" }, + { id = 3, skill = "Enchanting", window = "Enchanting", spell = "Enchanting" }, + { id = 4, skill = "Engineering", window = "Engineering", spell = "Engineering" }, + { id = 5, skill = "Herbalism" }, + { id = 6, skill = "Leatherworking", window = "Leatherworking", spell = "Leatherworking" }, + { id = 7, skill = "Mining", window = "Smelting", spell = "Smelting" }, + { id = 8, skill = "Skinning" }, + { id = 9, skill = "Tailoring", window = "Tailoring", spell = "Tailoring" }, + { id = 10, skill = "Cooking", window = "Cooking", spell = "Cooking" }, + { id = 14, skill = "Jewelcrafting", window = "Jewelcrafting", spell = "Jewelcrafting" }, + { id = 15, skill = "Survival", window = "Survival", spell = "Survival" }, +} +-- Deliberately NOT tracked, though ids 11/12/13 are reserved for them: First Aid +-- and Poisons are things every character has for itself and nobody ever asks a +-- guildmate to make, and Fishing has no recipes at all. The point of this page +-- is "who can make the thing I want", so listing them is noise. Cooking stays +-- because raid food genuinely is requested, and Mining stays because it carries +-- the Smelting recipes (Dark Iron, Elementium), which are a real service. + +local PROF_BY_SKILL, PROF_BY_WINDOW, PROF_BY_ID = {}, {}, {} +for i = 1, table.getn(PROFS) do + local p = PROFS[i] + PROF_BY_SKILL[p.skill] = p + PROF_BY_ID[p.id] = p + if p.window then PROF_BY_WINDOW[p.window] = p end +end + +-- --------------------------------------------------------------------------- +-- Runtime state (never saved) +-- --------------------------------------------------------------------------- + +local outbox = {} -- queued payload strings +local nextSendAt = 0 +local readyAt = nil -- end of the post-login grace period +-- Settle state for whichever profession window is open, silent scan or not. +local winSeen = nil -- row count seen on the previous look +local winProf = nil -- which profession that count belonged to +local winIsCraft = nil -- which API the open window belongs to +local winRecheckAt = nil -- when to look again for a stable count +local winTries = 0 -- bounded, so an untracked window stops being polled +local lastPassive = {} -- [profId] = GetTime() of the last passive read +local pending = {} -- [profId] = batch awaiting acknowledgement +local metaCache = {} -- [profId][recipeId] = { n = name, i = icon } +local metaRounds = 0 +local skillsCheckAt = nil -- debounced SKILL_LINES_CHANGED handler +local nextIdleCheck = nil +local learnSettleAt = nil -- debounced "you have learned" burst +local learnFirstAt = nil -- start of the current burst, bounds the debounce +local lastRankSend = {} -- [profId] = GetTime() of the last skill-level message +local pauseUntil = nil -- set when the relay turns out to be offline +local rosterAskedAt = 0 +local gateAt, gateOk = 0, false +local reading = false -- re-entrancy guard around a window read + +-- Counters and the last notable event, for /profsync status. This feature is +-- invisible by design — no chat output, no frames — so without somewhere to look +-- a failure is indistinguishable from "nothing needed doing". Writing a string +-- costs nothing and is never shown unless a human asks. +local statSent, statAcked = 0, 0 +local notes = {} +local NOTE_KEEP = 8 +-- A single "last thing that happened" freezes whichever moment the player +-- happened to type the command in, which during a multi-profession scan is +-- almost never the interesting one. Keeping the last few turns one status call +-- into the whole story. +local function Note(msg) + table.insert(notes, msg) + if table.getn(notes) > NOTE_KEEP then table.remove(notes, 1) end +end + +local function Say(msg) + DEFAULT_CHAT_FRAME:AddMessage("|cffC8A84EProfession sync:|r " .. msg) +end + +-- --------------------------------------------------------------------------- +-- Compact id encoding +-- --------------------------------------------------------------------------- + +local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-" + +-- One unsigned integer as base-64 characters holding 5 data bits each, most +-- significant first; every character except the last has bit 6 set to mean +-- "keep reading". 0-31 is one character, up to 1023 two, up to 32767 three. +local function EncodeNum(v) + local digits, n = {}, 0 + repeat + local q = math.floor(v / 32) + table.insert(digits, 1, v - q * 32) + v = q + n = n + 1 + until v == 0 + local out = "" + for i = 1, n do + local d = digits[i] + if i < n then d = d + 32 end + out = out .. string.sub(B64, d + 1, d + 1) + end + return out +end + +-- Sorted ids -> delta-encoded string. The first delta is the id itself, so the +-- decoder just keeps a running total and needs no separate base value. +local function EncodeIds(sorted) + local parts, prev = {}, 0 + for i = 1, table.getn(sorted) do + table.insert(parts, EncodeNum(sorted[i] - prev)) + prev = sorted[i] + end + return table.concat(parts, "") +end + +-- --------------------------------------------------------------------------- +-- Small helpers +-- --------------------------------------------------------------------------- + +local function IconName(path) + if not path or path == "" then return "" end + return string.lower((string.gsub(path, ".*\\", ""))) +end + +-- Recipe identity: the crafted item for a trade skill, the enchant spell for a +-- craft (an enchant produces no item, so there is nothing else to key on). +local function IdFromLink(link) + if not link then return nil end + local _, _, id = string.find(link, "item:(%d+)") + if not id then _, _, id = string.find(link, "enchant:(%d+)") end + if not id then _, _, id = string.find(link, "spell:(%d+)") end + return id and tonumber(id) or nil +end + +-- Rarity, read out of the hyperlink's own colour. There is no GetItemInfo call +-- that works for an item the player has never held, but the tradeskill link is +-- already tinted by the product's quality, so the colour IS the answer. The +-- website has item metadata for only a fraction of what gets crafted, so +-- without this most of the catalog would render as plain white. +local QUALITY_BY_COLOR = { + ["9d9d9d"] = 0, ["ffffff"] = 1, ["1eff00"] = 2, + ["0070dd"] = 3, ["a335ee"] = 4, ["ff8000"] = 5, +} +local function QualityFromLink(link) + if not link then return -1 end + local _, _, hex = string.find(link, "|c%x%x(%x%x%x%x%x%x)") + if not hex then return -1 end + local q = QUALITY_BY_COLOR[string.lower(hex)] + return q or -1 -- an enchant link is gold, which is not a rarity +end + +local function EditBoxFilled(name) + local frame = getglobal(name) + if frame and frame.GetText then + local t = frame:GetText() + if t and t ~= "" then return true end + end + return false +end + +local function BoxChecked(name) + local frame = getglobal(name) + if frame and frame.GetChecked and frame:GetChecked() then return true end + return false +end + +local function FrameShown(name) + local frame = getglobal(name) + return (frame and frame.IsVisible and frame:IsVisible()) and true or false +end + +local function InGuild() + if not IsInGuild() then return false end + return GetGuildInfo("player") == GUILD_NAME +end + +local function RelayOnline() + local n = GetNumGuildMembers() + if not n or n == 0 then return nil end -- roster not loaded yet + for i = 1, n do + local name, _, _, _, _, _, _, _, online = GetGuildRosterInfo(i) + if name == RELAY_NAME then return (online and true) or false end + end + return false +end + +-- The single gate every outbound decision passes through. Answering it costs a +-- walk of the guild roster, so the answer is cached for a few seconds. +local function CanSend() + local now = GetTime() + if now < gateAt then return gateOk end + gateAt = now + GATE_CACHE + gateOk = false + + if not InGuild() then + Note("not in <" .. GUILD_NAME .. ">") + return false + end + + local online = RelayOnline() + if online == nil then + -- Nobody has asked the server for the roster yet this session. Ask, and let + -- the next call decide — this is not an error, just not knowable yet. + if now - rosterAskedAt > ROSTER_THROTTLE then + rosterAskedAt = now + GuildRoster() + end + Note("waiting for the guild roster") + gateAt = now + 1 + return false + end + if not online then + Note(RELAY_NAME .. " is offline") + pauseUntil = now + RETRY_DELAY + return false + end + + gateOk = true + return true +end + +-- --------------------------------------------------------------------------- +-- Saved state +-- --------------------------------------------------------------------------- +-- sepgp_profsync = { +-- v = 1, synced = , +-- profs = { [profId] = { r, m, full, gone, ids = { [recipeId] = true } } } +-- } +-- r / m are the rank and cap the WEBSITE has confirmed, not the live ones, so a +-- lost message is always noticed on the next comparison. + +local function DB() + if type(sepgp_profsync) ~= "table" then sepgp_profsync = {} end + local db = sepgp_profsync + -- ⚠ Bump this after any bug that could have stored WRONG data, not just after + -- a shape change. v3 also carries a row count per profession (see + -- OnWindowVisible). v2 discarded everything collected on 2026-08-09, when a + -- window that had not populated yet could be committed as a confirmed full + -- set — an alchemist ended up with a confirmed set of nothing, and a cook with + -- 4 of 50 recipes, neither of which would ever re-scan itself. A full re-scan + -- costs one message set and is always correct. + if db.v ~= 3 then + for k in pairs(db) do db[k] = nil end + db.v = 3 + end + if type(db.profs) ~= "table" then db.profs = {} end + if db.synced == nil then db.synced = false end + return db +end + +local function ProfState(id) + local db = DB() + local st = db.profs[id] + if type(st) ~= "table" then + st = { r = -1, m = -1, full = false, rows = nil } + db.profs[id] = st + end + if type(st.ids) ~= "table" then st.ids = {} end + return st +end + +-- --------------------------------------------------------------------------- +-- Sending +-- --------------------------------------------------------------------------- + +-- Split one logical message into wire chunks. `body` is opaque here; the relay +-- glues the bodies back together in order before anything parses them. +local function Send(op, body) + local len = string.len(body) + local total = 1 + if len > CHUNK_BODY then total = math.floor((len - 1) / CHUNK_BODY) + 1 end + if total > MAX_CHUNKS then return end + if table.getn(outbox) + total > 64 then return end -- runaway guard + local pos = 1 + for part = 1, total do + table.insert(outbox, PROTO .. ":" .. op .. ":" .. part .. ":" .. total .. ":" .. + string.sub(body, pos, pos + CHUNK_BODY - 1)) + pos = pos + CHUNK_BODY + end +end + +--[[ ⚠ THE TRANSPORT IS THE GUILD ADDON CHANNEL, NOT A WHISPER. + + A whisper is the obvious choice — point to point, nobody else's client has to + hear it — and it CANNOT be made to work on this client. + + A normal OctoWoW install ships four copies of ChatThrottleLib (aux-addon, + Cartographer's AceComm-2.0, Spy's AceComm-3.0, TWLC2). Whichever wins replaces + the global with a three-argument wrapper: + + SendAddonMessage = function(a1,a2,a3) return Hook_SendAddonMessage(a1,a2,a3) end + + and the whisper target is the fourth argument. A targetless WHISPER addon + message makes the client throw "Unknown addon chat type". Reaching past the + hook for the library's captured original is not a fix either, because the + library is target-blind the whole way down: its own public API is + `:SendAddonMessage(prio, prefix, text, chattype)` and its despooler calls + `ORIG_SendAddonMessage(prefix, text, chattype)` — four addons, no target + anywhere. There is no whisper path through any of them. + + GUILD needs no target, so it survives every one of those hooks untouched, and + it is what the addon chat channel exists for. The relay is in the guild and + hears it. Other members' clients receive it too, which is precisely why the + batching above matters: one member's entire sync is a handful of tiny messages, + once, and an idle member sends none. The relay still answers by whisper — it + builds its own packets and no hook is involved on that side. +]] +local function RawSendAddonMessage(prefix, msg) + local ctl = ChatThrottleLib + if ctl and ctl.SendAddonMessage then + -- Use the library properly when it is there. BULK is paced and metered, and + -- is exactly the priority for something nobody is waiting on. + local ok = pcall(ctl.SendAddonMessage, ctl, "BULK", prefix, msg, "GUILD") + if ok then return true end + end + -- No library, or its queue refused it: go direct. Still under pcall, because + -- somebody else's hook must never put an error on screen over a cosmetic sync. + local ok, err = pcall(SendAddonMessage, prefix, msg, "GUILD") + if not ok then Note("send failed: " .. tostring(err)) end + return ok +end + +local function FlushOutbox() + if table.getn(outbox) == 0 then return end + local now = GetTime() + if now < nextSendAt then return end + if not CanSend() then + -- The relay went away mid-batch. Drop the queue rather than hold it: none of + -- it was committed, so the same work is rebuilt from scratch next time. + outbox = {} + pending = {} + return + end + local payload = table.remove(outbox, 1) + if string.len(payload) <= MAX_MSG then + if RawSendAddonMessage(ADDON_PREFIX, payload) then + statSent = statSent + 1 + Note("sent " .. string.sub(payload, 1, 10) .. " (" .. string.len(payload) .. " chars)") + else + -- The send itself blew up, which can only be a third-party hook. Stop for + -- a while rather than reproduce the failure every 1.5 seconds; nothing was + -- committed, so the batch is rebuilt later. RawSendAddonMessage has + -- already recorded what the client actually complained about. + outbox, pending = {}, {} + pauseUntil = now + RETRY_DELAY + return + end + end + nextSendAt = now + SEND_SPACING +end + +-- --------------------------------------------------------------------------- +-- Reading the Skills pane +-- --------------------------------------------------------------------------- + +-- Skill lines under a collapsed header are not returned at all, so the headers +-- have to be open to read a rank. Expanding is visible if the player has the +-- Skills pane up, so it is skipped while it is — the caller simply retries. +local function ExpandSkillHeaders() + if FrameShown("SkillFrame") then return nil end + local collapsed, guard = {}, 0 + while guard < 40 do + guard = guard + 1 + local found = nil + for i = 1, GetNumSkillLines() do + local name, isHeader, isExpanded = GetSkillLineInfo(i) + if isHeader and not isExpanded then + found = i + collapsed[name] = true + break + end + end + if not found then break end + ExpandSkillHeader(found) + end + return collapsed +end + +local function RestoreSkillHeaders(collapsed) + if not collapsed then return end + for i = GetNumSkillLines(), 1, -1 do + local name, isHeader = GetSkillLineInfo(i) + if isHeader and collapsed[name] then CollapseSkillHeader(i) end + end +end + +-- Returns [profId] = { r = rank, m = max } for every tracked profession this +-- character has, or nil when the pane could not be read cleanly right now. +local function ReadSkills() + local anyCollapsed = false + for i = 1, GetNumSkillLines() do + local _, isHeader, isExpanded = GetSkillLineInfo(i) + if isHeader and not isExpanded then anyCollapsed = true break end + end + + local restore = nil + if anyCollapsed then + restore = ExpandSkillHeaders() + if not restore then return nil end + end + + -- ⚠ A NAME CAN APPEAR TWICE. "Survival" is both a Turtle profession and a + -- hunter's class skill line, and a hunter sees both in this pane, so the last + -- one read must not win by accident — the higher cap does, which is always the + -- profession. A hunter who does NOT have the profession is caught later and + -- more cheaply: nothing in their spellbook opens it, so it is never scanned + -- and never reported. + -- + -- Deliberately no minimum-cap gate here. It would be a second way to tell the + -- two apart, but it is also a single condition that could silently blank every + -- profession on a client whose returns differ, and the spellbook check already + -- covers the case. + local out = {} + for i = 1, GetNumSkillLines() do + local name, isHeader, _, rank, _, _, maxRank = GetSkillLineInfo(i) + if not isHeader then + local p = PROF_BY_SKILL[name] + if p then + local prev = out[p.id] + if not prev or (maxRank or 0) > prev.m then + out[p.id] = { r = rank or 0, m = maxRank or 0 } + end + end + end + end + + RestoreSkillHeaders(restore) + return out +end + +-- --------------------------------------------------------------------------- +-- Reading an open profession window +-- --------------------------------------------------------------------------- + +local function RememberMeta(profId, recipeId, name, icon, quality) + if not name or name == "" then return end + if not metaCache[profId] then metaCache[profId] = {} end + metaCache[profId][recipeId] = { n = name, i = IconName(icon), q = quality or -1 } +end + +-- A filtered window shows a subset, which would read as "this player forgot half +-- their recipes". Such a read is still used — but only ever to ADD recipes, +-- never to replace the stored set. +local function TradeSkillClean(anyCollapsed) + if EditBoxFilled("TradeSkillFrameEditBox") then return false end + if EditBoxFilled("TradeSkillFrameSearchBox") then return false end + if BoxChecked("TradeSkillFrameMatsCheckButton") then return false end + if BoxChecked("TradeSkillMatsCheckButton") then return false end + if BoxChecked("TradeSkillFrameAvailableFilterCheckButton") then return false end + if BoxChecked("TradeSkillFrameSkillCheckButton") then return false end + if BoxChecked("TradeSkillSkillCheckButton") then return false end + -- Collapsed headers are spotted by the read loop, which is already visiting + -- every row; a second pass here would double the cost of every open. + return not anyCollapsed +end + +local function CraftClean(anyCollapsed) + if EditBoxFilled("CraftFrameEditBox") then return false end + if EditBoxFilled("CraftFrameSearchBox") then return false end + if BoxChecked("CraftFrameMatsCheckButton") then return false end + if BoxChecked("CraftMatsCheckButton") then return false end + if BoxChecked("CraftFrameAvailableFilterCheckButton") then return false end + if BoxChecked("CraftFrameSkillCheckButton") then return false end + if BoxChecked("CraftSkillCheckButton") then return false end + return not anyCollapsed +end + +-- Only ever called for a window this addon opened itself, so reorganising it is +-- safe: the player never sees it. +local function ExpandTradeSkillHeaders() + if not ExpandTradeSkillSubClass then return end + ExpandTradeSkillSubClass(0) + local guard = 0 + while guard < 40 do + guard = guard + 1 + local found = nil + for i = 1, GetNumTradeSkills() do + local _, skillType, _, isExpanded = GetTradeSkillInfo(i) + if skillType == "header" and not isExpanded then found = i break end + end + if not found then break end + ExpandTradeSkillSubClass(found) + end +end + +local function ExpandCraftHeaders() + if not ExpandCraftSubClass then return end + ExpandCraftSubClass(0) + local guard = 0 + while guard < 40 do + guard = guard + 1 + local found = nil + for i = 1, GetNumCrafts() do + local _, _, craftType, _, isExpanded = GetCraftInfo(i) + if craftType == "header" and not isExpanded then found = i break end + end + if not found then break end + ExpandCraftSubClass(found) + end +end + +-- Returns prof, sorted ids, clean — or nil when the open window belongs to +-- something not tracked (Beast Training, a custom profession, ...). +local function ReadTradeSkill(mayMutate) + local line = GetTradeSkillLine() + local prof = line and PROF_BY_WINDOW[line] + if not prof then return nil end + if mayMutate then ExpandTradeSkillHeaders() end + + -- What actually separates a recipe from a category header is that a header + -- has no product, so the LINK decides. Testing skillType is only a hint here: + -- if a client ever returns it in a different slot the link test still holds, + -- whereas requiring skillType to be a non-nil string would silently skip + -- every row. + local rows = GetNumTradeSkills() + local ids, seen, collapsed = {}, {}, false + for i = 1, rows do + local name, skillType, _, isExpanded = GetTradeSkillInfo(i) + if skillType == "header" then + if not isExpanded then collapsed = true end + elseif name then + local link = GetTradeSkillItemLink(i) + local id = IdFromLink(link) + if id and not seen[id] then + seen[id] = true + table.insert(ids, id) + RememberMeta(prof.id, id, name, GetTradeSkillIcon(i), QualityFromLink(link)) + end + end + end + table.sort(ids) + Note("read " .. prof.skill .. ": " .. table.getn(ids) .. " of " .. rows .. " rows") + return prof, ids, TradeSkillClean(collapsed), "i" +end + +local function ReadCraft(mayMutate) + -- Which craft is open is asked two ways: the display skill line is the right + -- answer, but not every 1.12-derived client exposes it, and the craft's own + -- name is the same string for everything this addon tracks. + local line = nil + if GetCraftDisplaySkillLine then line = GetCraftDisplaySkillLine() end + if (not line or line == "") and GetCraftName then line = GetCraftName() end + local prof = line and PROF_BY_WINDOW[line] + if not prof then return nil end + if mayMutate then ExpandCraftHeaders() end + + local rows = GetNumCrafts() + local ids, seen, collapsed = {}, {}, false + for i = 1, rows do + local name, _, craftType, _, isExpanded = GetCraftInfo(i) + if craftType == "header" then + if not isExpanded then collapsed = true end + elseif name then + local link = GetCraftItemLink(i) + local id = IdFromLink(link) + if id and not seen[id] then + seen[id] = true + table.insert(ids, id) + RememberMeta(prof.id, id, name, GetCraftIcon(i), QualityFromLink(link)) + end + end + end + table.sort(ids) + Note("read " .. prof.skill .. ": " .. table.getn(ids) .. " of " .. rows .. " rows") + return prof, ids, CraftClean(collapsed), "e" +end + +-- --------------------------------------------------------------------------- +-- Turning a read into messages +-- --------------------------------------------------------------------------- + +-- A skill-level-only message. Levelling a profession with the window open fires +-- an update per craft, so without this the player would emit one whisper every +-- few seconds for an hour. Suppressing one costs nothing: state.r is only +-- written when the website confirms, so the difference is still pending and the +-- next check sends the CURRENT level rather than the one that was skipped. +local function SendRank(profId, rank, max) + local now = GetTime() + if lastRankSend[profId] and (now - lastRankSend[profId]) < RANK_MIN_GAP then return end + lastRankSend[profId] = now + pending[profId] = { r = rank, m = max, at = now } + Send("P", profId .. ":" .. rank .. ":" .. max) +end + +local function Report(prof, ids, clean, kind, rank, max) + if not CanSend() then return end + -- ⚠ An empty read is never news. A profession window always holds at least one + -- recipe, so zero rows means the window was not readable — and sending that as + -- a full sync tells the website to delete everything it knows. Same rule the + -- bank stock sweep learned: a report of nothing is a bad read, not an empty + -- inventory. + if table.getn(ids) == 0 then + Note("ignoring empty read of " .. prof.skill) + return + end + local state = ProfState(prof.id) + + local fresh = {} + for i = 1, table.getn(ids) do + if not state.ids[ids[i]] then table.insert(fresh, ids[i]) end + end + local freshCount = table.getn(fresh) + local isFull = (clean and true or false) + local rankChanged = (state.r ~= rank or state.m ~= max) + + if not isFull then + -- A possibly filtered read may only add: recipes are never unlearned in + -- 1.12, so "I did not see it" is never evidence that it is gone. + if freshCount == 0 then + if rankChanged then SendRank(prof.id, rank, max) end + return + end + pending[prof.id] = { ids = ids, full = false, rows = winSeen, r = rank, m = max, at = GetTime() } + Send("A", prof.id .. ":" .. rank .. ":" .. max .. ":" .. kind .. ":" .. EncodeIds(fresh)) + return + end + + -- A clean read is authoritative and MAY replace the stored set — but doing so + -- every time would mean re-sending a whole profession after learning one + -- recipe. Once a full set has been confirmed, the only thing that can differ + -- is an addition (1.12 has no way to unlearn a recipe, and abandoning the + -- profession sends X instead), so the delta says everything the replace would. + -- The replace is kept for the first sync, and for the one case that proves the + -- stored set is wrong: a clean read that is MISSING something we think we have. + local stale = false + if state.full then + local seen = {} + for i = 1, table.getn(ids) do seen[ids[i]] = true end + for id in pairs(state.ids) do + if not seen[id] then stale = true break end + end + end + + if state.full and not stale then + if freshCount == 0 then + if rankChanged then SendRank(prof.id, rank, max) end + return + end + pending[prof.id] = { ids = ids, full = false, rows = winSeen, r = rank, m = max, at = GetTime() } + Send("A", prof.id .. ":" .. rank .. ":" .. max .. ":" .. kind .. ":" .. EncodeIds(fresh)) + return + end + + pending[prof.id] = { ids = ids, full = true, rows = winSeen, r = rank, m = max, at = GetTime() } + Send("F", prof.id .. ":" .. rank .. ":" .. max .. ":" .. kind .. ":" .. EncodeIds(ids)) +end + +-- --------------------------------------------------------------------------- +-- Profession set: additions, drops and rank changes +-- --------------------------------------------------------------------------- + +local function CheckSkills() + if not CanSend() then return end + local skills = ReadSkills() + if not skills then + skillsCheckAt = GetTime() + SKILL_DEBOUNCE -- Skills pane open, come back + return + end + local db = DB() + + -- Gone from the pane means abandoned. The row is kept, flagged, and only + -- deleted once the website confirms — otherwise a lost "dropped" message + -- would leave the site advertising recipes the player no longer has. + for id, state in pairs(db.profs) do + if not PROF_BY_ID[id] then + -- Not tracked any more (First Aid, Fishing and Poisons were dropped from + -- the list). Telling the website about it is pointless — it does not know + -- the id either, so it cannot acknowledge, and the message repeats + -- forever. Just forget it locally. + db.profs[id] = nil + pending[id] = nil + elseif not skills[id] then + if not state.gone or not pending[id] then + state.gone = true + pending[id] = { drop = true, at = GetTime() } + Send("X", tostring(id)) + end + elseif state.gone then + state.gone = nil -- re-learned before the drop was ever confirmed + end + end + + for id, info in pairs(skills) do + local prof = PROF_BY_ID[id] + local state = ProfState(id) + + -- ⚠ These two are INDEPENDENT, and used not to be. Chaining them behind an + -- elseif meant a profession whose recipe scan had not succeeded never + -- reported its skill level either — so a miner with a failing Smelting scan + -- was completely absent from the site rather than merely missing recipes. + -- The rank comes free from the Skills pane and is worth having on its own. + -- ⚠ Only a plausible cap gets a rank line. Every real profession is capped + -- at 75 or more from its first tier, and a class skill line that merely + -- shares a profession's name (a hunter's "Survival") never is — without + -- this, such a hunter would publish "Survival 0/0". The threshold gates ONLY + -- this cosmetic message: recipes are gated by the spellbook check instead, + -- so a client with odd numbers still syncs everything that matters. + if (info.m or 0) >= PROF_MIN_CAP and (state.r ~= info.r or state.m ~= info.m) then + SendRank(id, info.r, info.m) + end + + end +end + +-- --------------------------------------------------------------------------- +-- Replies from the website (relayed by Gchat) +-- --------------------------------------------------------------------------- + +local function Commit(profId) + local p = pending[profId] + if not p then return end + pending[profId] = nil + local db = DB() + + if p.drop then + db.profs[profId] = nil + metaCache[profId] = nil + return + end + + local state = ProfState(profId) + if p.ids then + if p.full then + state.ids = {} + state.full = true + end + for i = 1, table.getn(p.ids) do state.ids[p.ids[i]] = true end + -- The row count this set corresponds to, so a later open can be answered + -- with one integer comparison instead of a read. + state.rows = p.rows + end + state.r, state.m = p.r, p.m + + -- The one-off startup pass is complete once every scannable profession this + -- character has holds a confirmed full set. + local allFull = true + for id, st in pairs(db.profs) do + if PROF_BY_ID[id] and PROF_BY_ID[id].spell and not st.full and not st.gone then + allFull = false + end + end + if allFull then db.synced = true end +end + +-- Is a metadata batch still going out? Used to ignore the replies its own +-- messages provoke — see HandleReply. +local function MetaQueued() + for i = 1, table.getn(outbox) do + if string.sub(outbox[i], 1, 4) == PROTO .. ":M:" then return true end + end + return false +end + +-- The website only asks for names no guild member has taught it yet, so this +-- converges to nothing once the catalog is populated. +local function SendMeta(profId, idList) + if metaRounds >= MAX_META_ROUNDS then return end + metaRounds = metaRounds + 1 + local cache = metaCache[profId] + if not cache then return end + + local batch, count = {}, 0 + for id in string.gfind(idList, "%d+") do + local n = tonumber(id) + local m = n and cache[n] + if m then + table.insert(batch, n .. "~" .. m.i .. "~" .. m.q .. "~" .. m.n) + count = count + 1 + if count >= MAX_META then + Send("M", profId .. ":" .. table.concat(batch, ";")) + batch, count = {}, 0 + end + end + end + if count > 0 then Send("M", profId .. ":" .. table.concat(batch, ";")) end +end + +local function HandleReply(text) + -- Replies are broadcast to the whole guild, so each one names its recipient. + local _, _, op, who, rest = string.find(text, "^" .. PROTO .. ":(%a):([^:]+):(.*)$") + if not op then return end + if who ~= UnitName("player") then return end -- somebody else's answer + statAcked = statAcked + 1 + if op == "K" then + local id = tonumber(rest) + if id then + Note("website stored " .. ((PROF_BY_ID[id] and PROF_BY_ID[id].skill) or id)) + Commit(id) + end + elseif op == "N" then + local _, _, profId, ids = string.find(rest, "^(%d+):(.*)$") + profId = tonumber(profId) + if profId then + Commit(profId) -- the data landed; only the names are missing + -- ⚠ EVERY metadata message gets its own answer, so acting on all of them + -- multiplies: one request became seven messages, each of which drew + -- another request, each of which... The guild channel filled up and the + -- relay's flood guard cut the character off mid-catalog. A reply that + -- arrives while a batch is still going out is stale by definition — the + -- last message of the batch will draw a fresh, correct one. + if MetaQueued() then return end + Note("website wants names for " .. ((PROF_BY_ID[profId] and PROF_BY_ID[profId].skill) or profId)) + SendMeta(profId, ids or "") + end + end +end + +-- --------------------------------------------------------------------------- +-- Events +-- --------------------------------------------------------------------------- + +-- One shared handler for the four window events. `silent` says whether this is +-- our own scan (window hidden, safe to reorganise) or the player's (read-only). +-- Which tracked profession the currently open window belongs to, without +-- walking its rows. Used to throttle before doing the expensive part. +local function OpenWindowProf(isCraft) + local line + if isCraft then + if GetCraftDisplaySkillLine then line = GetCraftDisplaySkillLine() end + if (not line or line == "") and GetCraftName then line = GetCraftName() end + else + line = GetTradeSkillLine() + end + return (line and PROF_BY_WINDOW[line]) or nil +end + +-- How many rows the open window has, without walking any of them. +local function OpenWindowRows(isCraft) + if isCraft then return GetNumCrafts() end + return GetNumTradeSkills() +end + +--[[ ⚠ THE LIST FILLS IN OVER SEVERAL FRAMES, AND A PARTIAL READ IS POISON. + + A window event only means "the window is opening": the list behind it can be + empty (GetTradeSkillLine() answering "UNKNOWN", GetNumTradeSkills() 0) and then + arrive in pieces. Trusting it produced two real failures on 2026-08-09 — an + alchemist's still-empty window published a FULL SYNC OF NOTHING that replaced + everything the site knew about her, and a cook's first read caught 3 of 4 rows. + + So a count is only believed once it stops changing: look, wait, look again, and + only walk the rows when two consecutive looks agree on a non-zero number. This + applies to BOTH paths — the window the player opened is filling in exactly the + same way as one this addon opened. + + Checking the count is a single cheap call, which is also why it happens before + the expensive part: crafting with the window open fires an update per craft, + and each one used to build a full id list only to have it thrown away. +]] +local function OnWindowVisible(isCraft) + if reading then return end + + local prof = OpenWindowProf(isCraft) + local rows = (prof and OpenWindowRows(isCraft)) or 0 + + if learnSettleAt then return end -- mid-burst; the settle timer returns + -- The throttle is only stamped once something is actually reported, so a + -- glance at a half-filled window never costs the real read that follows it. + if prof and lastPassive[prof.id] and (GetTime() - lastPassive[prof.id]) < PASSIVE_THROTTLE then return end + + local settled = (prof and rows > 0 and winSeen == rows and winProf == prof.id) and true or false + winSeen = rows + winProf = prof and prof.id + winIsCraft = isCraft + + if not settled then + -- ⚠ Keep looking even when the window is not identifiable YET. Until the + -- data lands GetTradeSkillLine() answers "UNKNOWN", and returning here would + -- leave the read depending on a second event that a complete list never + -- sends. Bounded, so an untracked window (Beast Training) costs a couple of + -- seconds of glances and then stops. + winTries = winTries + 1 + if winTries <= WIN_MAX_TRIES then winRecheckAt = GetTime() + SCAN_SETTLE end + return + end + winTries = 0 + + --[[ ⚠ ALREADY DONE? THEN DO NOTHING — not even read. + + This runs every time anybody opens a profession, so the cheap path has to be + the common one. A confirmed profession whose window still has the same number + of rows cannot have changed, and comparing two integers is the whole check: + no walking ~95 rows, no building an id list, no diff, no message. Learning or + unlearning anything moves the count, which is exactly when the real read is + worth doing. + ]] + local known = ProfState(prof.id) + if known.full and known.rows == rows then + lastPassive[prof.id] = GetTime() + return + end + + -- Something changed (or this profession has never been confirmed): now it is + -- worth walking the rows. + reading = true + local ids, clean, kind + if isCraft then + prof, ids, clean, kind = ReadCraft(false) + else + prof, ids, clean, kind = ReadTradeSkill(false) + end + if prof then lastPassive[prof.id] = GetTime() end + winRecheckAt = nil + + if prof then + local state = ProfState(prof.id) + local skills = ReadSkills() + local info = skills and skills[prof.id] + -- The stored rank is -1 until the website has confirmed one, which is not + -- a number to put on the wire; fall back to 0 when the pane is unreadable. + local rank = (info and info.r) or state.r + local max = (info and info.m) or state.m + if rank < 0 then rank = 0 end + if max < 0 then max = 0 end + Report(prof, ids, clean, kind, rank, max) + end + + reading = false +end + +-- End of a learn burst: read the open window once, for everything at once. +-- +-- If no window is open there is nothing to do — the recipe is in the game and +-- will be reported the next time the player opens that profession, which they +-- have to do to use it anyway. +local function FlushLearned() + learnSettleAt, learnFirstAt = nil, nil + lastPassive = {} -- a real change outranks the throttle + winSeen, winProf = nil, nil -- force a fresh settle + if FrameShown("TradeSkillFrame") then OnWindowVisible(false) return end + if FrameShown("CraftFrame") then OnWindowVisible(true) end +end + +local f = CreateFrame("Frame", "ShootyProfSyncFrame") +f:RegisterEvent("PLAYER_ENTERING_WORLD") +f:RegisterEvent("TRADE_SKILL_SHOW") +f:RegisterEvent("TRADE_SKILL_UPDATE") +f:RegisterEvent("CRAFT_SHOW") +f:RegisterEvent("CRAFT_UPDATE") +-- Only to forget the settle state: a window that closed cannot be looked at +-- again, and a stale row count would make the NEXT one look already-settled. +f:RegisterEvent("TRADE_SKILL_CLOSE") +f:RegisterEvent("CRAFT_CLOSE") +f:RegisterEvent("SKILL_LINES_CHANGED") +f:RegisterEvent("CHAT_MSG_SYSTEM") +f:RegisterEvent("CHAT_MSG_ADDON") + +f:SetScript("OnEvent", function() + if event == "PLAYER_ENTERING_WORLD" then + -- Also fires on every zone change; only the first one matters. + if not readyAt then readyAt = GetTime() + LOGIN_DELAY end + return + end + + if event == "TRADE_SKILL_SHOW" or event == "TRADE_SKILL_UPDATE" then + if readyAt then OnWindowVisible(false) end + return + end + + if event == "CRAFT_SHOW" or event == "CRAFT_UPDATE" then + if readyAt then OnWindowVisible(true) end + return + end + + if event == "TRADE_SKILL_CLOSE" or event == "CRAFT_CLOSE" then + winSeen, winProf, winIsCraft, winRecheckAt, winTries = nil, nil, nil, nil, 0 + return + end + + if event == "SKILL_LINES_CHANGED" then + -- Fires on every point of skill-up, so it only arms a debounced check; that + -- check sends nothing unless a profession appeared, vanished, or moved. + skillsCheckAt = GetTime() + SKILL_DEBOUNCE + return + end + + if event == "CHAT_MSG_SYSTEM" then + -- "You have learned how to create a new item: X." — learned from a trainer + -- or a recipe item, so no window is open and only a re-scan can see it. + -- + -- Buying out a trainer produces a burst of these. Reacting to each one + -- would re-read the window thirty times and send thirty messages, so a learn + -- only pushes the settle timer; the read happens once the player stops, and + -- every recipe learned in the burst leaves in a single message. + if arg1 and string.find(arg1, "learned how to create") then + local now = GetTime() + if not learnFirstAt then learnFirstAt = now end + learnSettleAt = now + LEARN_SETTLE + end + return + end + + if event == "CHAT_MSG_ADDON" then + if arg1 == ADDON_PREFIX and arg4 == RELAY_NAME then HandleReply(arg2 or "") end + return + end +end) + +f:SetScript("OnUpdate", function() + local now = GetTime() + + -- Nothing happens during the login grace period: guild data, the spellbook + -- and the skill pane are all still settling in the first seconds. + if not readyAt or now < readyAt then return end + if pauseUntil then + if now < pauseUntil then return end + pauseUntil = nil + end + + -- Background sweep. Cheap, local, and silent unless something really moved. + if not nextIdleCheck then nextIdleCheck = now end + if now >= nextIdleCheck then + nextIdleCheck = now + IDLE_CHECK + if skillsCheckAt == nil then skillsCheckAt = now end + end + + -- The learn burst has gone quiet (or has run so long it is no longer a burst). + if learnSettleAt and (now >= learnSettleAt or (learnFirstAt and now - learnFirstAt >= LEARN_MAX_WAIT)) then + FlushLearned() + end + + if skillsCheckAt and now >= skillsCheckAt then + skillsCheckAt = nil + CheckSkills() + end + + -- Look at an open window again to see whether its row count has stopped + -- moving. TRADE_SKILL_UPDATE only fires when the list CHANGES, so a list that + -- is already complete would otherwise never produce a second look — and the + -- window the PLAYER opened needs this just as much as one we opened. + if winRecheckAt and now >= winRecheckAt then + winRecheckAt = nil + if winIsCraft ~= nil then OnWindowVisible(winIsCraft) end + end + + + -- A batch nobody acknowledged is abandoned rather than retried on the spot: + -- the relay may be down, and hammering it would not help. Because none of it + -- was committed, the next trigger rebuilds it. + for id, p in pairs(pending) do + if (now - p.at) > ACK_TIMEOUT then pending[id] = nil end + end + + FlushOutbox() +end) + +-- --------------------------------------------------------------------------- +-- Manual override +-- --------------------------------------------------------------------------- +-- Deliberately the only thing in this file that ever prints, and only when a +-- human typed the command. + +-- Everything this feature knows about itself, in six lines. It sends no chat and +-- draws nothing, so when it does not work there is otherwise nowhere to look. +local function PrintStatus() + local guild = GetGuildInfo("player") or "none" + local online = RelayOnline() + local relay = RELAY_NAME .. (online == nil and " (roster not loaded)" + or (online and " online" or " OFFLINE")) + Say("guild " .. guild .. " | relay " .. relay) + + local skills = ReadSkills() + if not skills then + Say("could not read the Skills pane (close the character window)") + else + local parts, any = {}, false + for id, info in pairs(skills) do + local prof = PROF_BY_ID[id] + local st = ProfState(id) + local mark = st.full and "synced" or (st.gone and "dropped" or "pending") + if not prof.spell then mark = "no recipes" end + table.insert(parts, prof.skill .. " " .. info.r .. "/" .. info.m .. " (" .. mark .. ")") + any = true + end + Say(any and table.concat(parts, ", ") or "no tracked professions on this character") + end + + Say("outbox " .. table.getn(outbox) .. + " | sent " .. statSent .. " | replies " .. statAcked) + if pauseUntil then + Say("paused for " .. math.floor(pauseUntil - GetTime()) .. "s") + end + local n = table.getn(notes) + if n == 0 then + Say("nothing has happened yet") + else + for i = 1, n do Say(" " .. i .. ". " .. notes[i]) end + end +end + +SLASH_SHOOTYPROFSYNC1 = "/profsync" +SlashCmdList["SHOOTYPROFSYNC"] = function(msg) + if msg and string.find(string.lower(msg), "status") then + PrintStatus() + return + end + if not InGuild() then + Say("only <" .. GUILD_NAME .. "> members sync.") + return + end + local db = DB() + db.synced = false + for id, st in pairs(db.profs) do + st.full = false + st.ids = {} + st.rows = nil + st.r, st.m = -1, -1 + end + outbox, pending = {}, {} + lastPassive, lastRankSend, metaRounds = {}, {}, 0 + winSeen, winProf, winRecheckAt, winTries = nil, nil, nil, 0 + learnSettleAt, learnFirstAt = nil, nil + gateAt, pauseUntil = 0, nil + readyAt = GetTime() + skillsCheckAt = GetTime() + statSent, statAcked, notes = 0, 0, {} + Note("manual rescan requested") + if RelayOnline() then + Say("forgotten — open each profession once and it will re-send.") + else + Say(RELAY_NAME .. " is offline — this will sync by itself once it is back.") + end +end diff --git a/shootyepgp.toc b/shootyepgp.toc index 0c6ca0f..23f4be2 100644 --- a/shootyepgp.toc +++ b/shootyepgp.toc @@ -7,11 +7,11 @@ ## X-Website: https://github.com/cinecom/shootyepgp/ ## X-Category: Guild ## X-Credits: shooty, Qcat(zhCN) -## Version: 10.19.12 +## Version: 10.20.3 ## X-Alpha: ## OptionalDeps: Ace2 ## SavedVariables: sepgp_saychannel, sepgp_groupbyclass, sepgp_groupbyarmor, sepgp_groupbyrole, sepgp_raidonly, sepgp_dbver, sepgp_debug, sepgp_fubar, sepgp_sound, RaidStrikeDB, RaidStrikeResetTime -## SavedVariablesPerCharacter: sepgp_main, sepgp_log, sepgp_looted, sepgp_decay, sepgp_minep, sepgp_reservechannel, sepgp_progress, sepgp_discount, sepgp_altspool, sepgp_altpercent, sepgp_autobidwindow +## SavedVariablesPerCharacter: sepgp_main, sepgp_log, sepgp_looted, sepgp_decay, sepgp_minep, sepgp_reservechannel, sepgp_progress, sepgp_discount, sepgp_altspool, sepgp_altpercent, sepgp_autobidwindow, sepgp_profsync Libs\AceLibrary\AceLibrary.lua Libs\AceOO-2.0\AceOO-2.0.lua @@ -49,4 +49,5 @@ matrix.lua raidstrike.lua bench.lua snowball.lua -altnames.lua \ No newline at end of file +altnames.lua +profsync.lua \ No newline at end of file