diff --git a/CoaExporter/CoaExporter.toc b/CoaExporter/CoaExporter.toc index 63386df..869853f 100644 --- a/CoaExporter/CoaExporter.toc +++ b/CoaExporter/CoaExporter.toc @@ -3,7 +3,7 @@ ## Notes: Per-character export (talents/gear/mystic enchants/scrolls) + game-data catalog dump (skills/dispels/passives/talents) for db.exil.es ## Author: Subd from CoA / Exiles EU ## Version: 1.0.0 -## SavedVariables: CoaExporterSaved, CoaExporterConfig, CoaExporterScrollCache, CoaExporterCatalog +## SavedVariables: CoaExporterSaved, CoaExporterConfig, CoaExporterScrollCache, CoaExporterCatalog, CoaExporterItemCache Util\Json.lua Data\ScrollCatalog.lua @@ -14,6 +14,7 @@ Collectors\Gear.lua Collectors\Enchants.lua Collectors\MysticScrolls.lua Collectors\MysticScrollProbe.lua +Collectors\Items.lua Catalogs\Common.lua Catalogs\Skills.lua diff --git a/CoaExporter/Collectors/Items.lua b/CoaExporter/Collectors/Items.lua new file mode 100644 index 0000000..e549572 --- /dev/null +++ b/CoaExporter/Collectors/Items.lua @@ -0,0 +1,231 @@ +-- CoaExporter / Collectors / Items.lua +-- +-- Async item-ID scanner. Forces the client to fetch item templates from the +-- server for a list/range of item IDs, captures the resolved data, and (as a +-- side effect) populates the client's itemcache.wdb so a server-side +-- re-import can pick the items up too. +-- +-- Why this exists: CoA's per-class character-creation starting gear lives at +-- item ids 2000000+. The client only caches an item once a character has +-- actually seen it in-game, so items nobody has viewed are absent from both +-- the cache and db.exil.es. Calling GetItemInfo()/SetHyperlink() on an +-- unseen id triggers ITEM_QUERY_SINGLE to the server, which fills the cache. +-- +-- Usage: +-- /coae items scan → scan the default CoA custom block +-- /coae items scan missing → scan the known-missing id list (below) +-- /coae items scan 2000000 2000200 → scan an explicit inclusive range +-- /coae items scan 2089618 → scan a single id +-- /coae items export → JSON of everything resolved so far +-- /coae items status → resolved/seen counts +-- /coae items reset → clear the cache +-- +-- Design mirrors Collectors/MysticScrolls.lua: a pass fires a batch of +-- GetItemInfo requests, waits ~5s, then re-scans and records whatever +-- resolved; unresolved ids are retried up to maxAttempts. Results accumulate +-- in the CoaExporterItemCache SavedVariable across sessions. + +CoaExporter = _G.CoaExporter or {} +_G.CoaExporter = CoaExporter +local AE = CoaExporter + +CoaExporterItemCache = CoaExporterItemCache or { + entries = {}, + meta = { lastScanAt = nil, lastRange = nil, resolved = 0, unresolved = {} }, +} + +-- Default scan target: the CoA character-creation block plus the one known +-- outlier (2089618). Range is inclusive. Keep this aligned with the gaps the +-- db.exil.es item_character_creation mapping is still missing. +AE.ItemsDefaultFrom = 2000000 +AE.ItemsDefaultTo = 2000099 +AE.ItemsExtras = { 2089618 } + +-- Known-missing ids (absent from db.exil.es as of 2026-06-06). `/coae items +-- scan missing` targets exactly these. +AE.ItemsMissing = { + 2000003, 2000006, + 2000027, 2000028, 2000029, 2000030, + 2000034, 2000035, 2000036, 2000037, + 2000048, 2000049, 2000050, + 2089618, +} + +local function EnsureScanner() + if not AE._itemScanner then + AE._itemScanner = CreateFrame("GameTooltip", "CoaExpItemsTT", nil, "GameTooltipTemplate") + AE._itemScanner:SetOwner(WorldFrame, "ANCHOR_NONE") + end + return AE._itemScanner +end + +local function TipLines(tt) + local out = {} + for i = 1, tt:NumLines() do + local left = _G[tt:GetName() .. "TextLeft" .. i] + if left then + local t = left:GetText() + if t and t ~= "" then out[#out+1] = t end + end + end + return out +end + +local function ScanItemTooltip(itemID) + local tt = EnsureScanner() + tt:ClearLines() + tt:SetHyperlink("item:" .. itemID) + return TipLines(tt) +end + +-- Returns true when the item is resolved (now or already cached). On a miss +-- it pokes the server (tooltip hyperlink) so the next pass can pick it up. +local function TryResolve(itemID) + local cache = CoaExporterItemCache.entries + if cache[itemID] and cache[itemID].name then + return true + end + local name, link, quality, iLevel, reqLevel, class, subclass, maxStack, + equipSlot, texture, sellPrice = GetItemInfo(itemID) + if not name then + -- Trigger the server-side ITEM_QUERY_SINGLE; this is also what writes + -- the row into itemcache.wdb. + GameTooltip:SetOwner(WorldFrame, "ANCHOR_NONE") + GameTooltip:SetHyperlink("item:" .. itemID) + GameTooltip:Hide() + return false + end + local itemLines = ScanItemTooltip(itemID) + local spellName, spellRank + if GetItemSpell then + spellName, spellRank = GetItemSpell(itemID) + end + cache[itemID] = { + itemID = itemID, + name = name, + link = link, + quality = quality, + itemLevel = iLevel, + reqLevel = reqLevel, + class = class, -- localized item class ("Armor", "Weapon") + subClass = subclass, -- localized subclass ("Cloth", "Polearm") + maxStack = maxStack, + equipSlot = equipSlot, -- INVTYPE_* string + icon = texture and ((texture:match("[Ii]nterface\\[Ii]cons\\(.+)") or texture):lower()) or nil, + sellPrice = sellPrice, -- nil on stock 3.3.5; present on Ascension's client + spellName = spellName, + spellRank = spellRank, + tooltip = table.concat(itemLines, "\n"), + fetchedAt = time(), + } + return true +end + +-- One synchronous pass over `ids`. Returns counts + the still-unresolved list. +function AE.ItemsScanPass(ids) + local total = #ids + local newlyResolved = 0 + local unresolved = {} + for _, id in ipairs(ids) do + if TryResolve(id) then + newlyResolved = newlyResolved + 1 + else + unresolved[#unresolved+1] = id + end + end + CoaExporterItemCache.meta.lastScanAt = time() + CoaExporterItemCache.meta.resolved = newlyResolved + CoaExporterItemCache.meta.unresolved = unresolved + return { total = total, resolved = newlyResolved, unresolved = #unresolved } +end + +-- Multi-pass scan with delays so the server has time to answer queries. +function AE.ItemsStartScan(ids, callback) + local attempts = 0 + local maxAttempts = 5 + local function step() + attempts = attempts + 1 + local stats = AE.ItemsScanPass(ids) + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "CoaExporter items: pass %d/%d - resolved this pass %d/%d (still unresolved: %d)", + attempts, maxAttempts, stats.resolved, stats.total, stats.unresolved)) + if stats.unresolved > 0 and attempts < maxAttempts then + local t = CreateFrame("Frame") + local elapsed = 0 + t:SetScript("OnUpdate", function(self, dt) + elapsed = elapsed + dt + if elapsed > 5 then + self:SetScript("OnUpdate", nil) + step() + end + end) + else + if stats.unresolved > 0 then + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "CoaExporter items: %d id(s) never resolved - likely no server-side template (dead ids).", + stats.unresolved)) + end + if callback then callback(stats) end + end + end + step() +end + +-- Build the id list for a `/coae items scan ` invocation. +function AE.ItemsResolveTargets(arg) + arg = arg and arg:match("^%s*(.-)%s*$") or "" + if arg == "" then + local ids = {} + for id = AE.ItemsDefaultFrom, AE.ItemsDefaultTo do ids[#ids+1] = id end + for _, id in ipairs(AE.ItemsExtras) do ids[#ids+1] = id end + return ids, string.format("%d-%d+extras", AE.ItemsDefaultFrom, AE.ItemsDefaultTo) + end + if arg == "missing" then + local ids = {} + for _, id in ipairs(AE.ItemsMissing) do ids[#ids+1] = id end + return ids, "missing" + end + local from, to = arg:match("^(%d+)%s+(%d+)$") + if from then + from, to = tonumber(from), tonumber(to) + if to < from then from, to = to, from end + -- Guard against a runaway range. + if (to - from) > 20000 then to = from + 20000 end + local ids = {} + for id = from, to do ids[#ids+1] = id end + return ids, string.format("%d-%d", from, to) + end + local single = arg:match("^(%d+)$") + if single then + return { tonumber(single) }, single + end + return nil, nil +end + +function AE.ItemsExport() + local out = { + schemaVersion = 1, + exportedAt = date("!%Y-%m-%dT%H:%M:%SZ"), + cacheMeta = CoaExporterItemCache.meta, + entries = {}, + } + for _, rec in pairs(CoaExporterItemCache.entries) do + table.insert(out.entries, rec) + end + return out +end + +function AE.ItemsReset() + CoaExporterItemCache = { + entries = {}, + meta = { lastScanAt = nil, lastRange = nil, resolved = 0, unresolved = {} }, + } +end + +function AE.ItemsCount() + local n = 0 + for _ in pairs(CoaExporterItemCache.entries) do n = n + 1 end + return n +end + +AE._loadedItems = true diff --git a/CoaExporter/Core.lua b/CoaExporter/Core.lua index 99d6877..af90b89 100644 --- a/CoaExporter/Core.lua +++ b/CoaExporter/Core.lua @@ -381,6 +381,8 @@ CoaExporter commands: /coae catalog all|skills|talents|icons /coae catalog dispels [class]|passives [class]|status /coae scrolls scan|export|reset|status +/coae items scan [missing | | ] +/coae items export|reset|status /coae sv on|off (SavedVariables for character export) /coae debug /coae help @@ -481,6 +483,55 @@ local function HandleScrolls(rest) end end +local function HandleItems(rest) + rest = rest or "" + local sub, arg = rest:match("^(%S*)%s*(.*)$") + sub = (sub or ""):lower() + if sub == "" or sub == "scan" then + if not AE.ItemsStartScan then + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: items collector not loaded") + return + end + local ids, label = AE.ItemsResolveTargets(arg) + if not ids then + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae items scan [missing | | ]") + return + end + CoaExporterItemCache.meta.lastRange = label + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "CoaExporter items: scanning %d id(s) [%s], up to ~25s...", #ids, label)) + AE.ItemsStartScan(ids, function(stats) + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "CoaExporter items: scan complete - %d cached total, %d unresolved this run. " + .. "Log out to flush itemcache.wdb, then '/coae items export'.", + AE.ItemsCount and AE.ItemsCount() or 0, stats.unresolved)) + end) + elseif sub == "export" then + if not AE.ItemsExport then + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: items collector not loaded") + return + end + local encoder = _G.CoaExporter_Json_Encode + if not encoder then + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: JSON encoder missing") + return + end + AE:ShowExport(encoder(AE.ItemsExport()), "CoaExporter - Items (Ctrl+C)") + elseif sub == "reset" then + if AE.ItemsReset then AE.ItemsReset() end + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter items: cache cleared") + elseif sub == "status" then + local meta = CoaExporterItemCache and CoaExporterItemCache.meta or {} + DEFAULT_CHAT_FRAME:AddMessage(string.format( + "CoaExporter items: %d cached (last range: %s, last scan: %s)", + AE.ItemsCount and AE.ItemsCount() or 0, + tostring(meta.lastRange or "-"), + meta.lastScanAt and date("%Y-%m-%d %H:%M:%S", meta.lastScanAt) or "never")) + else + DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: /coae items [scan|export|reset|status]") + end +end + local function HandleDebug() local lines = {} local function add(m) table.insert(lines, m) end @@ -489,10 +540,11 @@ local function HandleDebug() add(string.format("- AddOn: %s", tostring(ADDON_NAME))) add(string.format("- UI: %s", type(_G.CoaExporter_ShowExportFrame) == "function" and "yes" or "no")) add(string.format("- JSON: %s", type(_G.CoaExporter_Json_Encode) == "function" and "yes" or "no")) - add(string.format("- Loaded: talents=%s spellbook=%s gear=%s enchants=%s scrolls=%s probe=%s catCommon=%s catSkills=%s catTalents=%s", + add(string.format("- Loaded: talents=%s spellbook=%s gear=%s enchants=%s scrolls=%s probe=%s items=%s catCommon=%s catSkills=%s catTalents=%s", tostring(AE._loadedTalents or false), tostring(AE._loadedSpellbook or false), tostring(AE._loadedGear or false), tostring(AE._loadedEnchants or false), tostring(AE._loadedMysticScrolls or false), tostring(AE._loadedMysticScrollProbe or false), + tostring(AE._loadedItems or false), tostring(AE._loadedCatalogCommon or false), tostring(AE._loadedCatalogSkills or false), tostring(AE._loadedCatalogTalents or false))) @@ -523,6 +575,7 @@ local function HandleDebug() add("MysticEnchants: MISSING") end add(string.format("ScrollCatalog: %d entries", AE.ScrollCatalogTotal or 0)) + add(string.format("Items: %d cached", AE.ItemsCount and AE.ItemsCount() or 0)) local meta = (CoaExporterCatalog and CoaExporterCatalog._meta) or {} add(string.format("Catalog: lastScan=%s filter=%s", @@ -553,6 +606,7 @@ local function Dispatch(msg) if cmd == "export" then return HandleExport(rest) end if cmd == "catalog" then return HandleCatalog(rest) end if cmd == "scrolls" then return HandleScrolls(rest) end + if cmd == "items" then return HandleItems(rest) end if cmd == "debug" then return HandleDebug() end if cmd == "sv" then return HandleSv(Norm(rest)) end DEFAULT_CHAT_FRAME:AddMessage("CoaExporter: unknown command. Type /coae help")