Files
Horyoshi f38395a95c Fix profsync guild-wide lag, price MC trash, bid on 0 GP items (10.20.5)
profsync: stop expanding skill headers on every read. Expand/CollapseSkillHeader
fire SKILL_LINES_CHANGED, which this addon also listens to, so each read re-armed
its own debounced check -- a permanent 15s cycle that dragged pfQuest's
quest-giver rebuild and Cartographer's addon walk along with it on every guild
member, whether or not they ever opened a profession. Reads are now plain scans
that report whether a collapsed header may have censored them; an expand happens
only where the difference actually matters (a known profession vanishing, the
open window's own rank, /status), and is bracketed by a grace window so our own
echo is ignored. A drop is never announced off an incomplete read.

loot: only skip items with NO price entry. A price of 0 is a known item that just
hasn't been priced yet, and it was silently skipping the bid/trade window.

prices: Molten Core trash belts and bracers 0 -> 67 GP, Boots of Blistering
Flames and Core Forged Helmet 100 -> 67, add Core Forged Greaves.

minep default 300.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:52:02 +02:00

1337 lines
56 KiB
Lua

--[[---------------------------------------------------------------------------
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 <tab> 1:<op>:<part>:<total>:<body>
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 <prof>:<rank>:<max>:<k>:<ids> full recipe set for a profession (replaces)
A <prof>:<rank>:<max>:<k>:<ids> newly learned recipes only (adds)
X <prof> profession dropped (removes)
P <prof>:<rank>:<max> skill level changed, no recipe change
M <prof>:<id>~<icon>~<q>~<name>;… labels the website asked for
<k> 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 <player>:<prof> stored, nothing else needed
N <player>:<prof>:<id>,<id>,… 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.
<ids> 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
-- ⚠ Expanding or collapsing a skill header FIRES SKILL_LINES_CHANGED — see the
-- warning above ReadSkills. This is how long the addon disbelieves that event
-- after having caused it itself. A genuine skill-up landing inside the window is
-- simply missed, which costs a cosmetic rank line until the next idle sweep.
local SKILL_ECHO_GRACE = 1
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 skillEchoUntil = nil -- ignore SKILL_LINES_CHANGED we caused ourselves
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 = <initial pass confirmed>,
-- 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
local function AnyCollapsedHeader()
for i = 1, GetNumSkillLines() do
local _, isHeader, isExpanded = GetSkillLineInfo(i)
if isHeader and not isExpanded then return true end
end
return false
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 function ScanSkillLines()
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
return out
end
--[[ ⚠ EXPANDING A SKILL HEADER IS EXPENSIVE FOR THE WHOLE UI, NOT JUST FOR US.
ExpandSkillHeader/CollapseSkillHeader fire SKILL_LINES_CHANGED — FrameXML's own
expand button calls nothing else and relies on that event to redraw the pane —
and that event goes to EVERY addon. On a normal OctoWoW install it lands on
pfQuest, whose handler compares the visible skill-line names and, because
collapsing a header hides its children from GetSkillLineInfo, sees a change
every single time and runs a full pfDatabase:SearchQuests() quest-giver rebuild;
and on Cartographer's Professions module, which walks GetNumAddOns() calling
GetAddOnMetadata on every installed addon.
Worse, it used to feed itself: this addon listens to SKILL_LINES_CHANGED too,
so its own expand/collapse re-armed the debounced check, which read the skills
again, which expanded again — a permanent 15-second cycle of quest-giver
rebuilds on every guild member, whether or not they ever opened a profession.
That is what made the whole guild lag (2026-08-09).
So: the plain read NEVER touches a header, and callers that can live with a
partial answer ask for one. `mayExpand` is for the rare case where the
difference actually matters — telling "profession abandoned" apart from
"profession hidden under a collapsed header" — and it brackets the mutation in
skillEchoUntil so our own echo is not mistaken for a real skill change.
Returns skills, complete. `complete` false means a collapsed header may be
hiding professions, so a MISSING one proves nothing. ]]
local function ReadSkills(mayExpand)
local skills = ScanSkillLines()
if not AnyCollapsedHeader() then return skills, true end
if not mayExpand then return skills, false end
skillEchoUntil = GetTime() + SKILL_ECHO_GRACE
local restore = ExpandSkillHeaders() -- nil when the Skills pane is open
if not restore then return skills, false end
skills = ScanSkillLines()
RestoreSkillHeaders(restore)
skillEchoUntil = GetTime() + SKILL_ECHO_GRACE
return skills, true
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, complete = ReadSkills(false)
local db = DB()
-- ⚠ Only pay for a header expand when a profession we ALREADY KNOW ABOUT has
-- vanished from the read, because that is the single decision a partial read
-- cannot make (see ReadSkills for what an expand costs the rest of the UI). A
-- rank read off a visible line is trustworthy on its own, and a BRAND NEW
-- profession hidden under a collapsed header is not worth an expand either:
-- opening its window is the only way to use it, and that reports the rank
-- alongside the recipes.
if not complete then
local suspect = false
for id in pairs(db.profs) do
if PROF_BY_ID[id] and not skills[id] then suspect = true break end
end
if suspect then
local full, ok = ReadSkills(true)
if not ok then
skillsCheckAt = GetTime() + SKILL_DEBOUNCE -- Skills pane open, come back
return
end
skills, complete = full, ok
end
end
-- 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
-- Belt and braces: the suspect check above should have upgraded the read
-- to a complete one before we get here. Never announce a drop off a read
-- that a collapsed header could have censored.
if complete and (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)
-- The cheap read first; only if THIS profession is the one hidden under a
-- collapsed header is an expand worth it — and here it is, because the
-- alternative is publishing a rank of 0/0. This path already only runs when
-- the recipe list actually changed, so it is rare.
local skills = ReadSkills(false)
local info = skills and skills[prof.id]
if not info then
skills = ReadSkills(true)
info = skills and skills[prof.id]
end
-- 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
-- ⚠ IGNORE OUR OWN ECHO. Expanding or collapsing a header to read the pane
-- fires this event, so without the grace window the check that did the
-- expanding re-arms itself and runs again forever, every SKILL_DEBOUNCE
-- seconds, dragging pfQuest's quest-giver rebuild along with it. See
-- ReadSkills.
if skillEchoUntil and GetTime() < skillEchoUntil then return end
-- 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)
-- A human typed this, so the expand is worth it here whatever it costs.
local skills, complete = ReadSkills(true)
if not complete then
Say("partial reading — close the character window for the full picture")
end
do
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