char-exporter/CoaExporter/Collectors/Items.lua
Florian Berthold 53bb5f6a85 chore: scanner-tooltip pokes, honest pass stats, drop dead code, ISO timestamps
- Items/MysticScrolls: use the dedicated hidden scanner tooltip for the
  server-query poke instead of the global GameTooltip
- Items: count cache hits and this-pass resolutions separately so the
  pass 2+ chat output no longer claims old hits as new
- Core: remove dead tiny_json_encode fallback (Util/Json.lua loads first
  in the TOC and always defines CoaExporter_Json_Encode)
- Catalogs/Common: ctx.startedAt now ISO 8601 UTC like every other
  exporter timestamp
2026-06-10 02:16:33 +02:00

235 lines
8.4 KiB
Lua

-- 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 via the hidden scanner
-- tooltip; this is also what writes the row into itemcache.wdb.
ScanItemTooltip(itemID)
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. `resolved` counts everything in the cache (including hits from
-- earlier passes); `new` counts only ids that resolved during this pass.
function AE.ItemsScanPass(ids)
local cache = CoaExporterItemCache.entries
local total = #ids
local resolved = 0
local newlyResolved = 0
local unresolved = {}
for _, id in ipairs(ids) do
local wasCached = cache[id] and cache[id].name ~= nil
if TryResolve(id) then
resolved = resolved + 1
if not wasCached then newlyResolved = newlyResolved + 1 end
else
unresolved[#unresolved+1] = id
end
end
CoaExporterItemCache.meta.lastScanAt = time()
CoaExporterItemCache.meta.resolved = resolved
CoaExporterItemCache.meta.unresolved = unresolved
return { total = total, resolved = resolved, new = 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 %d/%d (%d new this pass, still unresolved: %d)",
attempts, maxAttempts, stats.resolved, stats.total, stats.new, 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 <args>` 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