Files
Questie-X/Modules/QuestieLearnerExport.lua
T
Xurkon 2c2e4a24a7 fix(learner): harden import/merge for clean multi-player data merge
Importing learned data merged from several players must not corrupt the
store. Fixes:

- ValidateImport prefix check was a no-op:  parsed
  as  (always false), so non-Questie strings were
  never rejected. Now a proper inequality check.
- MergeImport now ensures the learner stores exist (fresh profile can
  import), merges each entry synchronously via _ApplyIncomingNetworkMerge
  (validates structure, only adopts missing fields, never overwrites good
  local data), wraps each entry in pcall so one malformed entry is
  skipped/counted rather than aborting the whole import, and returns
  accurate merged/skipped/rejected counts.
- Synchronous merge also fixes InjectLearnedData previously running before
  the async-queued merges landed.

selene 0 errors; busted 145 successes / same 7 pre-existing failures.
2026-06-10 05:12:04 -05:00

447 lines
16 KiB
Lua

---@class QuestieLearnerExport
local QuestieLearnerExport = QuestieLoader:CreateModule("QuestieLearnerExport")
---@type QuestieLearner
local QuestieLearner = QuestieLoader:ImportModule("QuestieLearner")
---@type QuestieServer
local QuestieServer = QuestieLoader:ImportModule("QuestieServer")
local LibDeflate = LibStub("LibDeflate")
local AceSerializer = LibStub("AceSerializer-3.0")
local FORMAT_PREFIX = "QxLD"
local FORMAT_VERSION = 1
local FORMAT_SEP = "!"
local MAX_IMPORT_LEN = 524288 -- 512 KB hard cap on raw decoded payload
local _Export = QuestieLearnerExport.private or {}
QuestieLearnerExport.private = _Export
-- Cached last export string and stats for the UI to read without re-computing
QuestieLearnerExport.lastExportString = nil
QuestieLearnerExport.lastExportStats = nil
-- Cached last import validation result
QuestieLearnerExport.lastImportStats = nil
QuestieLearnerExport.lastImportData = nil
-----------------------------------------------------------------------
-- Internal helpers
-----------------------------------------------------------------------
local function CountTable(t)
if not t then return 0 end
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
local function GetServerKey()
if QuestieServer then
if Questie.IsAscension then return "Ascension" end
if Questie.IsEbonhold then return "Ebonhold" end
if Questie.IsEra then return "Era" end
if Questie.Is335 then return "WotLK" end
end
local realm = GetRealmName and GetRealmName() or "unknown"
return realm ~= "" and realm or "unknown"
end
function QuestieLearnerExport:GetExportTable(serverKey)
local ld = Questie.dbLearner and Questie.dbLearner.global
if not ld then return nil end
if ld[serverKey] then return ld[serverKey] end
-- Fallback: flat (pre-bucket) layout still in use
if ld.npcs or ld.quests then return ld end
return nil
end
-- Returns the learnedData sub-table for the current server, or nil
local function GetServerBucket(serverKey)
return QuestieLearnerExport:GetExportTable(serverKey)
end
-- Builds a lightweight stats summary table from a bucket
local function BuildStats(bucket)
if not bucket then return { npcs = 0, quests = 0, items = 0, objects = 0, total = 0 } end
local s = {
npcs = CountTable(bucket.npcs),
quests = CountTable(bucket.quests),
items = CountTable(bucket.items),
objects = CountTable(bucket.objects),
}
s.total = s.npcs + s.quests + s.items + s.objects
return s
end
-----------------------------------------------------------------------
-- Export
-----------------------------------------------------------------------
--- Serializes + deflates + encodes the learned data for the given server key.
--- Returns the export string and a stats table, or nil + error message.
---@param serverKey string|nil defaults to current server
---@return string|nil, table|string
function QuestieLearnerExport:Export(serverKey)
serverKey = serverKey or GetServerKey()
local bucket = GetServerBucket(serverKey)
if not bucket then
return nil, "No learned data found for server: " .. tostring(serverKey)
end
local stats = BuildStats(bucket)
if stats.total == 0 then
return nil, "Nothing to export — learned data is empty."
end
local payload = {
v = FORMAT_VERSION,
server = serverKey,
ts = time and time() or 0,
data = bucket,
}
local ok, serialized = pcall(AceSerializer.Serialize, AceSerializer, payload)
if not ok or not serialized then
return nil, "Serialization failed: " .. tostring(serialized)
end
local compressed = LibDeflate:CompressDeflate(serialized, { level = 9 })
if not compressed then
return nil, "Compression failed."
end
local encoded = LibDeflate:EncodeForPrint(compressed)
if not encoded then
return nil, "Encoding failed."
end
local result = FORMAT_PREFIX .. ":" .. FORMAT_VERSION .. FORMAT_SEP .. encoded
self.lastExportString = result
self.lastExportStats = stats
Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] Exported", stats.total,
"entries for", serverKey, "len:", string.len(result))
return result, stats
end
--- Exports ALL server buckets merged into one payload.
---@return string|nil, table|string
function QuestieLearnerExport:ExportAll()
local ld = Questie.dbLearner and Questie.dbLearner.global
if not ld then return nil, "No learned data." end
local merged = { npcs = {}, quests = {}, items = {}, objects = {} }
local function MergeBucket(b)
if not b then return end
for id, v in pairs(b.npcs or {}) do merged.npcs[id] = v end
for id, v in pairs(b.quests or {}) do merged.quests[id] = v end
for id, v in pairs(b.items or {}) do merged.items[id] = v end
for id, v in pairs(b.objects or {}) do merged.objects[id] = v end
end
-- Flat layout
if ld.npcs or ld.quests then
MergeBucket(ld)
else
for _, bucket in pairs(ld) do
if type(bucket) == "table" and bucket.npcs then
MergeBucket(bucket)
end
end
end
local stats = BuildStats(merged)
if stats.total == 0 then return nil, "Nothing to export." end
local payload = {
v = FORMAT_VERSION,
server = "all",
ts = time and time() or 0,
data = merged,
}
local ok, serialized = pcall(AceSerializer.Serialize, AceSerializer, payload)
if not ok or not serialized then
return nil, "Serialization failed."
end
local compressed = LibDeflate:CompressDeflate(serialized, { level = 9 })
if not compressed then return nil, "Compression failed." end
local encoded = LibDeflate:EncodeForPrint(compressed)
if not encoded then return nil, "Encoding failed." end
local result = FORMAT_PREFIX .. ":" .. FORMAT_VERSION .. FORMAT_SEP .. encoded
self.lastExportString = result
self.lastExportStats = stats
return result, stats
end
-----------------------------------------------------------------------
-- Import / Validate
-----------------------------------------------------------------------
--- Validates an import string and returns the decoded payload table,
--- or nil + error string. Does NOT merge — call MergeImport() after confirming.
---@param importStr string
---@return table|nil, string|table
function QuestieLearnerExport:ValidateImport(importStr)
self.lastImportData = nil
self.lastImportStats = nil
if not importStr or importStr == "" then
return nil, "Empty import string."
end
-- Strip whitespace
importStr = importStr:gsub("%s+", "")
-- Check prefix (e.g. "QxLD:"). NOTE: the previous check `not str:sub(...) == x` parsed as
-- `(not str:sub(...)) == x` which is always false, so invalid strings were never rejected.
if importStr:sub(1, #FORMAT_PREFIX + 1) ~= (FORMAT_PREFIX .. ":") then
return nil, "Not a Questie-X export string (missing " .. FORMAT_PREFIX .. " prefix)."
end
local sepPos = importStr:find(FORMAT_SEP, 1, true)
if not sepPos then
return nil, "Malformed string — missing separator."
end
local encoded = importStr:sub(sepPos + 1)
if string.len(encoded) > MAX_IMPORT_LEN then
return nil, "Import string too large (max 512 KB)."
end
local compressed = LibDeflate:DecodeForPrint(encoded)
if not compressed then
return nil, "Decode failed — string may be corrupted."
end
local serialized = LibDeflate:DecompressDeflate(compressed)
if not serialized then
return nil, "Decompression failed — string may be corrupted."
end
local ok, payload = AceSerializer:Deserialize(serialized)
if not ok or type(payload) ~= "table" then
return nil, "Deserialization failed — data is malformed."
end
if payload.v ~= FORMAT_VERSION then
return nil, "Unsupported format version: " .. tostring(payload.v)
end
local bucket = payload.data
if type(bucket) ~= "table" then
return nil, "Payload missing data table."
end
if type(bucket.npcs) ~= "table" or
type(bucket.quests) ~= "table" or
type(bucket.items) ~= "table" or
type(bucket.objects) ~= "table" then
return nil, "Payload data is missing required sub-tables (npcs/quests/items/objects)."
end
local stats = BuildStats(bucket)
stats.server = payload.server or "unknown"
stats.ts = payload.ts or 0
self.lastImportData = payload
self.lastImportStats = stats
Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] ValidateImport OK:",
stats.total, "entries from", stats.server)
return payload, stats
end
--- Merges previously validated import data into learnedData.
--- Must call ValidateImport() first.
---@return boolean, string
function QuestieLearnerExport:MergeImport()
if not self.lastImportData then
return false, "No validated import data. Run ValidateImport() first."
end
local payload = self.lastImportData
local bucket = payload.data
-- Ensure the learner stores exist before merging so a fresh/empty profile can still
-- accept an import (and so per-type merges never index a nil table).
local g = Questie.dbLearner and Questie.dbLearner.global
if not g then
return false, "Learner data is not initialized."
end
g.npcs = g.npcs or {}
g.quests = g.quests or {}
g.items = g.items or {}
g.objects = g.objects or {}
g.settings = g.settings or {}
local merged = 0
local skipped = 0
local rejected = 0
-- Apply each entry SYNCHRONOUSLY and DEFENSIVELY so importing data merged from several
-- different players is safe:
-- * each entry is validated for key/coordinate structure inside
-- _ApplyIncomingNetworkMerge (via _ValidateLearnedSpawnData) and only adopts fields
-- the local store is missing — it never overwrites good local data;
-- * a single malformed entry (corrupt coords, wrong types) is caught by pcall and
-- skipped/counted instead of aborting the whole import or corrupting the store;
-- * we merge synchronously (not via the async comms queue) so InjectLearnedData below
-- sees the merged data and the returned counts are accurate.
local function MergeType(typ, src)
if type(src) ~= "table" then return end
for id, d in pairs(src) do
local nid = tonumber(id) or id
if type(nid) == "number" and nid > 0 and type(d) == "table" then
local ok, applied = pcall(QuestieLearner._ApplyIncomingNetworkMerge, QuestieLearner, typ, nid, d)
if ok and applied then
merged = merged + 1
elseif ok then
skipped = skipped + 1
else
rejected = rejected + 1
end
else
rejected = rejected + 1
end
end
end
MergeType("NPC", bucket.npcs)
MergeType("QUEST", bucket.quests)
MergeType("ITEM", bucket.items)
MergeType("OBJECT", bucket.objects)
self.lastImportData = nil
self.lastImportStats = nil
-- Push merged data into QuestieDB overrides immediately (no reload required for override data)
if QuestieLearner and QuestieLearner.InjectLearnedData then
pcall(QuestieLearner.InjectLearnedData, QuestieLearner)
end
local msg = string.format("Import complete: merged %d, skipped %d already-known%s.",
merged, skipped,
rejected > 0 and (", rejected " .. rejected .. " malformed") or "")
Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport]", msg)
return true, msg
end
-----------------------------------------------------------------------
-- Cleanup / Prune
-----------------------------------------------------------------------
--- Returns a count of entries that would be pruned (dry run).
---@return table { npcs=N, quests=N, items=N, objects=N, total=N, reasons={} }
function QuestieLearnerExport:DryRunPrune()
return _Export:RunPrune(true)
end
--- Runs the actual prune and returns counts of removed entries.
---@return table
function QuestieLearnerExport:Prune()
return _Export:RunPrune(false)
end
local QuestieDB -- lazily imported to avoid circular dep
function _Export:RunPrune(dryRun)
if not QuestieDB then QuestieDB = QuestieLoader:ImportModule("QuestieDB") end
local serverKey = GetServerKey()
local bucket = GetServerBucket(serverKey)
local result = { npcs = 0, quests = 0, items = 0, objects = 0, total = 0, reasons = {} }
if not bucket then return result end
local settings = (Questie.dbLearner and Questie.dbLearner.global and Questie.dbLearner.global.settings) or {}
local thresholdDays = settings.staleThreshold or 90
local thresholdSeconds = thresholdDays * 86400
local minConfidence = settings.minConfidencePins or 2
local pruneVerified = settings.pruneVerified
local now = time()
local function ShouldPruneNPC(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 2 and not entry[7] then return "unverified with no coords" end
end
return nil
end
local function ShouldPruneQuest(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if QuestieDB and QuestieDB.GetQuest then
local dbEntry = QuestieDB.GetQuest(id)
if dbEntry and (entry.mc or 0) < 2 then
return "fully covered by official DB, mc < 2"
end
end
end
return nil
end
local function ShouldPruneItem(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 1 then return "zero match count" end
end
return nil
end
local function ShouldPruneObject(id, entry)
if CountTable(entry) == 0 then return "empty entry" end
local isVerified = (entry.mc or 0) >= minConfidence
if (not isVerified) and (now - (entry.ls or 0)) > thresholdSeconds then
return "unconfirmed and stale (> " .. thresholdDays .. " days)"
end
if pruneVerified or not isVerified then
if (entry.mc or 0) < 2 and not entry[4] then return "unverified with no coords" end
end
return nil
end
local function PruneStore(store, checkFn, typeName)
if not store then return end
for id, entry in pairs(store) do
local reason = checkFn(id, entry)
if reason then
result[typeName] = result[typeName] + 1
result.total = result.total + 1
table.insert(result.reasons, typeName .. ":" .. tostring(id) .. " — " .. reason)
if not dryRun then
store[id] = nil
end
Questie:Debug(Questie.DEBUG_DEVELOP, "[LearnerExport] Prune",
dryRun and "(dry)" or "", typeName, id, reason)
end
end
end
PruneStore(bucket.npcs, ShouldPruneNPC, "npcs")
PruneStore(bucket.quests, ShouldPruneQuest, "quests")
PruneStore(bucket.items, ShouldPruneItem, "items")
PruneStore(bucket.objects, ShouldPruneObject, "objects")
return result
end